From 7ff143753e679b3ab0c942a04033f7ea37656bad Mon Sep 17 00:00:00 2001 From: Miguel Aranha Baldi Horlle Date: Sat, 6 Apr 2024 21:24:53 -0300 Subject: [PATCH] New features - EditorConfig: .editorconfig - Issue's templates updated. - Support Kafka connection authentication (SASL_PLAINTEXT): windows and linux. - Messages page: * Experimental: copy messages on selection and paste as CSV - Trying GitHub workflow for publication. --- .editorconfig | 21 + .github/ISSUE_TEMPLATE/bug_report.md | 33 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/workflows/build_and_publish.yml | 53 ++ .vscode/settings.json | 3 +- Cargo.toml | 8 +- create_windows_installer.sh | 2 +- docker-compose.yml | 1 + src/backend/kafka.rs | 324 +++++----- src/backend/repository.rs | 295 +++++---- src/component/app.rs | 740 +++++++++++----------- src/component/connection_list.rs | 190 +++--- src/component/connection_page.rs | 375 +++++++---- src/component/messages/lists.rs | 301 +++++---- src/component/messages/messages_page.rs | 501 +++++++++------ src/component/status_bar.rs | 131 ++-- src/component/topics_page.rs | 359 +++++------ src/config.rs | 138 ++-- src/main.rs | 87 +-- src/modals/about.rs | 3 +- src/styles.css | 11 +- win-prepare-and-build.sh | 15 +- 22 files changed, 2071 insertions(+), 1540 deletions(-) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/build_and_publish.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5600faa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true +[*] +indent_style = space +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 + +[*.{build,css,doap,scss,ui,xml,xml.in,xml.in.in,yaml,yml}] +indent_size = 2 + +[*.{json,py,rs}] +indent_size = 4 + +[*.{c,h,h.in}] +indent_size = 2 +max_line_length = 80 + +[NEWS] +indent_size = 2 +max_line_length = 72 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..bf94fd0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml new file mode 100644 index 0000000..46c37ab --- /dev/null +++ b/.github/workflows/build_and_publish.yml @@ -0,0 +1,53 @@ +name: Build and publish + +on: + push: + pull_request: +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Build + run: docker-compose up + - name: Upload Artifact + uses: actions/upload-artifact@v2.3.1 + with: + name: executables + path: | + *.AppImage + build-windows: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build (Windows) + run: docker-compose -f docker-compose.windows.yml up + - name: Make Windows Installer + run: ./create_windows_installer.sh + - name: Zip Portable Windows Artifact + run: zip -r windows-portable.zip package + - name: Upload Artifact + uses: actions/upload-artifact@v2.3.1 + with: + name: executables + path: | + *.zip + *.exe + + publish: + needs: [build, build-windows] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - uses: actions/download-artifact@v2 + - uses: "marvinpinto/action-automatic-releases@v1.2.1" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: false + files: | + **/*.AppImage + **/*.zip + **/*.exe diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e94c91..2938df3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "rust-analyzer.linkedProjects": [ "./Cargo.toml" - ] + ], + "rust-analyzer.showUnlinkedFileNotification": false } diff --git a/Cargo.toml b/Cargo.toml index a6c5359..5102ec0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,6 @@ gtk = { version = "0.8.1", package = "gtk4", features = ["v4_8"] } adw = { version = "0.6.0", package = "libadwaita", features = ["v1_2"] } relm4 = { version = "0.8.0", features = ["libadwaita"] } relm4-components = "0.8.0" -rdkafka = { version = "0.36.2", features = ["cmake-build"] } rusqlite = { version = "0.31.0", features = ["bundled"] } tokio = "0.1.21" sourceview5 = { version = "0.8.0", features = ["v5_4"] } @@ -20,14 +19,21 @@ directories = "4.0.1" futures = { version = "0.3.25", default-features = false } serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.79" +ron = "0.8" tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", features = ["env-filter"] } tracing-tree = "0.2.4" thiserror = "1.0.58" 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" ] } + +[target.'cfg(target_os = "windows")'.dependencies] +sasl2-sys = { version = "0.1.20",features = ["openssl-vendored"]} [build-dependencies] +openssl-src = { version = "300", default-features = false, features = ["force-engine"] } #glib-build-tools = "0.19.0" [package.metadata.appimage] diff --git a/create_windows_installer.sh b/create_windows_installer.sh index f0fd128..d78bd84 100755 --- a/create_windows_installer.sh +++ b/create_windows_installer.sh @@ -6,4 +6,4 @@ docker cp LICENSE.md setup:/work/ docker start -i -a setup docker cp setup:/work/Output/. . docker rm setup -# rm -r package +zip -r package.zip package diff --git a/docker-compose.yml b/docker-compose.yml index 2864173..f9fec2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ version: "3" services: gtk4-rs: + #image: relm4-docker-appimage image: ghcr.io/miguelbaldi/relm4-docker:latest-appimage volumes: - .:/mnt:z diff --git a/src/backend/kafka.rs b/src/backend/kafka.rs index 689079d..9454920 100644 --- a/src/backend/kafka.rs +++ b/src/backend/kafka.rs @@ -1,17 +1,19 @@ use rdkafka::client::ClientContext; use rdkafka::config::{ClientConfig, FromClientConfigAndContext, RDKafkaLogLevel}; use rdkafka::consumer::stream_consumer::StreamConsumer; -use rdkafka::consumer::{BaseConsumer, Consumer, ConsumerContext}; +use rdkafka::consumer::{Consumer, ConsumerContext}; use rdkafka::error::KafkaResult; use rdkafka::message::Headers; use rdkafka::topic_partition_list::TopicPartitionList; use rdkafka::Message; use std::fmt::{self, Display}; -use std::time::Duration; -use tracing::{info, trace, warn}; +use std::time::{Duration, Instant}; +use tracing::{debug, info, trace, warn}; use crate::backend::repository::{KrustConnection, KrustHeader, KrustMessage}; +use super::repository::KrustConnectionSecurityType; + const TIMEOUT: Duration = Duration::from_millis(5000); const GROUP_ID: &str = "krust-kafka-client"; @@ -26,9 +28,9 @@ struct CustomContext; impl ClientContext for CustomContext {} impl ConsumerContext for CustomContext { - fn commit_callback(&self, result: KafkaResult<()>, _offsets: &TopicPartitionList) { - info!("Committing offsets: {:?}", result); - } + fn commit_callback(&self, result: KafkaResult<()>, _offsets: &TopicPartitionList) { + info!("Committing offsets: {:?}", result); + } } // A type alias with your custom consumer can be created for convenience. @@ -38,165 +40,195 @@ type LoggingConsumer = StreamConsumer; #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq)] pub struct Partition { - pub id: i32, + pub id: i32, } #[derive(Debug, Default, Clone, PartialEq, PartialOrd, Eq)] pub struct Topic { - pub name: String, - pub partitions: Vec, + pub name: String, + pub partitions: Vec, } impl Display for Topic { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.name) - } + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.name) + } } #[derive(Debug, Clone)] pub struct KafkaBackend { - pub config: KrustConnection, + pub config: KrustConnection, } impl KafkaBackend { - pub fn new(config: KrustConnection) -> Self { - Self { config: config } - } - - fn consumer(&self, context: C) -> KafkaResult - where - C: ClientContext, - T: FromClientConfigAndContext, - { - ClientConfig::new() - .set("bootstrap.servers", self.config.brokers_list.as_str()) - .set("group.id", GROUP_ID) - .set("enable.partition.eof", "false") - .set("session.timeout.ms", "6000") - .set("enable.auto.commit", "false") - //.set("statistics.interval.ms", "30000") - .set("auto.offset.reset", "earliest") - .set_log_level(RDKafkaLogLevel::Debug) - .create_with_context::(context) - } - - pub async fn list_topics(&self) -> Vec { - let consumer: BaseConsumer = ClientConfig::new() - .set("bootstrap.servers", self.config.brokers_list.as_str()) - .create() - .expect("Consumer creation failed"); - - trace!("Consumer created"); - - let metadata = consumer - .fetch_metadata(None, TIMEOUT) - .expect("Failed to fetch metadata"); - - let mut topics = vec![]; - for topic in metadata.topics() { - let mut partitions = vec![]; - for partition in topic.partitions() { - partitions.push(Partition { id: partition.id() }); - } - - topics.push(Topic { - name: topic.name().to_string(), - partitions: partitions, - }); + pub fn new(config: KrustConnection) -> Self { + Self { config: config } } - topics - } - - pub fn topic_message_count(&self, topic: String) -> usize { - let consumer: BaseConsumer = ClientConfig::new() - .set("bootstrap.servers", self.config.brokers_list.as_str()) - .create() - .expect("Consumer creation failed"); - - trace!("Consumer created"); - - let metadata = consumer - .fetch_metadata(Some(&topic.as_str()), TIMEOUT) - .expect("Failed to fetch metadata"); - - let mut message_count: usize = 0; - match metadata.topics().first() { - Some(t) => { - for partition in t.partitions() { - let (low, high) = consumer - .fetch_watermarks(t.name(), partition.id(), Duration::from_secs(1)) - .unwrap_or((-1, -1)); - trace!( - "Low watermark: {} High watermark: {} (difference: {})", - low, - high, - high - low - ); - message_count += usize::try_from(high).unwrap() - usize::try_from(low).unwrap(); + + fn consumer(&self, context: C) -> KafkaResult + where + C: ClientContext, + T: FromClientConfigAndContext, + { + match self.config.security_type { + KrustConnectionSecurityType::SASL_PLAINTEXT => { + ClientConfig::new() + .set("bootstrap.servers", self.config.brokers_list.clone()) + .set("group.id", GROUP_ID) + .set("enable.partition.eof", "false") + .set("session.timeout.ms", "6000") + .set("enable.auto.commit", "false") + //.set("statistics.interval.ms", "30000") + .set("auto.offset.reset", "earliest") + .set("security.protocol", self.config.security_type.to_string()) + .set( + "sasl.mechanisms", + self.config.sasl_mechanism.clone().unwrap_or_default(), + ) + .set( + "sasl.username", + self.config.sasl_username.clone().unwrap_or_default(), + ) + .set( + "sasl.password", + self.config.sasl_password.clone().unwrap_or_default(), + ) + //.set("sasl.jaas.config", self.config.jaas_config.clone().unwrap_or_default()) + .set_log_level(RDKafkaLogLevel::Debug) + .create_with_context::(context) + } + _ => { + ClientConfig::new() + .set("bootstrap.servers", self.config.brokers_list.clone()) + .set("group.id", GROUP_ID) + .set("enable.partition.eof", "false") + .set("session.timeout.ms", "6000") + .set("enable.auto.commit", "false") + //.set("statistics.interval.ms", "30000") + .set("auto.offset.reset", "earliest") + .create_with_context::(context) + } } - } - None => warn!(""), } - - message_count - } - pub async fn list_messages_for_topic(&self, topic: String) -> Vec { - let topic_name = topic.as_str(); - let context = CustomContext; - let consumer: LoggingConsumer = self.consumer(context).expect("Consumer creation failed"); - - trace!("Consumer created"); - let total = self.topic_message_count(topic.clone()); - let mut counter = 0; - - consumer - .subscribe(&[topic_name]) - .expect("Can't subscribe to specified topics"); - - let mut messages = vec![]; - - 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); - "" + + pub async fn list_topics(&self) -> Vec { + let context = CustomContext; + let consumer: LoggingConsumer = self.consumer(context).expect("Consumer creation failed"); + + trace!("Consumer created"); + + let metadata = consumer + .fetch_metadata(None, TIMEOUT) + .expect("Failed to fetch metadata"); + + let mut topics = vec![]; + for topic in metadata.topics() { + let mut partitions = vec![]; + for partition in topic.partitions() { + partitions.push(Partition { id: partition.id() }); } - }; - info!("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()), - }; - header_list.push(h); + + topics.push(Topic { + name: topic.name().to_string(), + partitions: partitions, + }); + } + topics + } + + pub fn topic_message_count(&self, topic: String) -> usize { + let context = CustomContext; + let consumer: LoggingConsumer = self.consumer(context).expect("Consumer creation failed"); + + debug!("Consumer created"); + + let metadata = consumer + .fetch_metadata(Some(&topic.as_str()), TIMEOUT) + .expect("Failed to fetch metadata"); + + let mut message_count: usize = 0; + match metadata.topics().first() { + Some(t) => { + for partition in t.partitions() { + let (low, high) = consumer + .fetch_watermarks(t.name(), partition.id(), Duration::from_secs(1)) + .unwrap_or((-1, -1)); + trace!( + "Low watermark: {} High watermark: {} (difference: {})", + low, + high, + high - low + ); + message_count += usize::try_from(high).unwrap() - usize::try_from(low).unwrap(); + } } - header_list - } else { - vec![] - }; - messages.push(KrustMessage { - id: None, - connection_id: None, - topic: m.topic().to_string(), - partition: m.partition(), - offset: m.offset(), - timestamp: m.timestamp().to_millis(), - value: payload.to_string(), - headers: headers, - }); - counter += 1; - //consumer.commit_message(&m, CommitMode::Async).unwrap(); + None => warn!(""), + } + debug!("Topic {} has {} messages", topic, message_count); + message_count + } + pub async fn list_messages_for_topic(&self, topic: String) -> Vec { + let start_mark = Instant::now(); + let topic_name = topic.as_str(); + let context = CustomContext; + let consumer: LoggingConsumer = self.consumer(context).expect("Consumer creation failed"); + + debug!("Consumer created"); + let total = self.topic_message_count(topic.clone()); + let mut counter = 0; + + consumer + .subscribe(&[topic_name]) + .expect("Can't subscribe to specified topics"); + + let mut messages = vec![]; + + 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); + "" + } + }; + 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![] + }; + messages.push(KrustMessage { + id: None, + connection_id: None, + topic: m.topic().to_string(), + partition: m.partition(), + offset: m.offset(), + timestamp: m.timestamp().to_millis(), + value: payload.to_string(), + headers: headers, + }); + counter += 1; + //consumer.commit_message(&m, CommitMode::Async).unwrap(); + } + }; } - }; + let duration = start_mark.elapsed(); + info!("duration: {:?}", duration); + messages } - messages - } } diff --git a/src/backend/repository.rs b/src/backend/repository.rs index 099bc14..fba8c51 100644 --- a/src/backend/repository.rs +++ b/src/backend/repository.rs @@ -1,151 +1,208 @@ +use std::str::FromStr; +use std::string::ToString; + use rusqlite::{named_params, params, Row}; +use strum::EnumString; use crate::config::{database_connection, ExternalError}; +#[derive(Debug, Clone, PartialEq, Default, EnumString, strum::Display)] +pub enum KrustConnectionSecurityType { + #[default] + PLAINTEXT, + SASL_PLAINTEXT, +} + +impl KrustConnectionSecurityType { + pub const VALUES: [Self; 2] = [Self::PLAINTEXT, Self::SASL_PLAINTEXT]; +} #[derive(Debug, Clone, Default)] pub struct KrustConnection { - pub id: Option, - pub name: String, - pub brokers_list: String, - pub security_type: Option, - pub sasl_mechanism: Option, - pub jaas_config: Option, + pub id: Option, + pub name: String, + pub brokers_list: String, + pub security_type: KrustConnectionSecurityType, + pub sasl_mechanism: Option, + pub sasl_username: Option, + pub sasl_password: Option, } #[derive(Debug, Clone, Default)] pub struct KrustMessage { - pub id: Option, - pub connection_id: Option, - pub topic: String, - pub partition: i32, - pub offset: i64, - pub value: String, - pub timestamp: Option, - pub headers: Vec, + pub id: Option, + pub connection_id: Option, + pub topic: String, + pub partition: i32, + pub offset: i64, + pub value: String, + pub timestamp: Option, + pub headers: Vec, } #[derive(Debug, Clone, Default)] pub struct KrustHeader { - pub key: String, - pub value: Option, + pub key: String, + pub value: Option, } -pub struct Repository{ - conn: rusqlite::Connection, +pub struct Repository { + conn: rusqlite::Connection, } impl Repository { - pub fn new() -> Self { - let conn = database_connection().unwrap(); - Self { conn } - } + pub fn new() -> Self { + let conn = database_connection().unwrap(); + Self { conn } + } - pub fn init(&mut self) -> Result<(), ExternalError> { - self.conn.execute_batch(" - CREATE TABLE IF NOT EXISTS kl_connection(id INTEGER PRIMARY KEY, name TEXT UNIQUE, brokersList TEXT, securityType TEXT, saslMechanism TEXT, jaasConfig TEXT); + pub fn init(&mut self) -> Result<(), ExternalError> { + self.conn.execute_batch(" + CREATE TABLE IF NOT EXISTS kl_connection(id INTEGER PRIMARY KEY, name TEXT UNIQUE, brokersList TEXT, securityType TEXT, saslMechanism TEXT, saslUsername TEXT, saslPassword TEXT); CREATE TABLE IF NOT EXISTS kl_message (connection INTEGER, topic TEXT, partition INTEGER, offset INTEGER, value TEXT, timestamp TEXT, PRIMARY KEY (connection, topic, partition, offset)); CREATE INDEX IF NOT EXISTS idx_connection_topic ON kl_message (connection, topic); CREATE INDEX IF NOT EXISTS idx_value ON kl_message (value); ").map_err(ExternalError::DatabaseError) - } - - pub fn list_all_connections(&mut self) -> Result, ExternalError> { - let mut stmt = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, jaasConfig from kl_connection")?; - let rows = stmt.query_map([], |row| { - Ok(KrustConnection { - id: row.get(0)?, - name: row.get(1)?, - brokers_list: row.get(2)?, - security_type: row.get(3)?, - sasl_mechanism: row.get(4)?, - jaas_config: row.get(5)?, - }) - }).map_err(ExternalError::DatabaseError)?; - let mut connections = Vec::new(); - for row in rows { - connections.push(row?); } - Ok(connections) - } - - pub fn save_connection(&mut self, konn: &KrustConnection) -> Result { - let id = konn.id.clone(); - let name = konn.name.clone(); - let brokers = konn.brokers_list.clone(); - let security = konn.security_type.clone(); - let sasl = konn.sasl_mechanism.clone(); - let jaas = konn.jaas_config.clone(); - let mut stmt_by_id = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, jaasConfig from kl_connection where id = ?1")?; - let mut stmt_by_name = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, jaasConfig from kl_connection where name = ?1")?; - let row_to_model = move |row: &Row<'_>| { - Ok(KrustConnection { - id: row.get(0)?, - name: row.get(1)?, - brokers_list: row.get(2)?, - security_type: row.get(3)?, - sasl_mechanism: row.get(4)?, - jaas_config: row.get(5)?, - }) - }; - let maybe_konn = match id { - Some(_) =>{ - stmt_by_id.query_row([&id], row_to_model).map_err(ExternalError::DatabaseError) - }, - None => { - stmt_by_name.query_row([&name], row_to_model).map_err(ExternalError::DatabaseError) - } - }; - - match maybe_konn { - Ok(konn_to_update) => { - let mut up_stmt = self.conn.prepare_cached("UPDATE kl_connection SET name = :name, brokersList = :brokers, securityType = :security, saslMechanism = :sasl, jaasConfig = :jaas WHERE id = :id")?; - up_stmt - .execute(named_params! { ":id": &konn_to_update.id.unwrap(), ":name": &name, ":brokers": &brokers, ":security": &security, ":sasl": &sasl, ":jaas": &jaas }) + + pub fn list_all_connections(&mut self) -> Result, ExternalError> { + let mut stmt = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, saslUsername, saslPassword from kl_connection")?; + let rows = stmt + .query_map([], |row| { + Ok(KrustConnection { + id: row.get(0)?, + name: row.get(1)?, + brokers_list: row.get(2)?, + security_type: KrustConnectionSecurityType::from_str( + row.get::(3)?.as_str(), + ) + .unwrap_or_default(), + sasl_mechanism: row.get(4)?, + sasl_username: row.get(5)?, + sasl_password: row.get(6)?, + }) + }) + .map_err(ExternalError::DatabaseError)?; + let mut connections = Vec::new(); + for row in rows { + connections.push(row?); + } + Ok(connections) + } + + pub fn save_connection( + &mut self, + konn: &KrustConnection, + ) -> Result { + let id = konn.id.clone(); + let name = konn.name.clone(); + let brokers = konn.brokers_list.clone(); + let security = konn.security_type.clone(); + let sasl = konn.sasl_mechanism.clone(); + let sasl_username = konn.sasl_username.clone(); + let sasl_password = konn.sasl_password.clone(); + let mut stmt_by_id = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, saslUsername, saslPassword from kl_connection where id = ?1")?; + let mut stmt_by_name = self.conn.prepare_cached("SELECT id, name, brokersList, securityType, saslMechanism, saslUsername, saslPassword from kl_connection where name = ?1")?; + let row_to_model = move |row: &Row<'_>| { + Ok(KrustConnection { + id: row.get(0)?, + name: row.get(1)?, + brokers_list: row.get(2)?, + security_type: KrustConnectionSecurityType::from_str( + row.get::(3)?.as_str(), + ) + .unwrap_or_default(), + sasl_mechanism: row.get(4)?, + sasl_username: row.get(5)?, + sasl_password: row.get(6)?, + }) + }; + let maybe_konn = match id { + Some(_) => stmt_by_id + .query_row([&id], row_to_model) + .map_err(ExternalError::DatabaseError), + None => stmt_by_name + .query_row([&name], row_to_model) + .map_err(ExternalError::DatabaseError), + }; + + match maybe_konn { + Ok(konn_to_update) => { + let mut up_stmt = self.conn.prepare_cached("UPDATE kl_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: name, brokers_list: brokers, security_type: security, sasl_mechanism: sasl, jaas_config: jaas }}) - } - Err(_) => { - let mut ins_stmt = self.conn.prepare_cached("INSERT INTO kl_connection (id, name, brokersList, securityType, saslMechanism, jaasConfig) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")?; - ins_stmt.query_row(params![ - &konn.id, - &konn.name, - &konn.brokers_list, - None::, - None::, - None::, - ], |row| { Ok(KrustConnection { id: row.get(0)?, name: name, brokers_list: brokers, security_type: security, sasl_mechanism: sasl, jaas_config: jaas, })}) - .map_err(ExternalError::DatabaseError) + .map( |_| {KrustConnection { id: konn_to_update.id, name: name, brokers_list: brokers, security_type: security, sasl_mechanism: sasl, sasl_username: sasl_username, sasl_password: sasl_password }}) + } + Err(_) => { + let mut ins_stmt = self.conn.prepare_cached("INSERT INTO kl_connection (id, name, brokersList, securityType, saslMechanism, saslUsername, saslPassword) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")?; + ins_stmt + .query_row( + params![ + &konn.id, + &konn.name, + &konn.brokers_list, + &konn.security_type.to_string(), + &konn.sasl_mechanism, + &konn.sasl_username, + &konn.sasl_password, + ], + |row| { + Ok(KrustConnection { + id: row.get(0)?, + name: name, + brokers_list: brokers, + security_type: security, + sasl_mechanism: sasl, + sasl_username: sasl_username, + sasl_password: sasl_password, + }) + }, + ) + .map_err(ExternalError::DatabaseError) + } } - } - //Ok(Konnection {id: None, name: "".into()}) + //Ok(Konnection {id: None, name: "".into()}) } - 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 kl_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 delete_all_messages_for_topic( + &mut self, + conn_id: usize, + topic_name: String, + ) -> Result { + let mut delete_stmt = self.conn.prepare_cached( + "DELETE from kl_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 {id: _, connection_id, topic, partition, offset, value, timestamp, headers} = message; - let mut insert_stmt = self.conn.prepare_cached("INSERT INTO kl_message (connection, topic, partition, offset, value, timestamp) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")?; - let result = insert_stmt.query_row(params![ - &message.connection_id, - ], |row| { - Ok( - KrustMessage { - id: row.get(0)?, - connection_id: connection_id, - topic: topic, - partition: partition, - offset: offset, - value: value, - timestamp: timestamp, - headers: headers - } - ) - }) - .map_err(ExternalError::DatabaseError)?; - Ok(result) + let KrustMessage { + id: _, + connection_id, + topic, + partition, + offset, + value, + timestamp, + headers, + } = message; + let mut insert_stmt = self.conn.prepare_cached("INSERT INTO kl_message (connection, topic, partition, offset, value, timestamp) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")?; + let result = insert_stmt + .query_row(params![&message.connection_id,], |row| { + Ok(KrustMessage { + id: row.get(0)?, + connection_id: connection_id, + topic: topic, + partition: partition, + offset: offset, + value: value, + timestamp: timestamp, + headers: headers, + }) + }) + .map_err(ExternalError::DatabaseError)?; + Ok(result) } - } \ No newline at end of file +} diff --git a/src/component/app.rs b/src/component/app.rs index 2892ef4..b51177f 100644 --- a/src/component/app.rs +++ b/src/component/app.rs @@ -1,111 +1,127 @@ //! Application entrypoint. use gtk::prelude::*; -use relm4::{actions::{RelmAction, RelmActionGroup}, factory::FactoryVecDeque, main_application, prelude::*}; +use relm4::{ + actions::{RelmAction, RelmActionGroup}, + factory::FactoryVecDeque, + main_application, + prelude::*, +}; use tracing::{error, info, warn}; use crate::{ - backend::repository::{KrustConnection, KrustMessage, Repository}, component::{ - connection_list::KrustConnectionOutput, connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, status_bar::{StatusBarModel, STATUS_BROKER}, topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput} - }, config::State, modals::about::AboutDialog + backend::{ + kafka::Topic, + repository::{KrustConnection, Repository}, + }, + component::{ + connection_list::KrustConnectionOutput, + connection_page::{ConnectionPageModel, ConnectionPageMsg, ConnectionPageOutput}, + status_bar::{StatusBarModel, STATUS_BROKER}, + topics_page::{TopicsPageModel, TopicsPageMsg, TopicsPageOutput}, + }, + config::State, + modals::about::AboutDialog, }; -use super::{connection_list::ConnectionListModel, messages::messages_page::{MessagesPageModel, MessagesPageMsg}}; +use super::{ + connection_list::ConnectionListModel, + messages::messages_page::{MessagesPageModel, MessagesPageMsg}, +}; #[derive(Debug)] struct DialogModel { - hidden: bool, + hidden: bool, } #[derive(Debug)] enum DialogInput { - Show, - Accept, - Cancel, + Show, + Accept, + Cancel, } #[derive(Debug)] enum DialogOutput { - Close, + Close, } #[relm4::component] impl SimpleComponent for DialogModel { - type Init = bool; - type Input = DialogInput; - type Output = DialogOutput; - - view! { - #[name(message_dialog)] - gtk::MessageDialog { - set_modal: true, - set_default_height: 160, - #[watch] - set_visible: !model.hidden, - set_text: Some("Do you want to close before saving?"), - set_secondary_text: Some("All unsaved changes will be lost"), - add_button: ("Close", gtk::ResponseType::Accept), - add_button: ("Cancel", gtk::ResponseType::Cancel), - connect_response[sender] => move |_, resp| { - sender.input(if resp == gtk::ResponseType::Accept { - DialogInput::Accept - } else { - DialogInput::Cancel - }) + type Init = bool; + type Input = DialogInput; + type Output = DialogOutput; + + view! { + #[name(message_dialog)] + gtk::MessageDialog { + set_modal: true, + set_default_height: 160, + #[watch] + set_visible: !model.hidden, + set_text: Some("Do you want to close before saving?"), + set_secondary_text: Some("All unsaved changes will be lost"), + add_button: ("Close", gtk::ResponseType::Accept), + add_button: ("Cancel", gtk::ResponseType::Cancel), + connect_response[sender] => move |_, resp| { + sender.input(if resp == gtk::ResponseType::Accept { + DialogInput::Accept + } else { + DialogInput::Cancel + }) + } } } - } - - fn init( - params: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = DialogModel { hidden: params }; - let widgets = view_output!(); - ComponentParts { model, widgets } - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - DialogInput::Show => { - self.hidden = false; - } - DialogInput::Accept => { - self.hidden = true; - sender.output(DialogOutput::Close).unwrap() - } - DialogInput::Cancel => self.hidden = true, + + fn init( + params: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let model = DialogModel { hidden: params }; + let widgets = view_output!(); + ComponentParts { model, widgets } } - } -} + fn update(&mut self, msg: Self::Input, sender: ComponentSender) { + match msg { + DialogInput::Show => { + self.hidden = false; + } + DialogInput::Accept => { + self.hidden = true; + sender.output(DialogOutput::Close).unwrap() + } + DialogInput::Cancel => self.hidden = true, + } + } +} #[derive(Debug)] pub enum AppMsg { - CloseRequest(State), - Close, - AddConnection(KrustConnection), - ShowConnection, - SaveConnection(Option, KrustConnection), - ShowEditConnectionPage(DynamicIndex, KrustConnection), - ShowTopicsPage(KrustConnection), - ShowTopicsPageByIndex(i32), - ShowMessagesPage(Vec), - RemoveConnection(DynamicIndex), + CloseRequest(State), + Close, + AddConnection(KrustConnection), + ShowConnection, + SaveConnection(Option, KrustConnection), + ShowEditConnectionPage(DynamicIndex, KrustConnection), + ShowTopicsPage(KrustConnection), + ShowTopicsPageByIndex(i32), + ShowMessagesPage(KrustConnection, Topic), + RemoveConnection(DynamicIndex), } #[derive(Debug)] pub struct AppModel { - state: State, - _status_bar: Controller, - dialog: Controller, - _about_dialog: Controller, - connections: FactoryVecDeque, - main_stack: gtk::Stack, - connection_page: Controller, - topics_page: Controller, - messages_page: Controller, + state: State, + _status_bar: Controller, + dialog: Controller, + _about_dialog: Controller, + connections: FactoryVecDeque, + main_stack: gtk::Stack, + connection_page: Controller, + topics_page: Controller, + messages_page: Controller, } relm4::new_action_group!(pub(super) WindowActionGroup, "win"); @@ -115,305 +131,317 @@ relm4::new_stateless_action!(AboutAction, WindowActionGroup, "about"); #[relm4::component(pub)] impl Component for AppModel { - type Init = (); - type Input = AppMsg; - type Output = (); - type CommandOutput = (); - - menu! { - primary_menu: { - section! { - "_Add connection" => AddConnection, - "_Keyboard" => ShortcutsAction, - "_About" => AboutAction, + type Init = (); + type Input = AppMsg; + type Output = (); + type CommandOutput = (); + + menu! { + primary_menu: { + section! { + "_Add connection" => AddConnection, + "_Keyboard" => ShortcutsAction, + "_About" => AboutAction, + } } } - } - - view! { - main_window = adw::ApplicationWindow::new(&main_application()) { - set_visible: true, - set_maximized: state.is_maximized, - set_default_size: (state.width, state.height), - set_title: Some("KRust Kafka Client"), - 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, - set_position: state.separator_position, - #[wrap(Some)] - set_start_child = >k::ScrolledWindow { - set_min_content_width: 200, - set_hexpand: true, - set_vexpand: true, - set_propagate_natural_width: true, + + view! { + main_window = adw::ApplicationWindow::new(&main_application()) { + set_visible: true, + set_maximized: state.is_maximized, + set_default_size: (state.width, state.height), + set_title: Some("KRust Kafka Client"), + 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, + set_position: state.separator_position, #[wrap(Some)] - set_child = connections.widget() -> >k::ListBox { - set_selection_mode: gtk::SelectionMode::Single, + set_start_child = >k::ScrolledWindow { + set_min_content_width: 200, 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())); + 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())); + }, }, }, - }, - #[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 { - #[name(app_mode_label)] - gtk::Label { - #[watch] - set_label: "Home", + 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 { + #[name(app_mode_label)] + gtk::Label { + #[watch] + set_label: "Home", + }, + } -> { + set_title: "Home", + set_name: "Home", }, - } -> { - 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::Paned {} -> { - set_name: "Messages" - }, + 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::Paned {} -> { + set_name: "Messages" + }, + } } - } + }, }, + 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, main_paned] => move |this| { + let (width, height) = this.default_size(); + let is_maximized = this.is_maximized(); + let separator = main_paned.position(); + let new_state = State { + width, + height, + separator_position: separator, + is_maximized, + }; + + sender.input(AppMsg::CloseRequest(new_state)); + gtk::glib::Propagation::Stop + }, + + } + } + + fn init(_params: (), root: Self::Root, sender: ComponentSender) -> ComponentParts { + let state = State::read() + .map_err(|e| { + warn!("unable to read application state: {}", e); + e + }) + .unwrap_or_default(); + + let about_dialog = AboutDialog::builder() + .transient_for(&root) + .launch(()) + .detach(); + + let status_bar: Controller = StatusBarModel::builder() + .launch_with_broker((), &STATUS_BROKER) + .detach(); + + let dialog = DialogModel::builder() + .transient_for(&root) + .launch(true) + .forward(sender.input_sender(), |msg| match msg { + DialogOutput::Close => AppMsg::Close, + }); + + let connections = FactoryVecDeque::builder() + .launch(gtk::ListBox::default()) + .forward(sender.input_sender(), |output| match output { + KrustConnectionOutput::Add => AppMsg::ShowConnection, + KrustConnectionOutput::Remove(index) => AppMsg::RemoveConnection(index), + KrustConnectionOutput::Edit(index, conn) => { + AppMsg::ShowEditConnectionPage(index, conn) + } + KrustConnectionOutput::ShowTopics(conn) => AppMsg::ShowTopicsPage(conn), + }); + + let connection_page: Controller = ConnectionPageModel::builder() + .launch(None) + .forward(sender.input_sender(), |msg| match msg { + ConnectionPageOutput::Save(index, conn) => AppMsg::SaveConnection(index, conn), + }); + + let topics_page: Controller = TopicsPageModel::builder() + .launch(None) + .forward(sender.input_sender(), |msg| match msg { + TopicsPageOutput::OpenMessagesPage(connection, topic) => { + AppMsg::ShowMessagesPage(connection, topic) + } + }); + + let messages_page: Controller = + MessagesPageModel::builder().launch(()).detach(); + + info!("starting with application state: {:?}", state); + //let connection_listbox: gtk::ListBox = connections.widget(); + let widgets = view_output!(); + + let mut actions = RelmActionGroup::::new(); + + let add_connection_action = { + let input_sender = sender.clone(); + RelmAction::::new_stateless(move |_| { + input_sender.input(AppMsg::ShowConnection); + }) + }; + + let about_action = { + let about_sender = about_dialog.sender().clone(); + RelmAction::::new_stateless(move |_| { + about_sender.send(()).unwrap(); + }) + }; + + actions.add_action(add_connection_action); + actions.add_action(about_action); + actions.register_for_widget(&widgets.main_window); + + let mut repo = Repository::new(); + let conn_list = repo.list_all_connections(); + match conn_list { + Ok(list) => { + for conn in list { + sender.input(AppMsg::AddConnection(conn)); + } + } + Err(e) => error!("error loading connections: {:?}", e), } - }, - - connect_close_request[sender, main_paned] => move |this| { - let (width, height) = this.default_size(); - let is_maximized = this.is_maximized(); - let separator = main_paned.position(); - let new_state = State { - width, - height, - separator_position: separator, - is_maximized, + + let model = AppModel { + state, + _status_bar: status_bar, + dialog, + _about_dialog: about_dialog, + connections, + main_stack: widgets.main_stack.to_owned(), + connection_page, + topics_page, + messages_page, }; - - sender.input(AppMsg::CloseRequest(new_state)); - gtk::glib::Propagation::Stop - }, - + + ComponentParts { model, widgets } } - } - - fn init( - _params: (), - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let state = State::read() - .map_err(|e| { - warn!("unable to read application state: {}", e); - e - }) - .unwrap_or_default(); - - let about_dialog = AboutDialog::builder() - .transient_for(&root) - .launch(()) - .detach(); - - let status_bar: Controller = - StatusBarModel::builder() - .launch_with_broker((), &STATUS_BROKER) - .detach(); - - let dialog = DialogModel::builder() - .transient_for(&root) - .launch(true) - .forward(sender.input_sender(), |msg| match msg { - DialogOutput::Close => AppMsg::Close, - }); - - let connections = FactoryVecDeque::builder() - .launch(gtk::ListBox::default()) - .forward(sender.input_sender(), |output| match output { - KrustConnectionOutput::Add => AppMsg::ShowConnection, - KrustConnectionOutput::Remove(index) => AppMsg::RemoveConnection(index), - KrustConnectionOutput::Edit(index, conn) => AppMsg::ShowEditConnectionPage(index, conn), - KrustConnectionOutput::ShowTopics(conn) => AppMsg::ShowTopicsPage(conn), - }); - - let connection_page: Controller = ConnectionPageModel::builder() - .launch(None) - .forward(sender.input_sender(), |msg| match msg { - ConnectionPageOutput::Save(index, conn) => AppMsg::SaveConnection(index,conn), - }); - - let topics_page: Controller = TopicsPageModel::builder() - .launch(None) - .forward(sender.input_sender(), |msg| match msg { - TopicsPageOutput::OpenMessagesPage(data) => AppMsg::ShowMessagesPage(data), - }); - - let messages_page: Controller = MessagesPageModel::builder() - .launch(None) - .detach(); - - info!("starting with application state: {:?}", state); - //let connection_listbox: gtk::ListBox = connections.widget(); - let widgets = view_output!(); - - let mut actions = RelmActionGroup::::new(); - - let add_connection_action = { - let input_sender = sender.clone(); - RelmAction::::new_stateless(move |_| { - input_sender.input(AppMsg::ShowConnection); - }) - }; - - let about_action = { - let about_sender = about_dialog.sender().clone(); - RelmAction::::new_stateless(move |_| { - about_sender.send(()).unwrap(); - }) - }; - - actions.add_action(add_connection_action); - actions.add_action(about_action); - actions.register_for_widget(&widgets.main_window); - - let mut repo = Repository::new(); - let conn_list = repo.list_all_connections(); - match conn_list { - Ok(list) => { - for conn in list { - sender.input(AppMsg::AddConnection(conn)); + + fn update(&mut self, msg: Self::Input, _sender: ComponentSender, _: &Self::Root) { + match msg { + AppMsg::CloseRequest(state) => { + self.state = state; + self.dialog.sender().send(DialogInput::Show).unwrap(); + } + AppMsg::Close => { + if let Err(e) = self.state.write() { + warn!("unable to write application state: {}", e); + } + relm4::main_application().quit(); + } + AppMsg::ShowConnection => { + info!("|-->Showing new connection page"); + self.connection_page.emit(ConnectionPageMsg::New); + self.connection_page.widget().set_visible(true); + self.main_stack.set_visible_child_name("Connection"); + } + AppMsg::AddConnection(conn) => { + info!("|-->Adding connection "); + + self.connections.guard().push_back(conn); + } + AppMsg::SaveConnection(maybe_idx, conn) => { + info!("|-->Saving connection {:?}", conn); + + self.main_stack.set_visible_child_name("Home"); + let mut repo = Repository::new(); + let result = repo.save_connection(&conn); + match (maybe_idx, result) { + (None, Ok(new_conn)) => { + self.connections.guard().push_back(new_conn); + } + (Some(idx), Ok(new_conn)) => { + match self.connections.guard().get_mut(idx.current_index()) { + Some(conn_to_update) => { + conn_to_update.name = new_conn.name; + conn_to_update.brokers_list = new_conn.brokers_list; + conn_to_update.security_type = new_conn.security_type; + conn_to_update.sasl_mechanism = new_conn.sasl_mechanism; + conn_to_update.sasl_username = new_conn.sasl_username; + conn_to_update.sasl_password = new_conn.sasl_password; + } + None => todo!(), + }; + } + (_, Err(e)) => { + error!("error saving connection: {:?}", e); + } + }; + } + AppMsg::ShowEditConnectionPage(index, conn) => { + info!("|-->Show edit connection page for {:?}", conn); + self.connection_page + .emit(ConnectionPageMsg::Edit(index, conn)); + self.main_stack.set_visible_child_name("Connection"); + } + AppMsg::ShowTopicsPage(conn) => { + info!("|-->Show edit connection page for {:?}", conn); + self.topics_page.emit(TopicsPageMsg::List(conn)); + self.main_stack.set_visible_child_name("Topics"); + } + AppMsg::ShowTopicsPageByIndex(idx) => { + let is_connected = self + .connections + .guard() + .get(idx as usize) + .unwrap() + .is_connected; + if is_connected { + let conn: KrustConnection = self + .connections + .guard() + .get_mut(idx as usize) + .unwrap() + .into(); + info!( + "|-->Show edit connection page for index {:?} - {:?}", + idx, conn + ); + self.topics_page.emit(TopicsPageMsg::List(conn)); + self.main_stack.set_visible_child_name("Topics"); + } else { + self.main_stack.set_visible_child_name("Home"); + } + } + AppMsg::RemoveConnection(index) => { + info!("Removing connection {:?}", index); + } + AppMsg::ShowMessagesPage(connection, topic) => { + self.messages_page + .emit(MessagesPageMsg::Open(connection, topic)); + self.main_stack.set_visible_child_name("Messages"); + } } - }, - Err(e) => error!("error loading connections: {:?}", e), } - - let model = AppModel { - state, - _status_bar: status_bar, - dialog, - _about_dialog: about_dialog, - connections, - main_stack: widgets.main_stack.to_owned(), - connection_page, - topics_page, - messages_page, - }; - - ComponentParts { model, widgets } - } - - fn update(&mut self, msg: Self::Input, _sender: ComponentSender, _: &Self::Root) { - match msg { - AppMsg::CloseRequest(state) => { - self.state = state; - self.dialog.sender().send(DialogInput::Show).unwrap(); - } - AppMsg::Close => { - if let Err(e) = self.state.write() { - warn!("unable to write application state: {}", e); - } - relm4::main_application().quit(); - } - AppMsg::ShowConnection => { - info!("|-->Showing new connection page"); - self.connection_page.emit(ConnectionPageMsg::New); - self.connection_page.widget().set_visible(true); - self.main_stack.set_visible_child_name("Connection"); - } - AppMsg::AddConnection(conn) => { - info!("|-->Adding connection "); - - self.connections.guard().push_back(conn); - } - AppMsg::SaveConnection(maybe_idx, conn) => { - info!("|-->Saving connection {:?}", conn); - - self.main_stack.set_visible_child_name("Home"); - let mut repo = Repository::new(); - let result = repo.save_connection(&conn); - match (maybe_idx, result) { - (None, Ok(new_conn)) => { - self.connections.guard().push_back(new_conn); - }, - (Some(idx), Ok(new_conn)) => { - match self.connections.guard().get_mut(idx.current_index()) { - Some(conn_to_update) => { - conn_to_update.name = new_conn.name; - conn_to_update.brokers_list = new_conn.brokers_list; - conn_to_update.security_type = new_conn.security_type; - conn_to_update.sasl_mechanism = new_conn.sasl_mechanism; - conn_to_update.jaas_config = new_conn.jaas_config; - }, - None => todo!(), - }; - }, - (_, Err(e)) => {error!("error saving connection: {:?}", e);}, + fn post_view(&self, widgets: &mut Self::Widgets) { + if self.state.is_maximized { + info!("should maximize"); + widgets.main_window.maximize(); }; - } - AppMsg::ShowEditConnectionPage(index, conn) => { - info!("|-->Show edit connection page for {:?}", conn); - self.connection_page.emit(ConnectionPageMsg::Edit(index, conn)); - self.main_stack.set_visible_child_name("Connection"); - - } - AppMsg::ShowTopicsPage(conn) => { - info!("|-->Show edit connection page for {:?}", conn); - self.topics_page.emit(TopicsPageMsg::List(conn)); - self.main_stack.set_visible_child_name("Topics"); - - } - AppMsg::ShowTopicsPageByIndex(idx) => { - let is_connected = self.connections.guard().get(idx as usize).unwrap().is_connected; - if is_connected { - let conn: KrustConnection = self.connections.guard().get_mut(idx as usize).unwrap().into(); - info!("|-->Show edit connection page for index {:?} - {:?}", idx, conn); - self.topics_page.emit(TopicsPageMsg::List(conn)); - self.main_stack.set_visible_child_name("Topics"); - } else { - self.main_stack.set_visible_child_name("Home"); - } - - } - AppMsg::RemoveConnection(index) => { - info!("Removing connection {:?}", index); - } - AppMsg::ShowMessagesPage(messages) => { - self.messages_page.emit(MessagesPageMsg::List(messages)); - self.main_stack.set_visible_child_name("Messages"); - } } - } - fn post_view(&self, widgets: &mut Self::Widgets) { - if self.state.is_maximized { - info!("should maximize"); - widgets.main_window.maximize(); - }; - - } } diff --git a/src/component/connection_list.rs b/src/component/connection_list.rs index 6f2a1ad..98d5256 100644 --- a/src/component/connection_list.rs +++ b/src/component/connection_list.rs @@ -1,124 +1,126 @@ use gtk::prelude::*; use relm4::{ - factory::{DynamicIndex, FactoryComponent}, - FactorySender, + factory::{DynamicIndex, FactoryComponent}, + FactorySender, }; use tracing::info; -use crate::backend::repository::KrustConnection; - +use crate::backend::repository::{KrustConnection, KrustConnectionSecurityType}; #[derive(Debug)] pub enum KrustConnectionMsg { - Connect, - Disconnect, - Edit(DynamicIndex), + Connect, + Disconnect, + Edit(DynamicIndex), } #[derive(Debug)] pub enum KrustConnectionOutput { - Add, - Edit(DynamicIndex, KrustConnection), - Remove(DynamicIndex), - ShowTopics(KrustConnection), + Add, + Edit(DynamicIndex, KrustConnection), + Remove(DynamicIndex), + ShowTopics(KrustConnection), } #[derive(Debug, Clone, Default)] pub struct ConnectionListModel { - pub id: Option, - pub name: String, - pub brokers_list: String, - pub security_type: Option, - pub sasl_mechanism: Option, - pub jaas_config: Option, - pub is_connected: bool, + pub id: Option, + pub name: String, + pub brokers_list: String, + pub security_type: KrustConnectionSecurityType, + pub sasl_mechanism: Option, + pub sasl_username: Option, + pub sasl_password: Option, + pub is_connected: bool, } impl From<&mut ConnectionListModel> for KrustConnection { fn from(value: &mut ConnectionListModel) -> Self { - KrustConnection { - id: value.id.clone(), - name: value.name.clone(), - brokers_list: value.brokers_list.clone(), - security_type: value.security_type.clone(), - sasl_mechanism: value.sasl_mechanism.clone(), - jaas_config: value.jaas_config.clone(), - } + KrustConnection { + id: value.id.clone(), + name: value.name.clone(), + brokers_list: value.brokers_list.clone(), + security_type: value.security_type.clone(), + sasl_mechanism: value.sasl_mechanism.clone(), + sasl_username: value.sasl_username.clone(), + sasl_password: value.sasl_password.clone(), + } } } #[relm4::factory(pub)] impl FactoryComponent for ConnectionListModel { - type Init = KrustConnection; - type Input = KrustConnectionMsg; - type Output = KrustConnectionOutput; - type CommandOutput = (); - type ParentWidget = gtk::ListBox; - - view! { - #[root] - gtk::Box { - set_orientation: gtk::Orientation::Horizontal, - set_spacing: 10, - - #[name(connect_button)] - gtk::ToggleButton { - set_label: "Connect", - add_css_class: "connection-toggle", - connect_toggled[sender] => move |btn| { - if btn.is_active() { - sender.input(KrustConnectionMsg::Connect); - } else { - sender.input(KrustConnectionMsg::Disconnect); - } + type Init = KrustConnection; + type Input = KrustConnectionMsg; + type Output = KrustConnectionOutput; + type CommandOutput = (); + type ParentWidget = gtk::ListBox; + + view! { + #[root] + gtk::Box { + set_orientation: gtk::Orientation::Horizontal, + set_spacing: 10, + + #[name(connect_button)] + gtk::ToggleButton { + set_label: "Connect", + add_css_class: "krust-toggle", + connect_toggled[sender] => move |btn| { + if btn.is_active() { + sender.input(KrustConnectionMsg::Connect); + } else { + sender.input(KrustConnectionMsg::Disconnect); + } + }, + }, + gtk::Button { + set_icon_name: "emblem-system-symbolic", + connect_clicked[sender, index] => move |_| { + sender.input(KrustConnectionMsg::Edit(index.clone())); + }, }, - }, - gtk::Button { - set_icon_name: "emblem-system-symbolic", - connect_clicked[sender, index] => move |_| { - sender.input(KrustConnectionMsg::Edit(index.clone())); + #[name(label)] + gtk::Label { + #[watch] + set_label: &self.name, + set_width_chars: 3, }, - }, - #[name(label)] - gtk::Label { - #[watch] - set_label: &self.name, - set_width_chars: 3, - }, + } } - } - - fn init_model(conn: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { - Self { - id: conn.id, - name: conn.name, - brokers_list: conn.brokers_list, - security_type: conn.security_type, - sasl_mechanism: conn.sasl_mechanism, - jaas_config: conn.jaas_config, - is_connected: false, + + fn init_model(conn: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { + Self { + id: conn.id, + name: conn.name, + brokers_list: conn.brokers_list, + security_type: conn.security_type, + sasl_mechanism: conn.sasl_mechanism, + sasl_username: conn.sasl_username, + sasl_password: conn.sasl_password, + is_connected: false, + } } - } - - fn update(&mut self, msg: Self::Input, sender: FactorySender) { - match msg { - KrustConnectionMsg::Connect => { - info!("Connect request for {}", self.name); - self.is_connected = true; - let conn: KrustConnection = self.into(); - sender - .output(KrustConnectionOutput::ShowTopics(conn)) - .unwrap(); - } - KrustConnectionMsg::Disconnect => { - info!("Disconnect request for {}", self.name); - self.is_connected = false; - } - KrustConnectionMsg::Edit(index) => { - info!("Edit request for {}", self.name); - sender - .output(KrustConnectionOutput::Edit(index, self.into())) - .unwrap(); - } + + fn update(&mut self, msg: Self::Input, sender: FactorySender) { + match msg { + KrustConnectionMsg::Connect => { + info!("Connect request for {}", self.name); + self.is_connected = true; + let conn: KrustConnection = self.into(); + sender + .output(KrustConnectionOutput::ShowTopics(conn)) + .unwrap(); + } + KrustConnectionMsg::Disconnect => { + info!("Disconnect request for {}", self.name); + self.is_connected = false; + } + KrustConnectionMsg::Edit(index) => { + info!("Edit request for {}", self.name); + sender + .output(KrustConnectionOutput::Edit(index, self.into())) + .unwrap(); + } + } } - } } diff --git a/src/component/connection_page.rs b/src/component/connection_page.rs index 2092880..ffaeebb 100644 --- a/src/component/connection_page.rs +++ b/src/component/connection_page.rs @@ -1,140 +1,277 @@ +use std::borrow::Borrow; use gtk::prelude::*; use relm4::{factory::DynamicIndex, *}; +use relm4_components::simple_combo_box::{SimpleComboBox, SimpleComboBoxMsg}; use tracing::info; -use crate::backend::repository::KrustConnection; +use crate::backend::repository::{KrustConnection, KrustConnectionSecurityType}; -#[derive(Debug, Default)] +#[derive(Debug)] pub struct ConnectionPageModel { - pub current_index: Option, - pub current: Option, - name: String, - brokers_list: String, + pub current_index: Option, + pub current: Option, + name: String, + brokers_list: String, + security_type: KrustConnectionSecurityType, + sasl_mechanism: String, + sasl_username: String, + sasl_password: String, + security_type_combo: Controller>, } #[derive(Debug)] pub enum ConnectionPageMsg { - New, - Save, - Edit(DynamicIndex, KrustConnection), + New, + Save, + Edit(DynamicIndex, KrustConnection), + SecurityTypeChanged(usize), } #[derive(Debug)] pub enum ConnectionPageOutput { - Save(Option, KrustConnection), -} - -fn model_from(idx: Option, current: Option) -> ConnectionPageModel { - let (name, brokers_list) = match current.clone() { - Some(conn) => (conn.name, conn.brokers_list), - None => ("".into(), "".into()), - }; - - ConnectionPageModel { - current_index: idx, - current, - name, - brokers_list, - } + Save(Option, KrustConnection), } #[relm4::component(pub)] impl Component for ConnectionPageModel { - type CommandOutput = (); - - type Init = Option; - type Input = ConnectionPageMsg; - type Output = ConnectionPageOutput; - - view! { - #[root] - gtk::Grid { - set_margin_all: 10, - set_row_spacing: 6, - set_column_spacing: 10, - attach[0,0,1,2] = >k::Label { - set_label: "Name" - }, - attach[1,0,1,2]: name_entry = >k::Entry { - set_hexpand: true, - #[watch] - set_text: model.name.as_str(), - }, - attach[0,4,1,2] = >k::Label { - set_label: "Brokers" - }, - attach[1,4,1,2]: brokers_entry = >k::Entry { - set_hexpand: true, - #[watch] - set_text: model.brokers_list.as_str(), - }, - attach[1,16,1,2] = >k::Button { - set_label: "Save", - add_css_class: "suggested-action", - connect_clicked[sender] => move |_btn| { - sender.input(ConnectionPageMsg::Save) + type CommandOutput = (); + + type Init = Option; + type Input = ConnectionPageMsg; + type Output = ConnectionPageOutput; + + view! { + #[root] + gtk::Grid { + set_margin_all: 10, + set_row_spacing: 6, + set_column_spacing: 10, + attach[0,0,1,2] = >k::Label { + set_label: "Name" + }, + attach[1,0,1,2]: name_entry = >k::Entry { + set_hexpand: true, + set_text: model.name.as_str(), + }, + attach[0,4,1,2] = >k::Label { + set_label: "Brokers" + }, + attach[1,4,1,2]: brokers_entry = >k::Entry { + set_hexpand: true, + set_text: model.brokers_list.as_str(), + }, + attach[0,8,1,2] = >k::Label { + set_label: "Security type" + }, + attach[1,8,1,2] = model.security_type_combo.widget() -> >k::ComboBoxText {}, + attach[0,16,1,2] = >k::Label { + set_label: "SASL mechanism" + }, + attach[1,16,1,2]: sasl_mechanism_entry = >k::Entry { + set_hexpand: true, + set_text: model.sasl_mechanism.as_str(), + }, + attach[0,24,1,2] = >k::Label { + set_label: "SASL username" + }, + attach[1,24,1,2]: sasl_username_entry = >k::Entry { + set_hexpand: true, + set_text: model.sasl_username.as_str(), + }, + attach[0,28,1,2] = >k::Label { + set_label: "SASL password" + }, + attach[1,28,1,2]: sasl_password_entry = >k::PasswordEntry { + set_hexpand: true, + set_text: model.sasl_password.as_str(), + }, + attach[1,128,1,2] = >k::Button { + set_label: "Save", + add_css_class: "suggested-action", + connect_clicked[sender] => move |_btn| { + sender.input(ConnectionPageMsg::Save) + }, }, - }, - } - } - - fn init( - current: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - let model = model_from(None, current); - let widgets = view_output!(); - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - msg: ConnectionPageMsg, - sender: ComponentSender, - _: &Self::Root, - ) { - info!("received message: {:?}", msg); - - match msg { - ConnectionPageMsg::New => { - widgets.name_entry.set_text(""); - widgets.brokers_entry.set_text(""); - self.name = String::default(); - self.brokers_list = String::default(); - self.current = None; - self.current_index = None; - } - ConnectionPageMsg::Save => { - let name = widgets.name_entry.text().to_string(); - let brokers_list = widgets.brokers_entry.text().to_string(); - widgets.name_entry.set_text(""); - widgets.brokers_entry.set_text(""); - sender - .output(ConnectionPageOutput::Save(self.current_index.clone(), KrustConnection { - id: (move |current: Option| current?.id)( - self.current.clone(), - ), - name: name, - brokers_list: brokers_list, - jaas_config: None, - sasl_mechanism: None, - security_type: None, - })) - .unwrap(); - } - ConnectionPageMsg::Edit(index, conn) => { - let idx = Some(index.clone()); - let model = model_from(idx, Some(conn)); - self.current_index = model.current_index; - self.current = model.current; - self.name = model.name; - self.brokers_list = model.brokers_list; - //mem::swap(self, &mut model_from(idx, Some(conn))); } - }; - - self.update_view(widgets, sender); - } + } + + fn init( + current_connection: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let default_idx = 0; + let security_type_combo = SimpleComboBox::builder() + .launch(SimpleComboBox { + variants: KrustConnectionSecurityType::VALUES.to_vec(), + active_index: Some(default_idx), + }) + .forward( + sender.input_sender(), + ConnectionPageMsg::SecurityTypeChanged, + ); + let current = current_connection.clone(); + let model = ConnectionPageModel { + current_index: None, + name: current + .borrow() + .as_ref() + .map(|c| c.name.clone()) + .unwrap_or_default(), + brokers_list: current + .borrow() + .as_ref() + .map(|c| c.brokers_list.clone()) + .unwrap_or_default(), + security_type: current + .borrow() + .as_ref() + .map(|c| c.security_type.clone()) + .unwrap_or_default(), + security_type_combo: security_type_combo, + sasl_mechanism: current + .borrow() + .as_ref() + .map(|c| c.sasl_mechanism.clone().unwrap_or_default()) + .unwrap_or_default(), + sasl_username: current + .borrow() + .as_ref() + .map(|c| c.sasl_username.clone().unwrap_or_default()) + .unwrap_or_default(), + sasl_password: current + .borrow() + .as_ref() + .map(|c| c.sasl_password.clone().unwrap_or_default()) + .unwrap_or_default(), + current: current_connection, + }; + //let security_type_combo = model.security_type_combo.widget(); + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: ConnectionPageMsg, + sender: ComponentSender, + _: &Self::Root, + ) { + info!("received message: {:?}", msg); + + match msg { + ConnectionPageMsg::SecurityTypeChanged(_idx) => { + let sec_type = match self.security_type_combo.model().get_active_elem() { + Some(opt) => opt.clone(), + None => KrustConnectionSecurityType::default(), + }; + self.security_type = sec_type; + let sasl_visible = match &self.security_type { + KrustConnectionSecurityType::PLAINTEXT => false, + KrustConnectionSecurityType::SASL_PLAINTEXT => true, + }; + widgets.sasl_mechanism_entry.set_sensitive(sasl_visible); + widgets.sasl_username_entry.set_sensitive(sasl_visible); + widgets.sasl_password_entry.set_sensitive(sasl_visible); + } + ConnectionPageMsg::New => { + widgets.name_entry.set_text(""); + widgets.brokers_entry.set_text(""); + widgets.sasl_mechanism_entry.set_text(""); + widgets.sasl_username_entry.set_text(""); + widgets.sasl_password_entry.set_text(""); + widgets.sasl_mechanism_entry.set_sensitive(false); + widgets.sasl_username_entry.set_sensitive(false); + widgets.sasl_password_entry.set_sensitive(false); + self.security_type_combo + .sender() + .emit(SimpleComboBoxMsg::SetActiveIdx(0)); + self.name = String::default(); + self.brokers_list = String::default(); + self.security_type = KrustConnectionSecurityType::default(); + self.sasl_mechanism = String::default(); + self.sasl_username = String::default(); + self.sasl_password = String::default(); + self.current = None; + self.current_index = None; + } + ConnectionPageMsg::Save => { + let name = widgets.name_entry.text().to_string(); + let brokers_list = widgets.brokers_entry.text().to_string(); + let sasl_mechanism = match widgets.sasl_mechanism_entry.text().as_str() { + "" => None, + vstr => Some(vstr.to_string()), + }; + let sasl_username = match widgets.sasl_username_entry.text().as_str() { + "" => None, + vstr => Some(vstr.to_string()), + }; + let sasl_password = match widgets.sasl_password_entry.text().as_str() { + "" => None, + vstr => Some(vstr.to_string()), + }; + let security_type = self.security_type.clone(); + widgets.name_entry.set_text(""); + widgets.brokers_entry.set_text(""); + widgets.sasl_username_entry.set_text(""); + widgets.sasl_password_entry.set_text(""); + sender + .output(ConnectionPageOutput::Save( + self.current_index.clone(), + KrustConnection { + id: (move |current: Option| current?.id)( + self.current.clone(), + ), + name: name, + brokers_list: brokers_list, + sasl_username: sasl_username, + sasl_password: sasl_password, + sasl_mechanism: sasl_mechanism, + security_type: security_type, + }, + )) + .unwrap(); + } + ConnectionPageMsg::Edit(index, connection) => { + let idx = Some(index.clone()); + let conn = connection.clone(); + self.current_index = idx; + self.current = Some(connection); + self.name = conn.name; + self.brokers_list = conn.brokers_list; + self.security_type = conn.security_type.clone(); + self.sasl_mechanism = conn.sasl_mechanism.unwrap_or_default(); + self.sasl_username = conn.sasl_username.unwrap_or_default(); + self.sasl_password = conn.sasl_password.unwrap_or_default(); + widgets.name_entry.set_text(self.name.clone().as_str()); + widgets + .brokers_entry + .set_text(&self.brokers_list.clone().as_str()); + let combo_idx = KrustConnectionSecurityType::VALUES + .iter() + .position(|v| *v == self.security_type) + .unwrap_or_default(); + self.security_type_combo + .sender() + .emit(SimpleComboBoxMsg::SetActiveIdx(combo_idx)); + widgets + .sasl_username_entry + .set_text(&&self.sasl_username.clone().as_str()); + widgets + .sasl_password_entry + .set_text(&&self.sasl_password.clone().as_str()); + let sasl_visible = match &self.security_type { + KrustConnectionSecurityType::PLAINTEXT => false, + KrustConnectionSecurityType::SASL_PLAINTEXT => true, + }; + widgets.sasl_mechanism_entry.set_sensitive(sasl_visible); + widgets.sasl_username_entry.set_sensitive(sasl_visible); + widgets.sasl_password_entry.set_sensitive(sasl_visible); + //mem::swap(self, &mut model_from(idx, Some(conn))); + } + }; + + self.update_view(widgets, sender); + } } diff --git a/src/component/messages/lists.rs b/src/component/messages/lists.rs index f76a2f4..fffca1f 100644 --- a/src/component/messages/lists.rs +++ b/src/component/messages/lists.rs @@ -1,198 +1,221 @@ +use crate::{ + backend::repository::{KrustHeader, KrustMessage}, + DATE_TIME_FORMAT, +}; +use chrono::prelude::*; use chrono_tz::America; use gtk::prelude::*; use relm4::{ - typed_view::{column::{LabelColumn, RelmColumn}, OrdFn}, - *, + typed_view::{ + column::{LabelColumn, RelmColumn}, + OrdFn, + }, + *, }; -use chrono::prelude::*; -use crate::{backend::repository::{KrustHeader, KrustMessage}, DATE_TIME_FORMAT}; // Table headers: start #[derive(Debug, PartialEq, Eq)] pub struct HeaderListItem { - pub name: String, - pub value: Option, + pub name: String, + pub value: Option, } impl HeaderListItem { - pub fn new(value: KrustHeader) -> Self { - Self { - name: value.key, - value: value.value, + pub fn new(value: KrustHeader) -> Self { + Self { + name: value.key, + value: value.value, + } } - } } pub struct HeaderNameColumn; impl LabelColumn for HeaderNameColumn { - type Item = HeaderListItem; - type Value = String; - - const COLUMN_NAME: &'static str = "Name"; - - const ENABLE_SORT: bool = true; - const ENABLE_RESIZE: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.name.clone() - } - - fn format_cell_value(value: &Self::Value) -> String { - format!("{}", value) - } + type Item = HeaderListItem; + type Value = String; + + const COLUMN_NAME: &'static str = "Name"; + + const ENABLE_SORT: bool = true; + const ENABLE_RESIZE: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.name.clone() + } + + fn format_cell_value(value: &Self::Value) -> String { + format!("{}", value) + } } pub struct HeaderValueColumn; impl HeaderValueColumn { - fn format_cell_value(value: &String) -> String { - format!("{}", value) - } - fn get_cell_value(item: &HeaderListItem) -> String { - item.value.clone().unwrap_or_default() - } + fn format_cell_value(value: &String) -> String { + format!("{}", value) + } + fn get_cell_value(item: &HeaderListItem) -> String { + item.value.clone().unwrap_or_default() + } } impl RelmColumn for HeaderValueColumn { - type Root = gtk::Label; - type Widgets = (); - type Item = HeaderListItem; - - const COLUMN_NAME: &'static str = "Value"; - const ENABLE_RESIZE: bool = true; - const ENABLE_EXPAND: bool = true; - - fn setup(_item: >k::ListItem) -> (Self::Root, Self::Widgets) { - (gtk::Label::new(None), ()) - } - - fn bind(item: &mut Self::Item, _: &mut Self::Widgets, label: &mut Self::Root) { - label.set_label(&HeaderValueColumn::format_cell_value(&HeaderValueColumn::get_cell_value(item))); - label.set_halign(gtk::Align::Start); - } - - fn sort_fn() -> OrdFn { - Some(Box::new(|a: &HeaderListItem, b: &HeaderListItem| a.value.cmp(&b.value))) - } + type Root = gtk::Label; + type Widgets = (); + type Item = HeaderListItem; + + const COLUMN_NAME: &'static str = "Value"; + const ENABLE_RESIZE: bool = true; + const ENABLE_EXPAND: bool = true; + + fn setup(_item: >k::ListItem) -> (Self::Root, Self::Widgets) { + (gtk::Label::new(None), ()) + } + + fn bind(item: &mut Self::Item, _: &mut Self::Widgets, label: &mut Self::Root) { + label.set_label(&HeaderValueColumn::format_cell_value( + &HeaderValueColumn::get_cell_value(item), + )); + label.set_halign(gtk::Align::Start); + } + + fn sort_fn() -> OrdFn { + Some(Box::new(|a: &HeaderListItem, b: &HeaderListItem| { + a.value.cmp(&b.value) + })) + } } // Table headers: end // Table messages: start #[derive(Debug)] pub struct MessageListItem { - pub offset: i64, - pub partition: i32, - pub key: String, - pub value: String, - pub timestamp: Option, - pub headers: Vec, + pub offset: i64, + pub partition: i32, + pub key: String, + pub value: String, + pub timestamp: Option, + pub headers: Vec, } impl PartialEq for MessageListItem { - fn eq(&self, other: &Self) -> bool { - self.offset == other.offset - && self.key == other.key - && self.value == other.value - && self.timestamp == other.timestamp - } + fn eq(&self, other: &Self) -> bool { + self.offset == other.offset + && self.key == other.key + && self.value == other.value + && self.timestamp == other.timestamp + } } impl Eq for MessageListItem {} impl MessageListItem { - pub fn new(value: KrustMessage) -> Self { - Self { - offset: value.offset, - partition: value.partition, - key: "".to_string(), - value: value.value, - timestamp: value.timestamp, - headers: value.headers, - } - } + pub fn new(value: KrustMessage) -> Self { + Self { + offset: value.offset, + partition: value.partition, + key: "".to_string(), + value: value.value, + timestamp: value.timestamp, + headers: value.headers, + } + } } pub struct MessageOfssetColumn; impl LabelColumn for MessageOfssetColumn { - type Item = MessageListItem; - type Value = i64; - - const COLUMN_NAME: &'static str = "Offset"; - - const ENABLE_SORT: bool = true; - const ENABLE_RESIZE: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.offset - } - - fn format_cell_value(value: &Self::Value) -> String { - format!("{}", value) - } + type Item = MessageListItem; + type Value = i64; + + const COLUMN_NAME: &'static str = "Offset"; + + const ENABLE_SORT: bool = true; + const ENABLE_RESIZE: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.offset + } + + fn format_cell_value(value: &Self::Value) -> String { + format!("{}", value) + } } pub struct MessagePartitionColumn; impl LabelColumn for MessagePartitionColumn { - type Item = MessageListItem; - type Value = i32; - - const COLUMN_NAME: &'static str = "Partition"; - - const ENABLE_SORT: bool = true; - const ENABLE_RESIZE: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.partition - } - - fn format_cell_value(value: &Self::Value) -> String { - format!("{}", value) - } + type Item = MessageListItem; + type Value = i32; + + const COLUMN_NAME: &'static str = "Partition"; + + const ENABLE_SORT: bool = true; + const ENABLE_RESIZE: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.partition + } + + fn format_cell_value(value: &Self::Value) -> String { + format!("{}", value) + } } pub struct MessageTimestampColumn; impl LabelColumn for MessageTimestampColumn { - type Item = MessageListItem; - type Value = i64; - - const COLUMN_NAME: &'static str = "Date/time (Timestamp)"; - - const ENABLE_SORT: bool = true; - const ENABLE_RESIZE: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.timestamp.unwrap_or(Utc::now().timestamp()) - } - - fn format_cell_value(value: &Self::Value) -> String { - format!("{}", Utc.timestamp_millis_opt(*value).unwrap().with_timezone(&America::Sao_Paulo).format(DATE_TIME_FORMAT)) - } + type Item = MessageListItem; + type Value = i64; + + const COLUMN_NAME: &'static str = "Date/time (Timestamp)"; + + const ENABLE_SORT: bool = true; + const ENABLE_RESIZE: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.timestamp.unwrap_or(Utc::now().timestamp()) + } + + fn format_cell_value(value: &Self::Value) -> String { + format!( + "{}", + Utc.timestamp_millis_opt(*value) + .unwrap() + .with_timezone(&America::Sao_Paulo) + .format(DATE_TIME_FORMAT) + ) + } } pub struct MessageValueColumn; impl LabelColumn for MessageValueColumn { - type Item = MessageListItem; - type Value = String; - - const COLUMN_NAME: &'static str = "Value"; - const ENABLE_RESIZE: bool = true; - const ENABLE_EXPAND: bool = true; - const ENABLE_SORT: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.value.clone() - } - - fn format_cell_value(value: &Self::Value) -> String { - if value.len() >= 150 { - format!("{}...", value.replace("\n", " ").get(0..150).unwrap_or("").to_string()) - } else { - format!("{}...", value) - } - } + type Item = MessageListItem; + type Value = String; + + const COLUMN_NAME: &'static str = "Value"; + const ENABLE_RESIZE: bool = true; + const ENABLE_EXPAND: bool = true; + const ENABLE_SORT: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.value.clone() + } + + fn format_cell_value(value: &Self::Value) -> String { + if value.len() >= 150 { + format!( + "{}...", + value + .replace("\n", " ") + .get(0..150) + .unwrap_or("") + .to_string() + ) + } else { + format!("{}...", value) + } + } } -// Table messages: end \ No newline at end of file +// Table messages: end diff --git a/src/component/messages/messages_page.rs b/src/component/messages/messages_page.rs index 04cdcde..3ca988b 100644 --- a/src/component/messages/messages_page.rs +++ b/src/component/messages/messages_page.rs @@ -1,244 +1,341 @@ -use gtk::prelude::*; -use relm4::{ - typed_view::column::TypedColumnView, - *, -}; +use gtk::gdk::DisplayManager; +use relm4::{typed_view::column::TypedColumnView, *}; use sourceview::prelude::*; use sourceview5 as sourceview; -use tracing::info; +use tracing::{info, trace}; -use crate::{backend::repository::{KrustConnection, KrustMessage}, component::{messages::lists::{HeaderListItem, HeaderNameColumn, HeaderValueColumn, MessageListItem, MessageOfssetColumn, MessagePartitionColumn, MessageTimestampColumn, MessageValueColumn}, status_bar::{StatusBarMsg, STATUS_BROKER}}}; +use crate::{ + backend::{ + kafka::{KafkaBackend, Topic}, + repository::{KrustConnection, KrustMessage}, + }, + component::{ + messages::lists::{ + HeaderListItem, HeaderNameColumn, HeaderValueColumn, MessageListItem, + MessageOfssetColumn, MessagePartitionColumn, MessageTimestampColumn, + MessageValueColumn, + }, + status_bar::{StatusBarMsg, STATUS_BROKER}, + }, +}; #[derive(Debug)] pub struct MessagesPageModel { - pub current: Option, - messages_wrapper: TypedColumnView, - headers_wrapper: TypedColumnView, + topic: Option, + connection: Option, + messages_wrapper: TypedColumnView, + headers_wrapper: TypedColumnView, } #[derive(Debug)] pub enum MessagesPageMsg { - List(Vec), - Open(u32), + Open(KrustConnection, Topic), + GetMessages, + UpdateMessages(Vec), + OpenMessage(u32), + Selection(u32), } #[derive(Debug)] -pub enum MessagesPageOutput { - _ShowMessages, +pub enum CommandMsg { + Data(Vec), } #[relm4::component(pub)] impl Component for MessagesPageModel { - type CommandOutput = (); - - type Init = Option; - type Input = MessagesPageMsg; - type Output = (); - - view! { - #[root] - gtk::Paned { - set_orientation: gtk::Orientation::Vertical, - //set_resize_start_child: true, - #[wrap(Some)] - set_start_child = >k::Box { + type Init = (); + type Input = MessagesPageMsg; + type Output = (); + type CommandOutput = CommandMsg; + + view! { + #[root] + gtk::Paned { 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_resize_start_child: true, + #[wrap(Some)] + set_start_child = >k::Box { + set_orientation: gtk::Orientation::Vertical, set_hexpand: true, - #[wrap(Some)] - set_start_widget = >k::Box { - set_orientation: gtk::Orientation::Horizontal, - set_halign: gtk::Align::Start, - set_hexpand: true, - gtk::Button { set_icon_name: "media-playback-start-symbolic", }, - gtk::Button { set_icon_name: "media-playback-stop-symbolic", set_margin_start: 5, }, - }, - #[wrap(Some)] - set_end_widget = >k::Box { + set_vexpand: true, + gtk::CenterBox { set_orientation: gtk::Orientation::Horizontal, set_halign: gtk::Align::Fill, + set_margin_all: 10, set_hexpand: true, - #[name(topics_search_entry)] - gtk::SearchEntry { + #[wrap(Some)] + set_start_widget = >k::Box { + set_orientation: gtk::Orientation::Horizontal, + set_halign: gtk::Align::Start, set_hexpand: true, - set_halign: gtk::Align::Fill, - + gtk::Button { + set_icon_name: "media-playback-start-symbolic", + connect_clicked[sender] => move |_| { + sender.input(MessagesPageMsg::GetMessages); + }, + }, + gtk::Button { set_icon_name: "media-playback-stop-symbolic", set_margin_start: 5, }, + gtk::ToggleButton { + set_margin_start: 5, + set_label: "Cache", + add_css_class: "krust-toggle", + }, }, - gtk::Button { - set_icon_name: "edit-find-symbolic", - set_margin_start: 5, + #[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 { + gtk::ScrolledWindow { set_vexpand: true, set_hexpand: true, - set_show_row_separators: true, - set_show_column_separators: true, - set_single_click_activate: 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_propagate_natural_width: true, + #[local_ref] + messages_view -> gtk::ColumnView { 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", + set_show_row_separators: true, + set_show_column_separators: true, + set_single_click_activate: false, + set_enable_rubberband: true, + } }, - add_child = >k::Box { - gtk::ScrolledWindow { - set_vexpand: 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_propagate_natural_width: true, - #[local_ref] - headers_view -> gtk::ColumnView { + set_vexpand: true, + #[name = "value_container"] + gtk::ScrolledWindow { + add_css_class: "bordered", set_vexpand: true, set_hexpand: true, - set_show_row_separators: true, - set_show_column_separators: 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", }, - } -> { - set_title: "Header", - set_name: "Header", }, }, - }, - } - - } - - fn init( - current: Self::Init, - 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 model = MessagesPageModel { - current: current, - messages_wrapper: messages_wrapper, - headers_wrapper: headers_wrapper, - }; - - let messages_view = &model.messages_wrapper.view; - let headers_view = &model.headers_wrapper.view; - messages_view.connect_activate(move |_view, idx| { - sender.input(MessagesPageMsg::Open(idx)); - }); - - 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()); - - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - msg: MessagesPageMsg, - sender: ComponentSender, - _: &Self::Root, - ) { - info!("received message: {:?}", msg); - - match msg { - MessagesPageMsg::List(messages) => { - self.messages_wrapper.clear(); - for message in messages { - self.messages_wrapper.append(MessageListItem::new(message)); - } - widgets - .value_source_view - .buffer() - .set_text(""); - STATUS_BROKER.send(StatusBarMsg::StopWithInfo { text: Some("Messages loaded!".into()) }); } - MessagesPageMsg::Open(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), + + } + + fn init( + _: Self::Init, + 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 model = MessagesPageModel { + topic: None, + connection: None, + messages_wrapper: messages_wrapper, + headers_wrapper: headers_wrapper, }; + + 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 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()); - 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())); + + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: MessagesPageMsg, + sender: ComponentSender, + _: &Self::Root, + ) { + match msg { + 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.clone(); + let offset = item.borrow().offset.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.clone(); + 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) => { + self.connection = Some(connection); + self.topic = Some(topic); + } + MessagesPageMsg::GetMessages => { + STATUS_BROKER.send(StatusBarMsg::Start); + let topic_name = self.topic.clone().unwrap().name; + let conn = self.connection.clone().unwrap(); + sender.oneshot_command(async { + let kafka = KafkaBackend::new(conn); + let topic = topic_name.clone(); + // Run async background task + let messages = kafka.list_messages_for_topic(topic_name).await; + trace!("selected topic {} with {} messages", topic, messages.len(),); + CommandMsg::Data(messages) + }); + } + MessagesPageMsg::UpdateMessages(messages) => { + let total = messages.len(); + self.messages_wrapper.clear(); + self.messages_wrapper + .extend_from_iter(messages.iter().map(|m| MessageListItem::new(m.clone()))); + widgets.value_source_view.buffer().set_text(""); + STATUS_BROKER.send(StatusBarMsg::StopWithInfo { + text: Some(format!("{} messages loaded!", total)), + }); + } + 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())); + } + } + }; + + 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)), } - } - }; - - self.update_view(widgets, sender); - } + } } diff --git a/src/component/status_bar.rs b/src/component/status_bar.rs index 5337def..c347458 100644 --- a/src/component/status_bar.rs +++ b/src/component/status_bar.rs @@ -13,79 +13,84 @@ pub static STATUS_BROKER: MessageBroker = MessageBroker::new(); #[derive(Debug)] pub struct StatusBarModel { - is_loading: bool, - text: String, - duration: String, - start_marker: Option, + is_loading: bool, + text: String, + duration: String, + start_marker: Option, } #[derive(Debug)] pub enum StatusBarMsg { - Start, - StopWithInfo { text: Option }, + Start, + StopWithInfo { text: Option }, } #[relm4::component(pub)] impl SimpleComponent for StatusBarModel { - type Widgets = StatusBarWidgets; - type Init = (); - type Input = StatusBarMsg; - type Output = (); - - view! { - gtk::CenterBox { - set_orientation: gtk::Orientation::Horizontal, - set_margin_all: 10, - set_hexpand: true, - #[wrap(Some)] - set_start_widget = >k::Box { - gtk::Spinner { - #[watch] - set_spinning: model.is_loading, - }, - - gtk::Label { - #[watch] - set_label: model.text.as_str(), + type Widgets = StatusBarWidgets; + type Init = (); + type Input = StatusBarMsg; + type Output = (); + + view! { + gtk::CenterBox { + set_orientation: gtk::Orientation::Horizontal, + set_margin_all: 10, + set_hexpand: true, + #[wrap(Some)] + set_start_widget = >k::Box { + gtk::Spinner { + #[watch] + set_spinning: model.is_loading, + }, + + gtk::Label { + #[watch] + set_label: model.text.as_str(), + }, }, - }, - #[wrap(Some)] - set_end_widget = >k::Box { - gtk::Label { - set_halign: gtk::Align::End, - #[watch] - set_label: model.duration.as_str(), + #[wrap(Some)] + set_end_widget = >k::Box { + gtk::Label { + set_halign: gtk::Align::End, + #[watch] + set_label: model.duration.as_str(), + }, }, - }, - } - } - - fn init(_: (), root: Self::Root, _sender: ComponentSender) -> ComponentParts { - let model = StatusBarModel { - is_loading: false, - text: String::default(), - duration: String::default(), - start_marker: None, - }; - - let widgets = view_output!(); - - ComponentParts { model, widgets } - } - - fn update(&mut self, input: Self::Input, _sender: ComponentSender) { - match input { - StatusBarMsg::Start => { - self.is_loading = true; - self.text = String::default(); - self.duration = String::default(); - self.start_marker = Some(Instant::now()); - } - StatusBarMsg::StopWithInfo { text } => { - self.is_loading = false; - self.text = text.unwrap_or_default(); - self.duration = format!("Took {:?}", self.start_marker.unwrap_or_else(|| Instant::now()).elapsed()); } } - } + + fn init(_: (), root: Self::Root, _sender: ComponentSender) -> ComponentParts { + let model = StatusBarModel { + is_loading: false, + text: String::default(), + duration: String::default(), + start_marker: None, + }; + + let widgets = view_output!(); + + ComponentParts { model, widgets } + } + + fn update(&mut self, input: Self::Input, _sender: ComponentSender) { + match input { + StatusBarMsg::Start => { + self.is_loading = true; + self.text = String::default(); + self.duration = String::default(); + self.start_marker = Some(Instant::now()); + } + StatusBarMsg::StopWithInfo { text } => { + self.is_loading = false; + self.text = text.unwrap_or_default(); + self.duration = format!( + "Took {:?}", + self.start_marker + .unwrap_or_else(|| Instant::now()) + .elapsed() + ); + } + } + } } diff --git a/src/component/topics_page.rs b/src/component/topics_page.rs index bacb111..69ccd85 100644 --- a/src/component/topics_page.rs +++ b/src/component/topics_page.rs @@ -1,240 +1,233 @@ use gtk::prelude::*; use relm4::{ - typed_view::column::{LabelColumn, TypedColumnView}, - *, + typed_view::column::{LabelColumn, TypedColumnView}, + *, }; use tracing::{debug, info}; -use crate::{backend::{ - kafka::{KafkaBackend, Topic}, - repository::{KrustConnection, KrustMessage}, -}, component::status_bar::{StatusBarMsg, STATUS_BROKER}}; +use crate::{ + backend::{ + kafka::{KafkaBackend, Topic}, + repository::KrustConnection, + }, + component::status_bar::{StatusBarMsg, STATUS_BROKER}, +}; // Table: start #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct TopicListItem { - name: String, - partition_count: usize, + name: String, + partition_count: usize, } impl TopicListItem { - fn new(value: Topic) -> Self { - Self { - name: value.name, - partition_count: value.partitions.len(), + fn new(value: Topic) -> Self { + Self { + name: value.name, + partition_count: value.partitions.len(), + } } - } } struct PartitionCountColumn; impl LabelColumn for PartitionCountColumn { - type Item = TopicListItem; - type Value = usize; - - const COLUMN_NAME: &'static str = "Partitions"; - - const ENABLE_SORT: bool = true; - const ENABLE_RESIZE: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.partition_count - } - - fn format_cell_value(value: &Self::Value) -> String { - format!("{}", value) - } + type Item = TopicListItem; + type Value = usize; + + const COLUMN_NAME: &'static str = "Partitions"; + + const ENABLE_SORT: bool = true; + const ENABLE_RESIZE: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.partition_count + } + + fn format_cell_value(value: &Self::Value) -> String { + format!("{}", value) + } } struct NameColumn; impl LabelColumn for NameColumn { - type Item = TopicListItem; - type Value = String; - - const COLUMN_NAME: &'static str = "Name"; - const ENABLE_RESIZE: bool = true; - const ENABLE_EXPAND: bool = true; - const ENABLE_SORT: bool = true; - - fn get_cell_value(item: &Self::Item) -> Self::Value { - item.name.clone() - } - - fn format_cell_value(value: &Self::Value) -> String { - value.clone() - } + type Item = TopicListItem; + type Value = String; + + const COLUMN_NAME: &'static str = "Name"; + const ENABLE_RESIZE: bool = true; + const ENABLE_EXPAND: bool = true; + const ENABLE_SORT: bool = true; + + fn get_cell_value(item: &Self::Item) -> Self::Value { + item.name.clone() + } + + fn format_cell_value(value: &Self::Value) -> String { + value.clone() + } } // Table: end #[derive(Debug)] pub struct TopicsPageModel { - pub current: Option, - pub topics_wrapper: TypedColumnView, - pub is_loading: bool, - pub search_text: String, + pub current: Option, + pub topics_wrapper: TypedColumnView, + pub is_loading: bool, + pub search_text: String, } #[derive(Debug)] pub enum TopicsPageMsg { - List(KrustConnection), - OpenTopic(u32), - Search(String), + List(KrustConnection), + OpenTopic(u32), + Search(String), } #[derive(Debug)] pub enum TopicsPageOutput { - OpenMessagesPage(Vec), + OpenMessagesPage(KrustConnection, Topic), } #[derive(Debug)] pub enum CommandMsg { - Data(Vec), - ListFinished(Vec), + // Data(Vec), + ListFinished(Vec), } #[relm4::component(pub)] impl Component for TopicsPageModel { - type Init = Option; - type Input = TopicsPageMsg; - type Output = TopicsPageOutput; - 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, + type Init = Option; + type Input = TopicsPageMsg; + type Output = TopicsPageOutput; + type CommandOutput = CommandMsg; + + view! { + #[root] + gtk::Box { + set_orientation: gtk::Orientation::Vertical, 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())); - } + 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 { + gtk::ScrolledWindow { set_vexpand: true, set_hexpand: true, - set_show_row_separators: true, + set_propagate_natural_width: true, + #[local_ref] + topics_view -> gtk::ColumnView { + set_vexpand: true, + set_hexpand: true, + set_show_row_separators: true, + } } } } - } - - fn init( - current: Self::Init, - root: Self::Root, - sender: ComponentSender, - ) -> ComponentParts { - // Initialize the ListView wrapper - let mut view_wrapper = TypedColumnView::::new(); - view_wrapper.append_column::(); - view_wrapper.append_column::(); - - let model = TopicsPageModel { - current: current, - topics_wrapper: view_wrapper, - is_loading: false, - search_text: String::default(), - }; - - let topics_view = &model.topics_wrapper.view; - let snd = sender.clone(); - topics_view.connect_activate(move |_view, idx| { - snd.input(TopicsPageMsg::OpenTopic(idx)); - }); - - let widgets = view_output!(); - ComponentParts { model, widgets } - } - - fn update_with_view( - &mut self, - widgets: &mut Self::Widgets, - msg: TopicsPageMsg, - sender: ComponentSender, - _: &Self::Root, - ) { - info!("received message: {:?}", msg); - - match msg { - TopicsPageMsg::Search(term) => { - self.topics_wrapper.clear_filters(); - let search_term = term.clone(); - self.topics_wrapper.add_filter(move |item| { - debug!("Searching topics {}", search_term); - item.name.contains(search_term.as_str()) - }); - } - TopicsPageMsg::List(conn) => { - STATUS_BROKER.send(StatusBarMsg::Start); - self.current = Some(conn.clone()); - sender.oneshot_command(async { - let kafka = KafkaBackend::new(conn); - let topics = kafka.list_topics().await; - - CommandMsg::ListFinished(topics) - }); - } - TopicsPageMsg::OpenTopic(idx) => { - STATUS_BROKER.send(StatusBarMsg::Start); - let item = self.topics_wrapper.get_visible(idx).unwrap(); - let topic_name = item.borrow().name.clone(); - let conn = self.current.clone().unwrap(); - sender.oneshot_command(async { - let kafka = KafkaBackend::new(conn); - let message_count = kafka.topic_message_count(topic_name.clone()); - info!( - "selected topic {} with {} messages", - topic_name.clone(), - message_count - ); - // Run async background task - let messages = kafka.list_messages_for_topic(topic_name).await; - info!("MESSAGES COUNT::{:?}", messages.len()); - CommandMsg::Data(messages) + + fn init( + current: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + // Initialize the ListView wrapper + let mut view_wrapper = TypedColumnView::::new(); + view_wrapper.append_column::(); + view_wrapper.append_column::(); + + let model = TopicsPageModel { + current: current, + topics_wrapper: view_wrapper, + is_loading: false, + search_text: String::default(), + }; + + let topics_view = &model.topics_wrapper.view; + let snd = sender.clone(); + topics_view.connect_activate(move |_view, idx| { + snd.input(TopicsPageMsg::OpenTopic(idx)); }); - } - }; - - self.update_view(widgets, sender); - } - - fn update_cmd( - &mut self, - message: Self::CommandOutput, - sender: ComponentSender, - _: &Self::Root, - ) { - match message { - CommandMsg::Data(data) => { - sender - .output(TopicsPageOutput::OpenMessagesPage(data)) - .unwrap(); - } - CommandMsg::ListFinished(topics) => { - self.topics_wrapper.clear(); - for topic in topics.into_iter().filter(|t| !t.name.starts_with("__")) { - self.topics_wrapper - .insert_sorted(TopicListItem::new(topic), |a, b| a.cmp(b)); + + let widgets = view_output!(); + ComponentParts { model, widgets } + } + + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: TopicsPageMsg, + sender: ComponentSender, + _: &Self::Root, + ) { + info!("received message: {:?}", msg); + + match msg { + TopicsPageMsg::Search(term) => { + self.topics_wrapper.clear_filters(); + let search_term = term.clone(); + self.topics_wrapper.add_filter(move |item| { + debug!("Searching topics {}", search_term); + item.name.contains(search_term.as_str()) + }); + } + TopicsPageMsg::List(conn) => { + STATUS_BROKER.send(StatusBarMsg::Start); + self.current = Some(conn.clone()); + sender.oneshot_command(async { + let kafka = KafkaBackend::new(conn); + let topics = kafka.list_topics().await; + + CommandMsg::ListFinished(topics) + }); + } + TopicsPageMsg::OpenTopic(idx) => { + let item = self.topics_wrapper.get_visible(idx).unwrap(); + let topic = Topic { + name: item.borrow().name.clone(), + partitions: vec![], + }; + sender + .output(TopicsPageOutput::OpenMessagesPage( + self.current.clone().unwrap(), + topic, + )) + .unwrap(); + } }; - STATUS_BROKER.send(StatusBarMsg::StopWithInfo { text: Some("Topics loaded!".into()) }); - } + + self.update_view(widgets, sender); } - } -} + fn update_cmd( + &mut self, + message: Self::CommandOutput, + _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("__")) { + self.topics_wrapper + .insert_sorted(TopicListItem::new(topic), |a, b| a.cmp(b)); + } + STATUS_BROKER.send(StatusBarMsg::StopWithInfo { + text: Some("Topics loaded!".into()), + }); + } + } + } +} diff --git a/src/config.rs b/src/config.rs index 0ced31a..6a3478c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,10 +8,10 @@ use tracing::*; #[derive(Error, Debug)] pub enum ExternalError { - #[error("database error")] - DatabaseError(#[from] rusqlite::Error), - #[error("configuration error")] - ConfigurationError(String), + #[error("database error")] + DatabaseError(#[from] rusqlite::Error), + #[error("configuration error")] + ConfigurationError(String), } /// Application state that is not intended to be directly configurable by the user. The state is @@ -23,93 +23,93 @@ pub enum ExternalError { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct State { - /// Width of the main window at startup. - pub width: i32, + /// Width of the main window at startup. + pub width: i32, - /// Height of the main window at startup. - pub height: i32, + /// Height of the main window at startup. + pub height: i32, - /// Panned separator position - pub separator_position: i32, + /// Panned separator position + pub separator_position: i32, - /// Whether the window should be maximized at startup. - pub is_maximized: bool, + /// Whether the window should be maximized at startup. + pub is_maximized: bool, } impl State { - /// Read from the state file on disk. - pub fn read() -> Result { - let path = state_path()?; - Ok(serde_json::from_reader(File::open(path).map_err(|_| { - ExternalError::ConfigurationError("unable to find user home directory".into()) - })?) - .map_err(|_| { - ExternalError::ConfigurationError("unable to find user home directory".into()) - })?) - } + /// Read from the state file on disk. + pub fn read() -> Result { + let path = state_path()?; + Ok(serde_json::from_reader(File::open(path).map_err(|_| { + ExternalError::ConfigurationError("unable to find user home directory".into()) + })?) + .map_err(|_| { + ExternalError::ConfigurationError("unable to find user home directory".into()) + })?) + } - /// Persist to disk. - pub fn write(&self) -> Result<(), ExternalError> { - let path = state_path()?; + /// Persist to disk. + pub fn write(&self) -> Result<(), ExternalError> { + let path = state_path()?; - info!( - "persisting application state: {:?}, into path: {:?}", - self, path - ); - - let file = File::create(path).map_err(|op| { - ExternalError::ConfigurationError( - format!("unable to create intermediate directories: {:?}", op).into(), - ) - })?; - Ok(serde_json::to_writer(file, self).map_err(|op| { - ExternalError::ConfigurationError( - format!("unable to create to write state to disk: {:?}", op).into(), - ) - })?) - } + info!( + "persisting application state: {:?}, into path: {:?}", + self, path + ); + + let file = File::create(path).map_err(|op| { + ExternalError::ConfigurationError( + format!("unable to create intermediate directories: {:?}", op).into(), + ) + })?; + Ok(serde_json::to_writer(file, self).map_err(|op| { + ExternalError::ConfigurationError( + format!("unable to create to write state to disk: {:?}", op).into(), + ) + })?) + } } impl Default for State { - fn default() -> Self { - let width: i32 = 900; - State { - width: width, - height: 600, - separator_position: ((width as f32) * 0.25).round() as i32, - is_maximized: false, + fn default() -> Self { + let width: i32 = 900; + State { + width: width, + height: 600, + separator_position: ((width as f32) * 0.25).round() as i32, + is_maximized: false, + } } - } } pub fn database_connection() -> Result { - let data_file = ensure_app_config_dir()?.join("application.db"); - Connection::open_with_flags( - data_file, - OpenFlags::SQLITE_OPEN_READ_WRITE - | OpenFlags::SQLITE_OPEN_CREATE - | OpenFlags::SQLITE_OPEN_URI, - ) - .map_err(ExternalError::DatabaseError) + let data_file = ensure_app_config_dir()?.join("application.db"); + Connection::open_with_flags( + data_file, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(ExternalError::DatabaseError) } fn app_config_dir() -> Result { - let dirs = ProjectDirs::from("io", "miguelbaldi", "KRust").ok_or_else(|| { - ExternalError::ConfigurationError("unable to find user home directory".into()) - })?; - Ok(dirs.data_local_dir().to_path_buf()) + let dirs = ProjectDirs::from("io", "miguelbaldi", "KRust").ok_or_else(|| { + ExternalError::ConfigurationError("unable to find user home directory".into()) + })?; + Ok(dirs.data_local_dir().to_path_buf()) } fn state_path() -> Result { - Ok(ensure_app_config_dir()?.join("state.json")) + Ok(ensure_app_config_dir()?.join("state.json")) } fn ensure_app_config_dir() -> Result { - let app_config_path = app_config_dir()?; - fs::create_dir_all(&app_config_path).map_err(|op| { - ExternalError::ConfigurationError( - format!("unable to create intermediate directories: {:?}", op).into(), - ) - })?; - Ok(app_config_path) + let app_config_path = app_config_dir()?; + fs::create_dir_all(&app_config_path).map_err(|op| { + ExternalError::ConfigurationError( + format!("unable to create intermediate directories: {:?}", op).into(), + ) + })?; + Ok(app_config_path) } diff --git a/src/main.rs b/src/main.rs index 1067077..e2263ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,55 +1,62 @@ //#![windows_subsystem = "windows"] use gtk::prelude::ApplicationExt; use tracing::*; +use tracing_subscriber::filter; use tracing_subscriber::prelude::*; use tracing_subscriber::EnvFilter; use tracing_tree::HierarchicalLayer; use relm4::{ - actions::{AccelsPlus, RelmAction, RelmActionGroup}, - gtk, main_application, RelmApp, + actions::{AccelsPlus, RelmAction, RelmActionGroup}, + gtk, main_application, RelmApp, }; -use krust::{AppModel, APP_ID, Repository}; +use krust::{AppModel, Repository, APP_ID}; relm4::new_action_group!(AppActionGroup, "app"); relm4::new_stateless_action!(QuitAction, AppActionGroup, "quit"); fn main() -> Result<(), ()> { - // Call `gtk::init` manually because we instantiate GTK types in the app model. - gtk::init().unwrap(); - tracing_subscriber::registry() - .with(HierarchicalLayer::new(2)) - .with(EnvFilter::from_default_env()) - .init(); - - info!("Running: {}", APP_ID); - - let app = main_application(); - - let mut actions = RelmActionGroup::::new(); - - let quit_action = { - let app = app.clone(); - RelmAction::::new_stateless(move |_| { - app.quit(); - }) - }; - actions.add_action(quit_action); - actions.register_for_main_application(); - - app.set_accelerators_for_action::(&["q"]); - - let app = RelmApp::from_app(app); - - let mut repo = Repository::new(); - repo.init().unwrap(); - - //let app = RelmApp::new(APP_ID); - app.set_global_css(include_str!("styles.css")); - //app.run::(()); - app.visible_on_activate(false).run::(()); - info!("main loop exited"); - - Ok(()) -} \ No newline at end of file + // Call `gtk::init` manually because we instantiate GTK types in the app model. + gtk::init().unwrap(); + let filter = filter::Targets::new() + // 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); + tracing_subscriber::registry() + .with(HierarchicalLayer::new(2)) + .with(EnvFilter::from_default_env()) + .with(filter) + .init(); + + info!("Running: {}", APP_ID); + + let app = main_application(); + + let mut actions = RelmActionGroup::::new(); + + let quit_action = { + let app = app.clone(); + RelmAction::::new_stateless(move |_| { + app.quit(); + }) + }; + actions.add_action(quit_action); + actions.register_for_main_application(); + + app.set_accelerators_for_action::(&["q"]); + + let app = RelmApp::from_app(app); + + let mut repo = Repository::new(); + repo.init().unwrap(); + + //let app = RelmApp::new(APP_ID); + app.set_global_css(include_str!("styles.css")); + //app.run::(()); + app.visible_on_activate(false).run::(()); + info!("main loop exited"); + + Ok(()) +} diff --git a/src/modals/about.rs b/src/modals/about.rs index 3e7418c..059544e 100644 --- a/src/modals/about.rs +++ b/src/modals/about.rs @@ -3,7 +3,6 @@ use relm4::{adw, gtk, ComponentParts, ComponentSender, SimpleComponent}; use crate::{APP_NAME, VERSION}; - #[derive(Debug)] pub struct AboutDialog {} @@ -48,4 +47,4 @@ impl SimpleComponent for AboutDialog { fn update_view(&self, dialog: &mut Self::Widgets, _sender: ComponentSender) { dialog.present(); } -} \ No newline at end of file +} diff --git a/src/styles.css b/src/styles.css index ccc66ff..8b401b1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,13 +1,16 @@ .header-title { font-weight: bold; } -button.connection-toggle { + +button.krust-toggle { text-decoration: underline; } -button.connection-toggle:checked { - background: #b7f39b; + +button.krust-toggle:checked { + background: #b7f39b; text-decoration: underline; } + .status-bar { background: #FDEBD8; -} \ No newline at end of file +} diff --git a/win-prepare-and-build.sh b/win-prepare-and-build.sh index 17cbf89..73fd572 100755 --- a/win-prepare-and-build.sh +++ b/win-prepare-and-build.sh @@ -1,11 +1,24 @@ #!/bin/bash set -euo pipefail -dnf -y install mingw64-gcc-c++ zstd +dnf -y install mingw64-openssl-static mingw64-gcc-c++ zstd cyrus-sasl-devel perl curl --connect-timeout 60 -m 60 -L -o /tmp/gtksourceview-5.pkg.tar.zst https://mirror.msys2.org/mingw/mingw64/mingw-w64-x86_64-gtksourceview5-5.12.0-1-any.pkg.tar.zst +curl --connect-timeout 60 -m 60 -L -o /tmp/cyrus-sasl-2.pkg.tar.zst https://mirror.msys2.org/mingw/mingw64/mingw-w64-x86_64-cyrus-sasl-2.1.28-3-any.pkg.tar.zst cd /tmp tar --use-compress-program=unzstd -xvf gtksourceview-5.pkg.tar.zst +tar --use-compress-program=unzstd -xvf cyrus-sasl-2.pkg.tar.zst +# libsasl-2 +cp -fvr /tmp/mingw64/sbin/saslpasswd2.exe /usr/x86_64-w64-mingw32/sys-root/mingw/sbin/saslpasswd2.exe +cp -fvr /tmp/mingw64/sbin/sasldblistusers2.exe /usr/x86_64-w64-mingw32/sys-root/mingw/sbin/sasldblistusers2.exe +cp -fvr /tmp/mingw64/sbin/pluginviewer.exe /usr/x86_64-w64-mingw32/sys-root/mingw/sbin/pluginviewer.exe +cp -fvr /tmp/mingw64/bin/libsasl2-3.dll /usr/x86_64-w64-mingw32/sys-root/mingw/bin/libsasl2-3.dll +cp -fvr /tmp/mingw64/include/sasl/ /usr/x86_64-w64-mingw32/sys-root/mingw/include/ +cp -fvr /tmp/mingw64/lib/libsasl2.dll.a /usr/x86_64-w64-mingw32/sys-root/mingw/lib/ +cp -fvr /tmp/mingw64/lib/pkgconfig/libsasl2.pc /usr/x86_64-w64-mingw32/sys-root/mingw/lib/pkgconfig/ +cp -fvr /tmp/mingw64/lib/sasl2/ /usr/x86_64-w64-mingw32/sys-root/mingw/lib/ + +# GtkSourceview-5 cp -fvr /tmp/mingw64/bin/libgtksourceview-5-0.dll /usr/x86_64-w64-mingw32/sys-root/mingw/bin/libgtksourceview-5-0.dll cp -fvr /tmp/mingw64/include/gtksourceview-5/ /usr/x86_64-w64-mingw32/sys-root/mingw/include/ cp -fvr /tmp/mingw64/share/gtksourceview-5/ /usr/x86_64-w64-mingw32/sys-root/mingw/share/