From f8f65c818e3ad7a15226db8d1f7fc3614cac16f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 07:40:28 +0000 Subject: [PATCH 1/2] Initial plan From bc91229d13d2318cb13edcdc8fbd226052661326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Sep 2025 07:55:16 +0000 Subject: [PATCH 2/2] Implement comprehensive monitoring API with HTTP and Unix socket support Co-authored-by: rbas <122783+rbas@users.noreply.github.com> --- Cargo.lock | 12 +++ Cargo.toml | 1 + README.md | 134 +++++++++++++++++++++++++++++++- config.sample.toml | 15 +++- src/http.rs | 108 +++++++++++++++++++++++++- src/lib.rs | 1 + src/main.rs | 38 +++++++-- src/model.rs | 125 +++++++++++++++++++++++++++++- src/service.rs | 35 ++++++++- src/settings.rs | 35 +++++++++ src/state.rs | 189 +++++++++++++++++++++++++++++++++++++++++++++ 11 files changed, 672 insertions(+), 21 deletions(-) create mode 100644 src/state.rs diff --git a/Cargo.lock b/Cargo.lock index 477d530..ec2239b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,6 +95,8 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", "sync_wrapper", "tokio", "tower", @@ -990,6 +992,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 0b3d86d..79b9b5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ categories = [ axum = { version = "0.8.4", default-features = false, features = [ "http1", "tokio", + "json", ] } clap = "4.5.46" config = { version = "0.15.15", default-features = false, features = ["toml"] } diff --git a/README.md b/README.md index 75520da..a68e313 100644 --- a/README.md +++ b/README.md @@ -117,22 +117,150 @@ Below is the configuration for how Fluxa will behave when running. ```toml [fluxa] -# Listen on +# Listen on HTTP listen = "127.0.0.1:8080" + +# Optional: Unix socket for API access +api_socket = "/tmp/fluxa.sock" ``` * `listen`: The address and port on which Fluxa will listen. In this example, Fluxa listens on `127.0.0.1:8080`, meaning it will only accept local connections. Adjust the address and port as needed. +* `api_socket` (optional): Path to Unix socket for API access. When specified, +Fluxa will create a Unix socket that serves the same API endpoints as the HTTP server. +This is useful for local monitoring tools and provides better security for local access. #### Fluxa Health Check Endpoint -Fluxa provides an endpoint for cross-monitoring, which can be used to monitor the status of the Fluxa service itself. This endpoint responds with an HTTP status of `200`` and a body of`ok`. You can use this endpoint to monitor Fluxa using another monitoring system. +Fluxa provides an endpoint for cross-monitoring, which can be used to monitor the status of the Fluxa service itself. This endpoint responds with an HTTP status of `200`` and a body of`Ok`. You can use this endpoint to monitor Fluxa using another monitoring system. + + Health Check URL: http://:/ + Response: + HTTP Status: 200 OK + Body: Ok + +You can use this endpoint to confirm that Fluxa is running and responsive. + +#### Real-time Monitoring API + +Fluxa exposes a local API for real-time monitoring statistics, enabling you to build Terminal UI (TUI) applications or web interfaces. The API is available via both HTTP and Unix socket (if configured). + +**API Endpoints:** + +1. **List all monitored services:** + ``` + GET /api/services + ``` + + **Example Response:** + ```json + { + "services": [ + { + "url": "https://example.com", + "interval_seconds": 300, + "max_retries": 3, + "retry_interval_seconds": 5, + "stats": { + "status": "Healthy", + "last_response_time_ms": 150, + "last_check_timestamp": 1672531200, + "next_check_timestamp": 1672531500, + "last_error": null, + "current_retry_count": 0 + } + } + ], + "total_count": 1 + } + ``` + +2. **Get individual service details:** + ``` + GET /api/services/{service_id} + ``` + + The `service_id` can be either: + - Numeric index (0, 1, 2, ...) + - The exact URL of the service + + **Example Response:** + ```json + { + "url": "https://example.com", + "interval_seconds": 300, + "max_retries": 3, + "retry_interval_seconds": 5, + "stats": { + "status": "Unhealthy", + "last_response_time_ms": null, + "last_check_timestamp": 1672531200, + "next_check_timestamp": 1672531500, + "last_error": "HTTP 500 - Internal Server Error", + "current_retry_count": 2 + } + } + ``` + +**API Usage Examples:** + +Using HTTP: +```bash +# Get all services +curl http://127.0.0.1:8080/api/services + +# Get specific service by index +curl http://127.0.0.1:8080/api/services/0 + +# Get service by URL (URL-encoded) +curl "http://127.0.0.1:8080/api/services/https%3A%2F%2Fexample.com" +``` + +Using Unix socket: +```bash +# Get all services +curl --unix-socket /tmp/fluxa.sock http://localhost/api/services + +# Get specific service +curl --unix-socket /tmp/fluxa.sock http://localhost/api/services/0 +``` + +**Field Descriptions:** + +- `status`: Current health status ("Healthy" or "Unhealthy") +- `last_response_time_ms`: Response time of the last successful request in milliseconds +- `last_check_timestamp`: Unix timestamp of the last health check +- `next_check_timestamp`: Unix timestamp of the next scheduled check +- `last_error`: Description of the last error encountered (null if no errors) +- `current_retry_count`: Number of retry attempts for the current check cycle + +**Building TUI/Web Clients:** + +The API is designed to be lightweight and easily consumed by monitoring tools: + +```bash +# Example: Simple status check script +#!/bin/bash +SOCKET="/tmp/fluxa.sock" +curl -s --unix-socket "$SOCKET" http://localhost/api/services | jq '.services[] | select(.stats.status == "Unhealthy")' +``` + +```python +# Example: Python client for monitoring +import requests +import json + +def get_unhealthy_services(): + response = requests.get("http://127.0.0.1:8080/api/services") + data = response.json() + return [s for s in data["services"] if s["stats"]["status"] == "Unhealthy"] +``` Health Check URL: http://:/ Response: HTTP Status: 200 OK - Body: ok + Body: Ok You can use this endpoint to confirm that Fluxa is running and responsive. diff --git a/config.sample.toml b/config.sample.toml index 43471f0..c91ae6b 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -7,24 +7,31 @@ pushover_user_key = "key" # # Example # -# [[Services]] +# [[services]] # url = "https:/rbas.cz" # interval_seconds = 300 # max_retries = 3 # retry_interval = 3 # -# [[Services]] +# [[services]] # url = "https://uptime.kuma.pet/" # interval_seconds = 300 # max_retries = 3 # retry_interval = 3 +[fluxa] +# HTTP server configuration +listen = "127.0.0.1:8080" + +# Optional: Unix socket for API access (uncomment to enable) +# api_socket = "/tmp/fluxa.sock" + [[services]] # Monitored url url = "http://localhost:3000" -# How ofter the url will be monitored +# How often the url will be monitored interval_seconds = 300 -# Determin how many times it will try before the url will be considered as down +# Determine how many times it will try before the url will be considered as down max_retries = 3 # How many seconds retry has to wait before next try retry_interval = 3 diff --git a/src/http.rs b/src/http.rs index d44a553..96c5eab 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,16 +1,79 @@ use std::net::SocketAddr; -use axum::{routing::get, Router}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, + routing::get, + Router, +}; use log::info; +use serde_json::{json, Value}; -use crate::error::HttpError; +use crate::{ + error::HttpError, + model::{MonitoredService, ServiceInfo}, + state::{get_all_service_states, get_service_state, SharedMonitoringState}, +}; async fn status_handler() -> &'static str { "Ok" } -pub async fn spawn_web_server(socket_addr: &str) -> Result<(), HttpError> { - let app = Router::new().route("/", get(status_handler)); +async fn get_all_services_handler( + State(state): State, +) -> Result, StatusCode> { + let all_stats = get_all_service_states(&state.monitoring_state).await; + let services: Vec = state + .services + .iter() + .filter_map(|service| { + all_stats + .get(&service.url) + .map(|stats| service.to_service_info(stats.clone())) + }) + .collect(); + + Ok(Json(json!({ + "services": services, + "total_count": services.len() + }))) +} + +async fn get_service_handler( + Path(service_id): Path, + State(state): State, +) -> Result, StatusCode> { + // Find service by URL or index + let service = if let Ok(index) = service_id.parse::() { + state.services.get(index) + } else { + state.services.iter().find(|s| s.url == service_id) + }; + + let service = service.ok_or(StatusCode::NOT_FOUND)?; + let stats = get_service_state(&state.monitoring_state, &service.url) + .await + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(service.to_service_info(stats))) +} + +#[derive(Clone)] +pub struct AppState { + pub monitoring_state: SharedMonitoringState, + pub services: Vec, +} + +pub async fn spawn_web_server( + socket_addr: &str, + app_state: AppState, +) -> Result<(), HttpError> { + let app = Router::new() + .route("/", get(status_handler)) + .route("/api/services", get(get_all_services_handler)) + .route("/api/services/{service_id}", get(get_service_handler)) + .with_state(app_state); let addr: SocketAddr = socket_addr.parse()?; info!("Listening on {addr}"); @@ -25,3 +88,40 @@ pub async fn spawn_web_server(socket_addr: &str) -> Result<(), HttpError> { }), } } + +#[cfg(unix)] +pub async fn spawn_unix_socket_server( + socket_path: &str, + app_state: AppState, +) -> Result<(), HttpError> { + use std::path::Path; + use tokio::net::UnixListener; + + let app = Router::new() + .route("/", get(status_handler)) + .route("/api/services", get(get_all_services_handler)) + .route("/api/services/{service_id}", get(get_service_handler)) + .with_state(app_state); + + // Remove existing socket file if it exists + if Path::new(socket_path).exists() { + std::fs::remove_file(socket_path) + .map_err(|e| HttpError::Server { + message: format!("Failed to remove existing socket file: {}", e), + })?; + } + + info!("Listening on Unix socket: {}", socket_path); + let listener = UnixListener::bind(socket_path) + .map_err(|e| HttpError::Server { + message: format!("Failed to bind to Unix socket: {}", e), + })?; + + let result = axum::serve(listener, app.into_make_service()).await; + match result { + Ok(_) => Ok(()), + Err(e) => Err(HttpError::Server { + message: e.to_string(), + }), + } +} diff --git a/src/lib.rs b/src/lib.rs index 31c6542..91cbcfd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,3 +4,4 @@ pub mod model; pub mod notification; pub mod service; pub mod settings; +pub mod state; diff --git a/src/main.rs b/src/main.rs index d942531..7de51a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,20 @@ use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; use clap::{builder::PathBufValueParser, Arg, Command}; use fluxa::{ error::FluxaError, - http::spawn_web_server, + http::{spawn_web_server, AppState}, notification::Notifier, service::{build_services, monitor_url}, settings::FluxaConfig, + state::{MonitoringState, SharedMonitoringState}, }; -use log::info; +use log::{error, info}; + +#[cfg(unix)] +use fluxa::http::spawn_unix_socket_server; #[tokio::main] async fn main() -> Result<(), FluxaError> { @@ -40,22 +46,44 @@ async fn main() -> Result<(), FluxaError> { // Configuration for monitoring let services = build_services(&conf)?; + // Create shared monitoring state + let monitoring_state: SharedMonitoringState = Arc::new(RwLock::new(MonitoringState::new())); + info!("Spawning monitoring"); // Spawn monitoring tasks let mut handles = vec![]; - for service in services { + for service in services.clone() { let notifier_clone = notifier.clone(); + let state_clone = monitoring_state.clone(); let handle = tokio::spawn(async move { - monitor_url(service, notifier_clone).await + monitor_url(service, notifier_clone, state_clone).await // TODO Handle errors }); handles.push(handle); } + // Create app state for HTTP server + let app_state = AppState { + monitoring_state: monitoring_state.clone(), + services, + }; + + // Spawn Unix socket server if configured + #[cfg(unix)] + if let Some(socket_path) = &conf.fluxa.api_socket { + let unix_app_state = app_state.clone(); + let socket_path = socket_path.clone(); + tokio::spawn(async move { + if let Err(e) = spawn_unix_socket_server(&socket_path, unix_app_state).await { + error!("Unix socket server error: {}", e); + } + }); + } + // Spawning web server for monitoring that service is alive let socket_addr = conf.fluxa.listen.as_str(); - spawn_web_server(socket_addr).await?; + spawn_web_server(socket_addr, app_state).await?; // Wait for all tasks to complete (they will run indefinitely) for handle in handles { diff --git a/src/model.rs b/src/model.rs index d700f1f..8e55cfc 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,11 +1,12 @@ -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use reqwest::Url; +use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::settings::ServiceConfig; -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub enum HealthStatus { Healthy, Unhealthy, @@ -17,7 +18,43 @@ pub enum MonitoredServiceError { InvalidUrl(String), } -#[derive(Debug)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MonitoringStats { + pub status: HealthStatus, + pub last_response_time_ms: Option, + pub last_check_timestamp: u64, + pub next_check_timestamp: u64, + pub last_error: Option, + pub current_retry_count: usize, +} + +impl Default for MonitoringStats { + fn default() -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + Self { + status: HealthStatus::Healthy, + last_response_time_ms: None, + last_check_timestamp: now, + next_check_timestamp: now, + last_error: None, + current_retry_count: 0, + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServiceInfo { + pub url: String, + pub interval_seconds: u64, + pub max_retries: usize, + pub retry_interval_seconds: u64, + pub stats: MonitoringStats, +} + +#[derive(Debug, Clone)] pub struct MonitoredService { pub url: String, pub interval_seconds: u64, @@ -45,6 +82,16 @@ impl MonitoredService { retry_interval, }) } + + pub fn to_service_info(&self, stats: MonitoringStats) -> ServiceInfo { + ServiceInfo { + url: self.url.clone(), + interval_seconds: self.interval_seconds, + max_retries: self.max_retries, + retry_interval_seconds: self.retry_interval.as_secs(), + stats, + } + } } fn is_valid_url(input: &str) -> bool { @@ -68,6 +115,7 @@ impl TryFrom<&ServiceConfig> for MonitoredService { #[cfg(test)] mod test { use super::*; + use serde_json; #[test] fn test_configuration_error_when_url_is_invalid() { @@ -82,4 +130,75 @@ mod test { assert!(actual.is_err()); } + + #[test] + fn test_monitoring_stats_serialization() { + let stats = MonitoringStats { + status: HealthStatus::Healthy, + last_response_time_ms: Some(150), + last_check_timestamp: 1672531200, + next_check_timestamp: 1672531500, + last_error: None, + current_retry_count: 0, + }; + + let json = serde_json::to_string(&stats).unwrap(); + let deserialized: MonitoringStats = serde_json::from_str(&json).unwrap(); + + assert_eq!(stats.status, deserialized.status); + assert_eq!(stats.last_response_time_ms, deserialized.last_response_time_ms); + assert_eq!(stats.last_check_timestamp, deserialized.last_check_timestamp); + assert_eq!(stats.next_check_timestamp, deserialized.next_check_timestamp); + assert_eq!(stats.last_error, deserialized.last_error); + assert_eq!(stats.current_retry_count, deserialized.current_retry_count); + } + + #[test] + fn test_service_info_serialization() { + let stats = MonitoringStats::default(); + let service_info = ServiceInfo { + url: "https://example.com".to_string(), + interval_seconds: 60, + max_retries: 3, + retry_interval_seconds: 5, + stats, + }; + + let json = serde_json::to_string(&service_info).unwrap(); + let deserialized: ServiceInfo = serde_json::from_str(&json).unwrap(); + + assert_eq!(service_info.url, deserialized.url); + assert_eq!(service_info.interval_seconds, deserialized.interval_seconds); + assert_eq!(service_info.max_retries, deserialized.max_retries); + assert_eq!(service_info.retry_interval_seconds, deserialized.retry_interval_seconds); + } + + #[test] + fn test_monitored_service_to_service_info() { + let service = MonitoredService::new( + "https://test.com".to_string(), + 120, + HealthStatus::Healthy, + 2, + Duration::from_secs(10), + ).unwrap(); + + let stats = MonitoringStats { + status: HealthStatus::Unhealthy, + last_response_time_ms: Some(300), + last_check_timestamp: 1672531200, + next_check_timestamp: 1672531320, + last_error: Some("Timeout".to_string()), + current_retry_count: 1, + }; + + let service_info = service.to_service_info(stats.clone()); + + assert_eq!(service_info.url, "https://test.com"); + assert_eq!(service_info.interval_seconds, 120); + assert_eq!(service_info.max_retries, 2); + assert_eq!(service_info.retry_interval_seconds, 10); + assert_eq!(service_info.stats.status, HealthStatus::Unhealthy); + assert_eq!(service_info.stats.current_retry_count, 1); + } } diff --git a/src/service.rs b/src/service.rs index 634705b..5d1af5d 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,5 +1,6 @@ use log::{debug, error, info, warn}; use reqwest::Client; +use std::time::Instant; use tokio::time::{self, Duration}; use crate::{ @@ -7,21 +8,36 @@ use crate::{ model::{HealthStatus, MonitoredService}, notification::Notifier, settings::FluxaConfig, + state::{update_service_state, SharedMonitoringState}, }; async fn send_request( client: &Client, service: &mut MonitoredService, notifier: &Notifier, + state: &SharedMonitoringState, ) -> Result { let mut current_health = HealthStatus::Unhealthy; + let mut response_time_ms = None; + let mut last_error = None; + let mut retry_count = 0; + for attempt in 0..=service.max_retries { + retry_count = attempt; + let start_time = Instant::now(); + match client.get(&service.url).send().await { Ok(response) => { + let elapsed = start_time.elapsed(); + response_time_ms = Some(elapsed.as_millis() as u64); + if response.status().is_success() { current_health = HealthStatus::Healthy; + last_error = None; break; } else { + let error_msg = format!("HTTP {} - {}", response.status(), response.status().canonical_reason().unwrap_or("Unknown")); + last_error = Some(error_msg.clone()); debug!( "Request to {} failed with status: {}", service.url, @@ -29,7 +45,10 @@ async fn send_request( ); } } - Err(_) => { + Err(e) => { + let error_msg = format!("Request failed: {}", e); + last_error = Some(error_msg.clone()); + if attempt < service.max_retries { debug!( "Attempt {} to {} failed. Retrying in {:?}...", @@ -50,6 +69,17 @@ async fn send_request( } } + // Update shared state + update_service_state( + state, + service.url.clone(), + current_health.clone(), + response_time_ms, + last_error, + retry_count, + service.interval_seconds, + ).await; + if current_health != service.health_status { if current_health == HealthStatus::Healthy { let message = format!("{} is now healthy!", service.url); @@ -79,9 +109,10 @@ async fn send_request( pub async fn monitor_url( mut service: MonitoredService, notifier: Notifier, + state: SharedMonitoringState, ) -> Result<(), ServiceError> { loop { - send_request(&Client::new(), &mut service, ¬ifier).await?; + send_request(&Client::new(), &mut service, ¬ifier, &state).await?; time::sleep(Duration::from_secs(service.interval_seconds)).await; } } diff --git a/src/settings.rs b/src/settings.rs index 63f51a3..df117eb 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -15,6 +15,7 @@ pub struct ServiceConfig { #[derive(Debug, Default, Deserialize, PartialEq, Eq, Clone)] pub struct Fluxa { pub listen: String, + pub api_socket: Option, } #[derive(Debug, Default, Deserialize, PartialEq, Eq, Clone)] @@ -99,6 +100,40 @@ retry_interval = 3 Ok(config) => { assert_eq!(config.services.len(), 1); assert_eq!(config.fluxa.listen, "http://localhost:8080"); + assert_eq!(config.fluxa.api_socket, None); + } + Err(e) => { + panic!("Deserialization failed with error: {:?}", e); + } + } + } + + #[test] + fn test_build_from_config_with_unix_socket() { + let fluxa_configuration = r#" +# Pushover API key +pushover_api_key = "api key" +# Pushover user or group key +pushover_user_key = "key" + +[fluxa] +listen = "127.0.0.1:8080" +api_socket = "/tmp/fluxa.sock" + +[[services]] +# Monitored url +url = "http://localhost:3000" +interval_seconds = 300 +max_retries = 3 +retry_interval = 3 + "#; + let result = fluxa_configuration.parse::(); + + match result { + Ok(config) => { + assert_eq!(config.services.len(), 1); + assert_eq!(config.fluxa.listen, "127.0.0.1:8080"); + assert_eq!(config.fluxa.api_socket, Some("/tmp/fluxa.sock".to_string())); } Err(e) => { panic!("Deserialization failed with error: {:?}", e); diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..57b1dc6 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,189 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tokio::sync::RwLock; + +use crate::model::{HealthStatus, MonitoringStats}; + +pub type SharedMonitoringState = Arc>; + +#[derive(Debug, Default)] +pub struct MonitoringState { + services: HashMap, +} + +impl MonitoringState { + pub fn new() -> Self { + Self { + services: HashMap::new(), + } + } + + pub fn update_service_stats( + &mut self, + url: String, + status: HealthStatus, + response_time_ms: Option, + error: Option, + retry_count: usize, + next_check_in_seconds: u64, + ) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let stats = MonitoringStats { + status, + last_response_time_ms: response_time_ms, + last_check_timestamp: now, + next_check_timestamp: now + next_check_in_seconds, + last_error: error, + current_retry_count: retry_count, + }; + + self.services.insert(url, stats); + } + + pub fn get_all_services(&self) -> HashMap { + self.services.clone() + } + + pub fn get_service(&self, url: &str) -> Option { + self.services.get(url).cloned() + } +} + +pub async fn update_service_state( + state: &SharedMonitoringState, + url: String, + status: HealthStatus, + response_time_ms: Option, + error: Option, + retry_count: usize, + next_check_in_seconds: u64, +) { + let mut state_guard = state.write().await; + state_guard.update_service_stats( + url, + status, + response_time_ms, + error, + retry_count, + next_check_in_seconds, + ); +} + +pub async fn get_all_service_states(state: &SharedMonitoringState) -> HashMap { + let state_guard = state.read().await; + state_guard.get_all_services() +} + +pub async fn get_service_state( + state: &SharedMonitoringState, + url: &str, +) -> Option { + let state_guard = state.read().await; + state_guard.get_service(url) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_monitoring_state_update_and_retrieval() { + let state = Arc::new(RwLock::new(MonitoringState::new())); + + // Update service state + update_service_state( + &state, + "https://example.com".to_string(), + HealthStatus::Healthy, + Some(250), + None, + 0, + 300, + ).await; + + // Retrieve specific service state + let service_state = get_service_state(&state, "https://example.com").await; + assert!(service_state.is_some()); + + let stats = service_state.unwrap(); + assert_eq!(stats.status, HealthStatus::Healthy); + assert_eq!(stats.last_response_time_ms, Some(250)); + assert_eq!(stats.last_error, None); + assert_eq!(stats.current_retry_count, 0); + + // Retrieve all service states + let all_states = get_all_service_states(&state).await; + assert_eq!(all_states.len(), 1); + assert!(all_states.contains_key("https://example.com")); + } + + #[tokio::test] + async fn test_monitoring_state_update_with_error() { + let state = Arc::new(RwLock::new(MonitoringState::new())); + + // Update service state with error + update_service_state( + &state, + "https://example.com".to_string(), + HealthStatus::Unhealthy, + None, + Some("HTTP 500 - Internal Server Error".to_string()), + 2, + 60, + ).await; + + // Retrieve service state + let service_state = get_service_state(&state, "https://example.com").await; + assert!(service_state.is_some()); + + let stats = service_state.unwrap(); + assert_eq!(stats.status, HealthStatus::Unhealthy); + assert_eq!(stats.last_response_time_ms, None); + assert_eq!(stats.last_error, Some("HTTP 500 - Internal Server Error".to_string())); + assert_eq!(stats.current_retry_count, 2); + } + + #[tokio::test] + async fn test_monitoring_state_multiple_services() { + let state = Arc::new(RwLock::new(MonitoringState::new())); + + // Add multiple services + update_service_state( + &state, + "https://service1.com".to_string(), + HealthStatus::Healthy, + Some(100), + None, + 0, + 300, + ).await; + + update_service_state( + &state, + "https://service2.com".to_string(), + HealthStatus::Unhealthy, + None, + Some("Connection timeout".to_string()), + 1, + 60, + ).await; + + // Retrieve all services + let all_states = get_all_service_states(&state).await; + assert_eq!(all_states.len(), 2); + + // Check individual services + let service1 = get_service_state(&state, "https://service1.com").await.unwrap(); + assert_eq!(service1.status, HealthStatus::Healthy); + assert_eq!(service1.current_retry_count, 0); + + let service2 = get_service_state(&state, "https://service2.com").await.unwrap(); + assert_eq!(service2.status, HealthStatus::Unhealthy); + assert_eq!(service2.current_retry_count, 1); + } +} \ No newline at end of file