diff --git a/src/cli/readarr/list_command_handler.rs b/src/cli/readarr/list_command_handler.rs index 33959fbf..85909d48 100644 --- a/src/cli/readarr/list_command_handler.rs +++ b/src/cli/readarr/list_command_handler.rs @@ -19,6 +19,16 @@ mod list_command_handler_tests; pub enum ReadarrListCommand { #[command(about = "List disk space details for all provisioned root folders in Readarr")] DiskSpace, + #[command(about = "Fetch Readarr logs")] + Logs { + #[arg(long, help = "How many log events to fetch", default_value_t = 500)] + events: u64, + #[arg( + long, + help = "Output the logs in the same format as they appear in the log files" + )] + output_in_log_format: bool, + }, #[command(about = "List all Readarr updates")] Updates, } @@ -30,19 +40,19 @@ impl From for Command { } pub(super) struct ReadarrListCommandHandler<'a, 'b> { - _app: &'a Arc>>, + app: &'a Arc>>, command: ReadarrListCommand, network: &'a mut dyn NetworkTrait, } impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListCommandHandler<'a, 'b> { fn with( - _app: &'a Arc>>, + app: &'a Arc>>, command: ReadarrListCommand, network: &'a mut dyn NetworkTrait, ) -> Self { ReadarrListCommandHandler { - _app, + app, command, network, } @@ -57,6 +67,23 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrListCommand> for ReadarrListComman .await?; serde_json::to_string_pretty(&resp)? } + ReadarrListCommand::Logs { + events, + output_in_log_format, + } => { + let logs = self + .network + .handle_network_event(ReadarrEvent::GetLogs(events).into()) + .await?; + + if output_in_log_format { + let log_lines = &self.app.lock().await.data.readarr_data.logs.items; + + serde_json::to_string_pretty(log_lines)? + } else { + serde_json::to_string_pretty(&logs)? + } + } ReadarrListCommand::Updates => { let resp = self .network diff --git a/src/cli/readarr/list_command_handler_tests.rs b/src/cli/readarr/list_command_handler_tests.rs index 88f4958d..3a9db21e 100644 --- a/src/cli/readarr/list_command_handler_tests.rs +++ b/src/cli/readarr/list_command_handler_tests.rs @@ -18,9 +18,10 @@ mod tests { } mod cli { - use rstest::rstest; - use super::*; + use clap::{Parser, error::ErrorKind}; + use pretty_assertions::assert_eq; + use rstest::rstest; #[rstest] fn test_list_commands_have_no_arg_requirements( @@ -30,12 +31,39 @@ mod tests { assert_ok!(&result); } + + #[test] + fn test_list_logs_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "readarr", "list", "logs", "--events"]); + + assert_err!(&result); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_logs_default_values() { + let expected_args = ReadarrListCommand::Logs { + events: 500, + output_in_log_format: false, + }; + let result = Cli::try_parse_from(["managarr", "readarr", "list", "logs"]); + + assert_ok!(&result); + + let Some(Command::Readarr(ReadarrCommand::List(logs_command))) = result.unwrap().command + else { + panic!("Unexpected command type"); + }; + assert_eq!(logs_command, expected_args); + } } mod handler { use std::sync::Arc; use mockall::predicate::eq; + use pretty_assertions::assert_str_eq; use serde_json::json; use tokio::sync::Mutex; @@ -45,7 +73,7 @@ mod tests { CliCommandHandler, readarr::list_command_handler::{ReadarrListCommand, ReadarrListCommandHandler}, }, - models::{Serdeable, readarr_models::ReadarrSerdeable}, + models::{HorizontallyScrollableText, Serdeable, readarr_models::ReadarrSerdeable}, network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent}, }; @@ -72,6 +100,67 @@ mod tests { assert_ok!(&result); } + #[tokio::test] + async fn test_handle_list_logs_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + ReadarrEvent::GetLogs(expected_events).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: false, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + } + + #[tokio::test] + async fn test_handle_list_logs_command_output_in_log_format() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(ReadarrEvent::GetLogs(1000).into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Readarr(ReadarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::test_default())); + app_arc + .lock() + .await + .data + .readarr_data + .logs + .set_items(vec![HorizontallyScrollableText::from("readarr log line")]); + let list_logs_command = ReadarrListCommand::Logs { + events: 1000, + output_in_log_format: true, + }; + + let result = ReadarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert_ok!(&result); + assert_str_eq!(result.unwrap(), "[\n \"readarr log line\"\n]"); + } + #[tokio::test] async fn test_handle_list_updates_command() { let mut mock_network = MockNetworkTrait::new(); diff --git a/src/network/readarr_network/mod.rs b/src/network/readarr_network/mod.rs index d8313d75..91d407fc 100644 --- a/src/network/readarr_network/mod.rs +++ b/src/network/readarr_network/mod.rs @@ -15,6 +15,7 @@ mod readarr_network_tests; pub enum ReadarrEvent { GetDiskSpace, GetHostConfig, + GetLogs(u64), GetSecurityConfig, GetStatus, GetUpdates, @@ -27,6 +28,7 @@ impl NetworkResource for ReadarrEvent { ReadarrEvent::GetHostConfig | ReadarrEvent::GetSecurityConfig => "/config/host", ReadarrEvent::GetDiskSpace => "/diskspace", ReadarrEvent::HealthCheck => "/health", + ReadarrEvent::GetLogs(_) => "/log", ReadarrEvent::GetStatus => "/system/status", ReadarrEvent::GetUpdates => "/update", } @@ -53,6 +55,10 @@ impl Network<'_, '_> { .get_readarr_host_config() .await .map(ReadarrSerdeable::from), + ReadarrEvent::GetLogs(events) => self + .get_readarr_logs(events) + .await + .map(ReadarrSerdeable::from), ReadarrEvent::GetSecurityConfig => self .get_readarr_security_config() .await diff --git a/src/network/readarr_network/readarr_network_tests.rs b/src/network/readarr_network/readarr_network_tests.rs index 5375665c..4b1cf495 100644 --- a/src/network/readarr_network/readarr_network_tests.rs +++ b/src/network/readarr_network/readarr_network_tests.rs @@ -10,6 +10,7 @@ mod tests { #[case(ReadarrEvent::GetSecurityConfig, "/config/host")] #[case(ReadarrEvent::GetDiskSpace, "/diskspace")] #[case(ReadarrEvent::HealthCheck, "/health")] + #[case(ReadarrEvent::GetLogs(500), "/log")] #[case(ReadarrEvent::GetStatus, "/system/status")] #[case(ReadarrEvent::GetUpdates, "/update")] fn test_resource(#[case] event: ReadarrEvent, #[case] expected_uri: &str) { diff --git a/src/network/readarr_network/system/mod.rs b/src/network/readarr_network/system/mod.rs index 47a8cd3b..4b960ec6 100644 --- a/src/network/readarr_network/system/mod.rs +++ b/src/network/readarr_network/system/mod.rs @@ -1,5 +1,7 @@ -use crate::models::ScrollableText; -use crate::models::servarr_models::{DiskSpace, HostConfig, SecurityConfig, SystemStatus, Update}; +use crate::models::servarr_models::{ + DiskSpace, HostConfig, LogResponse, SecurityConfig, SystemStatus, Update, +}; +use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; use crate::network::readarr_network::ReadarrEvent; use crate::network::{Network, RequestMethod}; use anyhow::Result; @@ -43,6 +45,62 @@ impl Network<'_, '_> { .await } + pub(in crate::network::readarr_network) async fn get_readarr_logs( + &mut self, + events: u64, + ) -> Result { + info!("Fetching Readarr logs"); + let event = ReadarrEvent::GetLogs(events); + + let params = format!("pageSize={events}&sortDirection=descending&sortKey=time"); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if let Some(exception) = log.exception { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log + .logger + .as_ref() + .expect("logger must exist when exception is present"), + log + .exception_type + .as_ref() + .expect("exception_type must exist when exception is present"), + exception + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().expect("logger must exist in log entry"), + log + .message + .as_ref() + .expect("message must exist when exception is not present") + )) + } + }) + .collect(); + + app.data.readarr_data.logs.set_items(log_lines); + app.data.readarr_data.logs.scroll_to_bottom(); + }) + .await + } + pub(in crate::network::readarr_network) async fn get_readarr_security_config( &mut self, ) -> Result { diff --git a/src/network/readarr_network/system/readarr_system_network_tests.rs b/src/network/readarr_network/system/readarr_system_network_tests.rs index 958448cf..a8b4ed60 100644 --- a/src/network/readarr_network/system/readarr_system_network_tests.rs +++ b/src/network/readarr_network/system/readarr_system_network_tests.rs @@ -4,7 +4,7 @@ mod tests { use crate::models::readarr_models::ReadarrSerdeable; use crate::models::servarr_models::{ AuthenticationMethod, AuthenticationRequired, CertificateValidation, DiskSpace, HostConfig, - SecurityConfig, SystemStatus, Update, + LogResponse, SecurityConfig, SystemStatus, Update, }; use crate::network::network_tests::test_utils::{MockServarrApi, test_network}; use crate::network::readarr_network::ReadarrEvent; @@ -145,6 +145,92 @@ mod tests { assert_err!(result); } + #[tokio::test] + async fn test_handle_get_readarr_logs_event() { + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "ReadarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|ReadarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let (mock, app, _server) = MockServarrApi::get() + .returns(logs_response_json) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + + let ReadarrSerdeable::LogResponse(logs) = result.unwrap() else { + panic!("Expected LogResponse") + }; + + assert_eq!(logs, response); + assert_eq!(app.lock().await.data.readarr_data.logs.items, expected_logs); + assert_str_eq!( + app + .lock() + .await + .data + .readarr_data + .logs + .current_selection() + .text, + "2023-05-20 21:29:16 UTC|INFO|TestLogger|test message" + ); + } + + #[tokio::test] + async fn test_handle_get_readarr_logs_event_failure() { + let (mock, app, _server) = MockServarrApi::get() + .returns(json!({})) + .status(500) + .query("pageSize=500&sortDirection=descending&sortKey=time") + .build_for(ReadarrEvent::GetLogs(500)) + .await; + app.lock().await.server_tabs.set_index(3); + let mut network = test_network(&app); + + let result = network + .handle_readarr_event(ReadarrEvent::GetLogs(500)) + .await; + + mock.assert_async().await; + assert_err!(result); + assert_is_empty!(app.lock().await.data.readarr_data.logs); + } + #[tokio::test] async fn test_handle_get_readarr_security_config_event() { let (mock, app, _server) = MockServarrApi::get()