diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ccd3362f..44931dec 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1272,6 +1272,7 @@ dependencies = [ "prost", "prost-build", "rand 0.8.5", + "regex", "reqwest 0.12.8", "rust-ini 0.21.1", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cf08d3a4..d5a5eda8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ local-ip-address = "0.6" log = "0.4" prost = "0.13" rand = "0.8" +regex = "1.11" reqwest = { version = "0.12", features = ["json"] } rust-ini = "0.21" serde_json = "1.0" diff --git a/src-tauri/src/bin/defguard-client.rs b/src-tauri/src/bin/defguard-client.rs index aa2e0ab8..2d5ae068 100644 --- a/src-tauri/src/bin/defguard-client.rs +++ b/src-tauri/src/bin/defguard-client.rs @@ -11,15 +11,17 @@ use defguard_client::{ __cmd__disconnect, __cmd__get_latest_app_version, __cmd__get_settings, __cmd__last_connection, __cmd__location_interface_details, __cmd__location_stats, __cmd__open_link, __cmd__parse_tunnel_config, __cmd__save_device_config, __cmd__save_tunnel, - __cmd__tunnel_details, __cmd__update_instance, __cmd__update_location_routing, - __cmd__update_settings, __cmd__update_tunnel, + __cmd__start_global_logwatcher, __cmd__stop_global_logwatcher, __cmd__tunnel_details, + __cmd__update_instance, __cmd__update_location_routing, __cmd__update_settings, + __cmd__update_tunnel, appstate::AppState, commands::{ active_connection, all_connections, all_instances, all_locations, all_tunnels, connect, delete_instance, delete_tunnel, disconnect, get_latest_app_version, get_settings, last_connection, location_interface_details, location_stats, open_link, - parse_tunnel_config, save_device_config, save_tunnel, tunnel_details, update_instance, - update_location_routing, update_settings, update_tunnel, + parse_tunnel_config, save_device_config, save_tunnel, start_global_logwatcher, + stop_global_logwatcher, tunnel_details, update_instance, update_location_routing, + update_settings, update_tunnel, }, database::{self, models::settings::Settings}, enterprise::periodic::config::poll_config, @@ -35,6 +37,7 @@ use log::{Level, LevelFilter}; use tauri::{api::process, Env}; use tauri::{Builder, Manager, RunEvent, State, SystemTray, WindowEvent}; use tauri_plugin_log::LogTarget; +use tracing; #[derive(Clone, serde::Serialize)] struct Payload { @@ -102,6 +105,8 @@ async fn main() { update_tunnel, delete_tunnel, get_latest_app_version, + start_global_logwatcher, + stop_global_logwatcher ]) .on_window_event(|event| match event.event() { WindowEvent::CloseRequested { api, .. } => { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af79cb85..3cafaaac 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -23,8 +23,12 @@ use crate::{ enterprise::periodic::config::poll_instance, error::Error, events::{CONNECTION_CHANGED, INSTANCE_UPDATE, LOCATION_UPDATE}, + log_watcher::{ + global_log_watcher::{spawn_global_log_watcher_task, stop_global_log_watcher_task}, + service_log_watcher::stop_log_watcher_task, + }, proto::{DeviceConfig, DeviceConfigResponse}, - service::{log_watcher::stop_log_watcher_task, proto::RemoveInterfaceRequest}, + service::proto::RemoveInterfaceRequest, tray::{configure_tray_icon, reload_tray_menu}, utils::{ disconnect_interface, get_location_interface_details, get_tunnel_interface_details, @@ -67,6 +71,20 @@ pub async fn connect( Ok(()) } +#[tauri::command(async)] +pub async fn start_global_logwatcher(handle: AppHandle) -> Result<(), Error> { + let result = spawn_global_log_watcher_task(&handle, tracing::Level::DEBUG).await; + if let Err(err) = result { + error!("Error while spawning the global log watcher task: {}", err) + } + Ok(()) +} + +#[tauri::command(async)] +pub async fn stop_global_logwatcher(handle: AppHandle) -> Result<(), Error> { + stop_global_log_watcher_task(&handle) +} + #[tauri::command] pub async fn disconnect( location_id: Id, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e5809372..629a8402 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod database; pub mod enterprise; pub mod error; pub mod events; +pub mod log_watcher; pub mod periodic; pub mod service; pub mod tray; diff --git a/src-tauri/src/log_watcher/global_log_watcher.rs b/src-tauri/src/log_watcher/global_log_watcher.rs new file mode 100644 index 00000000..84604457 --- /dev/null +++ b/src-tauri/src/log_watcher/global_log_watcher.rs @@ -0,0 +1,488 @@ +//! Global log watcher that monitors both the service and client logs. +//! +// FIXME: Some of the code here overlaps with the `log_watcher` module and could be refactored to avoid duplication. + +use std::{ + fs::{read_dir, File}, + io::{BufRead, BufReader}, + path::PathBuf, + str::FromStr, + thread::sleep, + time::Duration, +}; + +use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use regex::Regex; +use tauri::{async_runtime::TokioJoinHandle, AppHandle, Manager}; +use tokio_util::sync::CancellationToken; +use tracing::Level; + +use crate::{ + appstate::AppState, + error::Error, + log_watcher::{extract_timestamp, LogLine, LogLineFields, LogWatcherError}, + utils::get_service_log_dir, +}; + +/// Helper struct to handle log directory logic +#[derive(Debug)] +pub struct LogDirs { + // Service + service_log_dir: PathBuf, + current_service_log_file: Option, + // Client + client_log_dir: PathBuf, +} + +const DELAY: Duration = Duration::from_secs(2); + +impl LogDirs { + #[must_use] + pub fn new(handle: &AppHandle) -> Result { + debug!("Getting log directories."); + let service_log_dir = get_service_log_dir().to_path_buf(); + let client_log_dir = + handle + .path_resolver() + .app_log_dir() + .ok_or(LogWatcherError::LogPathError( + "Path to client logs directory is empty.".to_string(), + ))?; + debug!( + "Log directories of service and client are: {:?} and {:?}", + service_log_dir, client_log_dir + ); + + return Ok(Self { + service_log_dir, + current_service_log_file: None, + client_log_dir, + }); + } + + /// Find the latest log file in directory for the service + /// + /// Log files are rotated daily and have a known naming format, + /// with the last 10 characters specifying a date (e.g. `2023-12-15`). + fn get_latest_log_file(&self) -> Result, LogWatcherError> { + trace!( + "Getting latest log file from directory: {:?}", + self.service_log_dir + ); + let entries = read_dir(&self.service_log_dir)?; + + let mut latest_log = None; + let mut latest_time = NaiveDate::MIN; + for entry in entries.flatten() { + // skip directories + if entry.metadata()?.is_file() { + let filename = entry.file_name().to_string_lossy().into_owned(); + if let Some(timestamp) = extract_timestamp(&filename) { + if timestamp > latest_time { + latest_time = timestamp; + latest_log = Some(entry.path()); + } + } + } + } + + Ok(latest_log) + } + + fn get_current_service_file(&self) -> Result { + debug!( + "Opening service log file: {:?}", + self.current_service_log_file + ); + match &self.current_service_log_file { + Some(path) => { + let file = File::open(path)?; + debug!( + "Successfully opened service log file at {:?}", + self.current_service_log_file + ); + Ok(file) + } + None => Err(LogWatcherError::LogPathError(format!( + "Couldn't find service log file at: {:?}", + self.current_service_log_file + ))), + } + } + + fn get_client_file(&self) -> Result { + debug!( + "Opening the log file for the client, using directory: {:?}", + self.client_log_dir + ); + let dir_str = self + .client_log_dir + .to_str() + .ok_or(LogWatcherError::LogPathError(format!( + "Couldn't convert the client log directory path ({:?}) to a string slice", + self.client_log_dir + )))?; + let path = format!("{}/defguard-client.log", dir_str); + debug!("Constructed client log file path: {path}"); + let file = File::open(&path)?; + debug!("Client log file at {:?} opened successfully", path); + Ok(file) + } +} + +#[derive(Debug)] +pub struct GlobalLogWatcher { + log_level: Level, + from: Option>, + log_dirs: LogDirs, + handle: AppHandle, + cancellation_token: CancellationToken, + event_topic: String, +} + +impl GlobalLogWatcher { + #[must_use] + pub fn new( + handle: AppHandle, + cancellation_token: CancellationToken, + event_topic: String, + log_level: Level, + from: Option>, + ) -> Result { + Ok(Self { + log_level, + from, + log_dirs: LogDirs::new(&handle)?, + handle, + cancellation_token, + event_topic, + }) + } + + /// Start log watching, calls the [`parse_log_dirs`] function. + pub fn run(&mut self) -> Result<(), LogWatcherError> { + self.parse_log_dirs() + } + + /// Parse the log files + /// + /// This function will open the log files and read them line by line, parsing each line + /// into a [`LogLine`] struct and emitting it to the frontend. It can be stopped by cancelling + /// the token by calling [`stop_global_log_watcher_task()`] + fn parse_log_dirs(&mut self) -> Result<(), LogWatcherError> { + debug!("Parsing log directories"); + self.log_dirs.current_service_log_file = self.log_dirs.get_latest_log_file()?; + debug!( + "Latest service log file found: {:?}", + self.log_dirs.current_service_log_file + ); + + debug!("Opening log files"); + let mut service_reader = if let Ok(file) = self.log_dirs.get_current_service_file() { + debug!("Service log file opened successfully"); + Some(BufReader::new(file)) + } else { + None + }; + let mut client_reader = if let Ok(file) = self.log_dirs.get_client_file() { + debug!("Client log file opened successfully"); + Some(BufReader::new(file)) + } else { + None + }; + + debug!("Checking if log files are available"); + if service_reader.is_none() && client_reader.is_none() { + warn!( + "Couldn't read files at {:?} and {:?}, there will be no logs reported in the client.", + self.log_dirs.current_service_log_file, self.log_dirs.client_log_dir + ); + // Wait for logs to appear. + sleep(DELAY); + return Ok(()); + } + debug!("Log files are available, starting to read lines."); + + let mut service_line = String::new(); + let mut client_line = String::new(); + let mut parsed_lines = Vec::new(); + + // Track the amount of bytes read from the log lines + let mut service_line_read; + let mut client_line_read; + + debug!("Starting the log reading loop"); + loop { + // Service + // If the reader is present, read the log file to the end. + // Parse every line. If we hit EOF, check if there's a new log file. + // If there is, switch to it and leave the loop. + if let Some(reader) = &mut service_reader { + trace!("Reading service log lines"); + loop { + service_line_read = reader.read_line(&mut service_line)?; + if service_line_read == 0 { + trace!("Read 0 bytes from service log file, probably reached EOF."); + let latest_log_file = self.log_dirs.get_latest_log_file()?; + if latest_log_file.is_some() + && latest_log_file != self.log_dirs.current_service_log_file + { + debug!( + "Found a new service log file: {:?}, switching to it.", + latest_log_file + ); + self.log_dirs.current_service_log_file = latest_log_file; + break; + } + } else { + trace!("Read service log line: {service_line:?}"); + if let Some(parsed_line) = self.parse_service_log_line(&service_line)? { + trace!("Parsed service log line: {parsed_line:?}"); + parsed_lines.push(parsed_line); + } + service_line.clear(); + } + + if service_line_read == 0 { + break; + } + } + } + + // Client + // If the reader is present, read the log file to the end. + // Parse every line. + // Warning: don't use anything other than a trace log level in this loop for logs that would appear on every iteration (or very often) + // This could result in the reader constantly producing and consuming logs without any progress. + if let Some(reader) = &mut client_reader { + loop { + client_line_read = reader.read_line(&mut client_line)?; + if client_line_read > 0 { + match self.parse_client_log_line(&client_line) { + Ok(Some(parsed_line)) => { + trace!("Parsed client log line: {parsed_line:?}"); + parsed_lines.push(parsed_line); + } + Ok(None) => { + trace!("The following log line was filtered out: {client_line:?}"); + } + Err(err) => { + // trace here is intentional, adding error logs would loop the reader infinitely + trace!("Couldn't parse client log line: {client_line:?}: {err}"); + } + } + client_line.clear(); + } else { + break; + } + } + } + + trace!("Read 0 bytes from both log files, we've reached EOF in both cases."); + if !parsed_lines.is_empty() { + parsed_lines.sort_by(|a, b| a.timestamp.cmp(&b.timestamp)); + trace!("Emitting parsed lines for the frontend"); + self.handle.emit_all(&self.event_topic, &parsed_lines)?; + trace!("Emitted {} lines to the frontend", parsed_lines.len()); + } + parsed_lines.clear(); + trace!("Sleeping for {DELAY:?} seconds before reading again"); + sleep(DELAY); + + if self.cancellation_token.is_cancelled() { + info!("Received cancellation request. Stopping global log watcher"); + break; + } + } + + Ok(()) + } + + /// Parse a service log line + /// + /// Deserializes the log line into a known struct. + /// Also performs filtering by log level and optional timestamp. + fn parse_service_log_line(&self, line: &str) -> Result, LogWatcherError> { + trace!("Parsing service log line: {line}"); + let log_line = if let Ok(line) = serde_json::from_str::(line) { + line + } else { + warn!("Failed to parse service log line: {line}"); + return Ok(None); + }; + trace!("Parsed service log line into: {log_line:?}"); + + // filter by log level + if log_line.level > self.log_level { + trace!( + "Log level {} is above configured verbosity threshold {}. Skipping line...", + log_line.level, + self.log_level + ); + return Ok(None); + } + + // filter by optional timestamp + if let Some(from) = self.from { + if log_line.timestamp < from { + trace!( + "Timestamp {} is below configured threshold {from}. Skipping line...", + log_line.timestamp + ); + return Ok(None); + } + } + + trace!("Successfully parsed service log line."); + + Ok(Some(log_line)) + } + + /// Parse a client log line into a known struct using regex. + /// If the line doesn't match the regex, it's filtered out. + fn parse_client_log_line(&self, line: &str) -> Result, LogWatcherError> { + trace!("Parsing client log line: {line}"); + // Example log: + // [2024-10-09][09:08:41][DEBUG][defguard_client::commands] Retrieving all locations. + let regex = Regex::new(r"\[(.*?)\]\[(.*?)\]\[(.*?)\]\[(.*?)\] (.*)")?; + let captures = regex + .captures(line) + .ok_or(LogWatcherError::LogParseError(line.to_string()))?; + let timestamp_date = captures + .get(1) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + let timestamp_time = captures + .get(2) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let timestamp = format!("{} {}", timestamp_date, timestamp_time); + let timestamp = Utc.from_utc_datetime( + &NaiveDateTime::parse_from_str(×tamp, "%Y-%m-%d %H:%M:%S").map_err(|e| { + LogWatcherError::LogParseError(format!( + "Failed to parse timestamp {} with error: {}", + timestamp, e + )) + })?, + ); + + let level = tracing::Level::from_str( + captures + .get(3) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(), + ) + .map_err(|e| { + LogWatcherError::LogParseError(format!("Failed to parse log level with error: {}", e)) + })?; + + let target = captures + .get(4) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str() + .to_string(); + + let message = captures + .get(5) + .ok_or(LogWatcherError::LogParseError(line.to_string()))? + .as_str(); + + let fields = LogLineFields { + message: message.to_string(), + }; + + let log_line = LogLine { + timestamp, + level, + target, + fields, + span: None, + }; + + if log_line.level > self.log_level { + trace!( + "Log level {} is above configured verbosity threshold {}. Skipping line...", + log_line.level, + self.log_level + ); + return Ok(None); + } + + if let Some(from) = self.from { + if log_line.timestamp < from { + trace!("Timestamp is before configured threshold {from}. Skipping line..."); + return Ok(None); + } + } + + trace!( + "Successfully parsed client log line from file {:?}", + self.log_dirs.client_log_dir + ); + Ok(Some(log_line)) + } +} + +/// Starts a global log watcher in a separate thread +pub async fn spawn_global_log_watcher_task( + handle: &AppHandle, + log_level: Level, +) -> Result { + debug!("Spawning global log watcher."); + let app_state = handle.state::(); + + // Show logs only from the last hour + let from = Some(Utc::now() - Duration::from_secs(60 * 60)); + + let event_topic = format!("log-update-global"); + + // explicitly clone before topic is moved into the closure + let topic_clone = event_topic.clone(); + let handle_clone = handle.clone(); + + // prepare cancellation token + let token = CancellationToken::new(); + let token_clone = token.clone(); + + // spawn the task + let _join_handle: TokioJoinHandle> = tokio::spawn(async move { + GlobalLogWatcher::new(handle_clone, token_clone, topic_clone, log_level, from)?.run()?; + Ok(()) + }); + + // store `CancellationToken` to manually stop watcher thread + let mut log_watchers = app_state + .log_watchers + .lock() + .expect("Failed to lock log watchers mutex"); + if let Some(old_token) = log_watchers.insert("GLOBAL".to_string(), token) { + // cancel previous global log watcher + debug!("Existing global log watcher found. Cancelling..."); + old_token.cancel(); + } + + info!("Global log watcher spawned"); + Ok(event_topic) +} + +pub fn stop_global_log_watcher_task(handle: &AppHandle) -> Result<(), Error> { + debug!("Cancelling global log watcher task"); + let app_state = handle.state::(); + + // get `CancellationToken` to manually stop watcher thread + let mut log_watchers = app_state + .log_watchers + .lock() + .expect("Failed to lock log watchers mutex"); + + if let Some(token) = log_watchers.remove("GLOBAL") { + debug!("Using cancellation token for global log watcher"); + token.cancel(); + info!("Global log watcher cancelled"); + Ok(()) + } else { + error!("Global log watcher not found, cannot cancel"); + Err(Error::NotFound) + } +} diff --git a/src-tauri/src/log_watcher/mod.rs b/src-tauri/src/log_watcher/mod.rs new file mode 100644 index 00000000..8f142a36 --- /dev/null +++ b/src-tauri/src/log_watcher/mod.rs @@ -0,0 +1,57 @@ +use chrono::{DateTime, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, DisplayFromStr}; +use thiserror::Error; +use tracing::Level; + +pub mod global_log_watcher; +pub mod service_log_watcher; + +#[derive(Error, Debug)] +pub enum LogWatcherError { + #[error(transparent)] + TauriError(#[from] tauri::Error), + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), + #[error(transparent)] + TokioError(#[from] regex::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error("Error while accessing the log file: {0}")] + LogPathError(String), + #[error("Failed to parse log line: {0}")] + LogParseError(String), +} + +/// Represents a single line in log file +#[serde_as] +#[derive(Clone, Debug, Deserialize, Serialize)] +struct LogLine { + timestamp: DateTime, + #[serde_as(as = "DisplayFromStr")] + level: Level, + target: String, + fields: LogLineFields, + span: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Span { + interface_name: Option, + name: Option, + peer: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct LogLineFields { + message: String, +} + +fn extract_timestamp(filename: &str) -> Option { + trace!("Extracting timestamp from log file name: {filename}"); + // we know that the date is always in the last 10 characters + let split_pos = filename.char_indices().nth_back(9)?.0; + let timestamp = &filename[split_pos..]; + // parse and convert to `NaiveDate` + NaiveDate::parse_from_str(timestamp, "%Y-%m-%d").ok() +} diff --git a/src-tauri/src/service/log_watcher.rs b/src-tauri/src/log_watcher/service_log_watcher.rs similarity index 88% rename from src-tauri/src/service/log_watcher.rs rename to src-tauri/src/log_watcher/service_log_watcher.rs index e204e7c5..e0bc0972 100644 --- a/src-tauri/src/service/log_watcher.rs +++ b/src-tauri/src/log_watcher/service_log_watcher.rs @@ -22,45 +22,13 @@ use tokio_util::sync::CancellationToken; use tracing::Level; use crate::{ - appstate::AppState, database::models::Id, error::Error, utils::get_service_log_dir, - ConnectionType, + appstate::AppState, database::models::Id, error::Error, log_watcher::extract_timestamp, + utils::get_service_log_dir, ConnectionType, }; -const DELAY: Duration = Duration::from_secs(2); - -#[derive(Debug, Error)] -pub enum LogWatcherError { - #[error(transparent)] - TauriError(#[from] tauri::Error), - #[error(transparent)] - SerdeJsonError(#[from] serde_json::Error), - #[error(transparent)] - IoError(#[from] std::io::Error), -} +use super::{LogLine, LogWatcherError}; -/// Represents a single line in log file -#[serde_as] -#[derive(Clone, Debug, Deserialize, Serialize)] -struct LogLine { - timestamp: DateTime, - #[serde_as(as = "DisplayFromStr")] - level: Level, - target: String, - fields: LogLineFields, - span: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct Span { - interface_name: Option, - name: Option, - peer: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -struct LogLineFields { - message: String, -} +const DELAY: Duration = Duration::from_secs(2); #[derive(Debug)] pub struct ServiceLogWatcher<'a> { @@ -238,15 +206,6 @@ impl<'a> ServiceLogWatcher<'a> { } } -fn extract_timestamp(filename: &str) -> Option { - trace!("Extracting timestamp from log file name: {filename}"); - // we know that the date is always in the last 10 characters - let split_pos = filename.char_indices().nth_back(9)?.0; - let timestamp = &filename[split_pos..]; - // parse and convert to `NaiveDate` - NaiveDate::parse_from_str(timestamp, "%Y-%m-%d").ok() -} - /// Starts a log watcher in a separate thread /// /// The watcher parses `defguard-service` log files and extracts logs relevant diff --git a/src-tauri/src/service/mod.rs b/src-tauri/src/service/mod.rs index 2c585c0d..1740433e 100644 --- a/src-tauri/src/service/mod.rs +++ b/src-tauri/src/service/mod.rs @@ -2,7 +2,6 @@ pub mod config; pub mod proto { tonic::include_proto!("client"); } -pub mod log_watcher; pub mod utils; #[cfg(windows)] pub mod windows; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7c26440f..15fa754d 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -21,12 +21,10 @@ use crate::{ }, error::Error, events::CONNECTION_CHANGED, - service::{ - log_watcher::spawn_log_watcher_task, - proto::{ - desktop_daemon_service_client::DesktopDaemonServiceClient, CreateInterfaceRequest, - ReadInterfaceDataRequest, RemoveInterfaceRequest, - }, + log_watcher::service_log_watcher::spawn_log_watcher_task, + service::proto::{ + desktop_daemon_service_client::DesktopDaemonServiceClient, CreateInterfaceRequest, + ReadInterfaceDataRequest, RemoveInterfaceRequest, }, ConnectionType, }; diff --git a/src/pages/client/clientAPI/clientApi.ts b/src/pages/client/clientAPI/clientApi.ts index cfaae2ba..4944ed4c 100644 --- a/src/pages/client/clientAPI/clientApi.ts +++ b/src/pages/client/clientAPI/clientApi.ts @@ -117,6 +117,12 @@ const deleteTunnel = async (id: number): Promise => const getLatestAppVersion = async (): Promise => invokeWrapper('get_latest_app_version'); +const startGlobalLogWatcher = async (): Promise => + invokeWrapper('start_global_logwatcher'); + +const stopGlobalLogWatcher = async (): Promise => + invokeWrapper('stop_global_logwatcher'); + export const clientApi = { getInstances, getTunnels, @@ -141,4 +147,6 @@ export const clientApi = { openLink, getTunnelDetails, getLatestAppVersion, + startGlobalLogWatcher, + stopGlobalLogWatcher, }; diff --git a/src/pages/client/clientAPI/types.ts b/src/pages/client/clientAPI/types.ts index e89c16fd..489e5736 100644 --- a/src/pages/client/clientAPI/types.ts +++ b/src/pages/client/clientAPI/types.ts @@ -132,4 +132,6 @@ export type TauriCommandKey = | 'delete_tunnel' | 'location_interface_details' | 'open_link' - | 'get_latest_app_version'; + | 'get_latest_app_version' + | 'start_global_logwatcher' + | 'stop_global_logwatcher'; diff --git a/src/pages/client/pages/ClientSettingsPage/ClientSettingsPage.tsx b/src/pages/client/pages/ClientSettingsPage/ClientSettingsPage.tsx index 0ed84e87..f4e53092 100644 --- a/src/pages/client/pages/ClientSettingsPage/ClientSettingsPage.tsx +++ b/src/pages/client/pages/ClientSettingsPage/ClientSettingsPage.tsx @@ -2,6 +2,7 @@ import './style.scss'; import { useI18nContext } from '../../../../i18n/i18n-react'; import { Card } from '../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { GlobalLogs } from './components/GlobalLogs/GlobalLogs'; import { GlobalSettingsTab } from './components/GlobalSettingsTab/GlobalSettingsTab'; import { InfoCard } from './components/InfoCard/InfoCard'; @@ -15,9 +16,12 @@ export const ClientSettingsPage = () => {

{pageLL.title()}

- - - +
+ + + + +
diff --git a/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogs.tsx b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogs.tsx new file mode 100644 index 00000000..8f7a7a2f --- /dev/null +++ b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogs.tsx @@ -0,0 +1,152 @@ +import './style.scss'; + +import { clipboard } from '@tauri-apps/api'; +import { save } from '@tauri-apps/api/dialog'; +import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { writeTextFile } from '@tauri-apps/api/fs'; +import { useCallback, useEffect, useRef } from 'react'; + +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { ActionButton } from '../../../../../../shared/defguard-ui/components/Layout/ActionButton/ActionButton'; +import { ActionButtonVariant } from '../../../../../../shared/defguard-ui/components/Layout/ActionButton/types'; +import { Card } from '../../../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { clientApi } from '../../../../clientAPI/clientApi'; +import { LogItem, LogLevel } from '../../../../clientAPI/types'; +import { useClientStore } from '../../../../hooks/useClientStore'; +import { GlobalLogsSelect } from './GlobalLogsSelect'; + +export const GlobalLogs = () => { + const logsContainerElement = useRef(null); + const appLogLevel = useClientStore((state) => state.settings.log_level); + const locationLogLevelRef = useRef(appLogLevel); + const { LL } = useI18nContext(); + const localLL = LL.pages.client.pages.instancePage.detailView.details.logs; + const { startGlobalLogWatcher, stopGlobalLogWatcher } = clientApi; + + const handleLogsDownload = async () => { + const path = await save({}); + if (path) { + const logs = getAllLogs(); + await writeTextFile(path, logs); + } + }; + + const clearLogs = useCallback(() => { + if (logsContainerElement.current) { + logsContainerElement.current.innerHTML = ''; + } + }, []); + + // Clear logs when the component is unmounted or locationId changes + useEffect(() => { + return () => clearLogs(); + }, [clearLogs]); + + // Listen to new logs + useEffect(() => { + let eventUnlisten: UnlistenFn; + const startLogListen = async () => { + eventUnlisten = await listen( + `log-update-global`, + ({ payload: logItems }) => { + if (logsContainerElement.current) { + logItems.forEach((item) => { + if ( + logsContainerElement.current && + filterLogByLevel(locationLogLevelRef.current, item.level) + ) { + const messageString = `${item.timestamp} ${item.level} ${item.fields.message}`; + const element = createLogLineElement(messageString); + const scrollAfterAppend = + logsContainerElement.current.scrollHeight - + logsContainerElement.current.scrollTop === + logsContainerElement.current.clientHeight; + logsContainerElement.current.appendChild(element); + // auto scroll to bottom if user didn't scroll up + if (scrollAfterAppend) { + logsContainerElement.current.scrollTo({ + top: logsContainerElement.current.scrollHeight, + }); + } + } + }); + } + }, + ); + }; + startLogListen(); + startGlobalLogWatcher(); + + //unsubscribe on dismount + return () => { + stopGlobalLogWatcher(); + eventUnlisten?.(); + }; + //eslint-disable-next-line + }, []); + + const getAllLogs = () => { + let logs = ''; + + if (logsContainerElement) { + logsContainerElement.current?.childNodes.forEach((item) => { + logs += item.textContent + '\n'; + }); + } + + return logs; + }; + + return ( + +
+

{localLL.title()}

+ { + locationLogLevelRef.current = level; + clearLogs(); + stopGlobalLogWatcher(); + startGlobalLogWatcher(); + }} + /> + { + const logs = getAllLogs(); + if (logs) { + clipboard.writeText(logs); + } + }} + /> + +
+
+
+ ); +}; + +const createLogLineElement = (content: string): HTMLParagraphElement => { + const element = document.createElement('p'); + element.classList.add('log-line'); + element.textContent = content; + return element; +}; + +// return true if log should be visible +const filterLogByLevel = (target: LogLevel, log: LogLevel): boolean => { + const log_level = log.toLocaleLowerCase(); + switch (target) { + case 'error': + return log_level === 'error'; + case 'info': + return ['info', 'error'].includes(log_level); + case 'debug': + return ['error', 'info', 'debug'].includes(log_level); + default: + return true; + } +}; diff --git a/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSelect.tsx b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSelect.tsx new file mode 100644 index 00000000..18050438 --- /dev/null +++ b/src/pages/client/pages/ClientSettingsPage/components/GlobalLogs/GlobalLogsSelect.tsx @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useState } from 'react'; + +import { useI18nContext } from '../../../../../../i18n/i18n-react'; +import { Select } from '../../../../../../shared/defguard-ui/components/Layout/Select/Select'; +import { + SelectOption, + SelectSelectedValue, + SelectSizeVariant, +} from '../../../../../../shared/defguard-ui/components/Layout/Select/types'; +import { LogLevel } from '../../../../clientAPI/types'; + +type Props = { + initSelected: LogLevel; + onChange: (selected: LogLevel) => void; +}; + +export const GlobalLogsSelect = ({ initSelected, onChange }: Props) => { + const { LL } = useI18nContext(); + const localLL = LL.pages.client.pages.settingsPage.tabs.global.logging.options; + const [selected, setSelected] = useState(initSelected); + + const options = useMemo((): SelectOption[] => { + return [ + { + key: 0, + label: localLL.error(), + value: 'error', + }, + { + key: 1, + label: localLL.info(), + value: 'info', + }, + { + key: 2, + label: localLL.debug(), + value: 'debug', + }, + ]; + }, [localLL]); + + const renderSelected = useCallback( + (value: LogLevel): SelectSelectedValue => { + const option = options.find((o) => o.value === value); + if (option) { + return { + key: option.key, + displayValue: option.label, + }; + } + return { + key: 0, + displayValue: '', + }; + }, + [options], + ); + + return ( +