feat(readarr): fetch Readarr logs and expose them on the CLI

Adds ReadarrEvent::GetLogs backed by get_readarr_logs, which formats each
record into the Readarr logs list, and surfaces it as
'readarr list logs --events N'.

The --output-in-log-format branch reads the Readarr log lines, and is
covered by a test that pins that source so it cannot silently drift to
another servarr's data.
This commit is contained in:
Dark-Alex-17 committed 2026-08-20 21:33:57 -06:00
1 parent c6d729162a
commit 690a17e152
6 files changed
+276 -9

No files matched your search

+6
View File
@@ -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
@@ -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) {
+60 -2
View File
@@ -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<LogResponse> {
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<SecurityConfig> {
@@ -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()