diff --git a/Cargo.lock b/Cargo.lock index 949e6f1..3bb34a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,7 +393,7 @@ checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cargo-rustapi" -version = "0.1.191" +version = "0.1.195" dependencies = [ "anyhow", "assert_cmd", @@ -453,9 +453,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -3336,7 +3336,7 @@ dependencies = [ [[package]] name = "rustapi-bench" -version = "0.1.191" +version = "0.1.195" dependencies = [ "criterion", "serde", @@ -3346,12 +3346,13 @@ dependencies = [ [[package]] name = "rustapi-core" -version = "0.1.191" +version = "0.1.195" dependencies = [ "async-stream", "base64 0.22.1", "brotli 6.0.0", "bytes", + "chrono", "cookie", "flate2", "futures-util", @@ -3395,7 +3396,7 @@ dependencies = [ [[package]] name = "rustapi-extras" -version = "0.1.191" +version = "0.1.195" dependencies = [ "base64 0.22.1", "bytes", @@ -3434,7 +3435,7 @@ dependencies = [ [[package]] name = "rustapi-jobs" -version = "0.1.191" +version = "0.1.195" dependencies = [ "async-trait", "chrono", @@ -3452,7 +3453,7 @@ dependencies = [ [[package]] name = "rustapi-macros" -version = "0.1.191" +version = "0.1.195" dependencies = [ "proc-macro2", "quote", @@ -3461,7 +3462,7 @@ dependencies = [ [[package]] name = "rustapi-openapi" -version = "0.1.191" +version = "0.1.195" dependencies = [ "bytes", "http 1.4.0", @@ -3473,7 +3474,7 @@ dependencies = [ [[package]] name = "rustapi-rs" -version = "0.1.191" +version = "0.1.195" dependencies = [ "async-trait", "doc-comment", @@ -3496,7 +3497,7 @@ dependencies = [ [[package]] name = "rustapi-testing" -version = "0.1.191" +version = "0.1.195" dependencies = [ "bytes", "futures-util", @@ -3516,7 +3517,7 @@ dependencies = [ [[package]] name = "rustapi-toon" -version = "0.1.191" +version = "0.1.195" dependencies = [ "bytes", "futures-util", @@ -3534,7 +3535,7 @@ dependencies = [ [[package]] name = "rustapi-validate" -version = "0.1.191" +version = "0.1.195" dependencies = [ "async-trait", "http 1.4.0", @@ -3551,7 +3552,7 @@ dependencies = [ [[package]] name = "rustapi-view" -version = "0.1.191" +version = "0.1.195" dependencies = [ "bytes", "http 1.4.0", @@ -3568,7 +3569,7 @@ dependencies = [ [[package]] name = "rustapi-ws" -version = "0.1.191" +version = "0.1.195" dependencies = [ "async-trait", "base64 0.22.1", @@ -4729,7 +4730,7 @@ dependencies = [ [[package]] name = "toon-bench" -version = "0.1.191" +version = "0.1.195" dependencies = [ "criterion", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7f4af8d..f3596cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ members = [ ] [workspace.package] -version = "0.1.194" +version = "0.1.195" edition = "2021" authors = ["RustAPI Contributors"] license = "MIT OR Apache-2.0" @@ -120,3 +120,4 @@ rustls-pemfile = "2.2" rcgen = "0.13" + diff --git a/crates/rustapi-core/Cargo.toml b/crates/rustapi-core/Cargo.toml index 271e7cc..6e9b271 100644 --- a/crates/rustapi-core/Cargo.toml +++ b/crates/rustapi-core/Cargo.toml @@ -76,6 +76,7 @@ h3-quinn = { version = "0.0.10", optional = true } rustls = { workspace = true, optional = true } rustls-pemfile = { workspace = true, optional = true } rcgen = { workspace = true, optional = true } +chrono = "0.4.43" [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } @@ -98,3 +99,4 @@ http3 = ["dep:quinn", "dep:h3", "dep:h3-quinn", "dep:rustls", "dep:rustls-pemfil http3-dev = ["http3", "dep:rcgen"] + diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index 4514b06..380a9d0 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -34,6 +34,7 @@ pub struct RustApi { interceptors: InterceptorChain, #[cfg(feature = "http3")] http3_config: Option, + status_config: Option, } impl RustApi { @@ -61,6 +62,7 @@ impl RustApi { interceptors: InterceptorChain::new(), #[cfg(feature = "http3")] http3_config: None, + status_config: None, } } @@ -869,6 +871,54 @@ impl RustApi { .route(path, docs_router) } + /// Enable automatic status page with default configuration + pub fn status_page(self) -> Self { + self.status_page_with_config(crate::status::StatusConfig::default()) + } + + /// Enable automatic status page with custom configuration + pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self { + self.status_config = Some(config); + self + } + + // Helper to apply status page logic (monitor, layer, route) + fn apply_status_page(&mut self) { + if let Some(config) = &self.status_config { + let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); + + // 1. Add middleware layer + self.layers + .push(Box::new(crate::status::StatusLayer::new(monitor.clone()))); + + // 2. Add status route + use crate::router::MethodRouter; + use std::collections::HashMap; + + let monitor = monitor.clone(); + let config = config.clone(); + let path = config.path.clone(); // Clone path before moving config + + let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| { + let monitor = monitor.clone(); + let config = config.clone(); + Box::pin(async move { + crate::status::status_handler(monitor, config) + .await + .into_response() + }) + }); + + let mut handlers = HashMap::new(); + handlers.insert(http::Method::GET, handler); + let method_router = MethodRouter::from_boxed(handlers); + + // We need to take the router out to call route() which consumes it + let router = std::mem::take(&mut self.router); + self.router = router.route(&path, method_router); + } + } + /// Run the server /// /// # Example @@ -880,6 +930,9 @@ impl RustApi { /// .await /// ``` pub async fn run(mut self, addr: &str) -> Result<(), Box> { + // Apply status page if configured + self.apply_status_page(); + // Apply body limit layer if configured (should be first in the chain) if let Some(limit) = self.body_limit { // Prepend body limit layer so it's the first to process requests @@ -899,9 +952,10 @@ impl RustApi { where F: std::future::Future + Send + 'static, { - // Apply body limit layer if configured (should be first in the chain) + // Apply status page if configured + self.apply_status_page(); + if let Some(limit) = self.body_limit { - // Prepend body limit layer so it's the first to process requests self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); } @@ -944,6 +998,9 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; + // Apply status page if configured + self.apply_status_page(); + // Apply body limit layer if configured if let Some(limit) = self.body_limit { self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); @@ -980,6 +1037,9 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; + // Apply status page if configured + self.apply_status_page(); + // Apply body limit layer if configured if let Some(limit) = self.body_limit { self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 23cf339..93bbe33 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -74,6 +74,7 @@ mod router; mod server; pub mod sse; pub mod static_files; +pub mod status; pub mod stream; pub mod typed_path; pub mod validation; diff --git a/crates/rustapi-core/src/status.rs b/crates/rustapi-core/src/status.rs new file mode 100644 index 0000000..835ab57 --- /dev/null +++ b/crates/rustapi-core/src/status.rs @@ -0,0 +1,276 @@ +use crate::response::IntoResponse; +use crate::{Request, Response}; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +/// Configuration for the Status Page +#[derive(Clone, Debug)] +pub struct StatusConfig { + /// Path to serve the status page (default: "/status") + pub path: String, + /// Title of the status page + pub title: String, +} + +impl Default for StatusConfig { + fn default() -> Self { + Self { + path: "/status".to_string(), + title: "System Status".to_string(), + } + } +} + +impl StatusConfig { + pub fn new() -> Self { + Self::default() + } + + pub fn path(mut self, path: impl Into) -> Self { + self.path = path.into(); + self + } + + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } +} + +/// Metrics for a specific endpoint +#[derive(Debug, Clone, Default)] +pub struct EndpointMetrics { + pub total_requests: u64, + pub successful_requests: u64, + pub failed_requests: u64, + pub total_latency_ms: u128, + pub last_access: Option, +} + +impl EndpointMetrics { + pub fn avg_latency_ms(&self) -> f64 { + if self.total_requests == 0 { + 0.0 + } else { + self.total_latency_ms as f64 / self.total_requests as f64 + } + } + + pub fn success_rate(&self) -> f64 { + if self.total_requests == 0 { + 0.0 + } else { + (self.successful_requests as f64 / self.total_requests as f64) * 100.0 + } + } +} + +/// Shared state for monitoring +#[derive(Debug, Default)] +pub struct StatusMonitor { + /// Map of route path -> metrics + metrics: RwLock>, + /// System start time + start_time: Option, +} + +impl StatusMonitor { + pub fn new() -> Self { + Self { + metrics: RwLock::new(HashMap::new()), + start_time: Some(Instant::now()), + } + } + + pub fn record_request(&self, path: &str, duration: Duration, success: bool) { + let mut metrics = self.metrics.write().unwrap(); + let entry = metrics.entry(path.to_string()).or_default(); + + entry.total_requests += 1; + if success { + entry.successful_requests += 1; + } else { + entry.failed_requests += 1; + } + entry.total_latency_ms += duration.as_millis(); + + let now = chrono::Utc::now().to_rfc3339(); + entry.last_access = Some(now); + } + + pub fn get_uptime(&self) -> Duration { + self.start_time + .map(|t| t.elapsed()) + .unwrap_or(Duration::from_secs(0)) + } + + pub fn get_snapshot(&self) -> HashMap { + self.metrics.read().unwrap().clone() + } +} + +use crate::middleware::{BoxedNext, MiddlewareLayer}; + +/// Middleware layer for status monitoring +#[derive(Clone)] +pub struct StatusLayer { + monitor: Arc, +} + +impl StatusLayer { + pub fn new(monitor: Arc) -> Self { + Self { monitor } + } +} + +impl MiddlewareLayer for StatusLayer { + fn call( + &self, + req: Request, + next: BoxedNext, + ) -> Pin + Send + 'static>> { + let monitor = self.monitor.clone(); + let path = req.uri().path().to_string(); + + Box::pin(async move { + let start = Instant::now(); + let response = next(req).await; + let duration = start.elapsed(); + + let status = response.status(); + let success = status.is_success() || status.is_redirection(); + + monitor.record_request(&path, duration, success); + + response + }) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// HTML Status Page Handler +pub async fn status_handler( + monitor: Arc, + config: StatusConfig, +) -> impl IntoResponse { + let metrics = monitor.get_snapshot(); + let uptime = monitor.get_uptime(); + + // Simple HTML template + let mut html = format!( + r#" + + + {title} + + + + +
+

{title}

+

System Uptime: {uptime}

+
+ +
+
+
{total_reqs}
+
Total Requests
+
+
+
{total_eps}
+
Active Endpoints
+
+
+ + + + + + + + + + + + +"#, + title = config.title, + uptime = format_duration(uptime), + total_reqs = metrics.values().map(|m| m.total_requests).sum::(), + total_eps = metrics.len() + ); + + // Sort metrics by path + let mut sorted_metrics: Vec<_> = metrics.iter().collect(); + sorted_metrics.sort_by_key(|(k, _)| *k); + + for (path, m) in sorted_metrics { + let success_class = if m.success_rate() > 95.0 { + "status-ok" + } else { + "status-err" + }; + + html.push_str(&format!( + r#" + + + + + + +"#, + path, + m.total_requests, + success_class, + m.success_rate(), + m.avg_latency_ms(), + m.last_access.as_deref().unwrap_or("-") + )); + } + + html.push_str( + r#" +
EndpointRequestsSuccess RateAvg LatencyLast Access
{}{}{:.1}%{:.2} ms{}
+ +"#, + ); + + crate::response::Html(html) +} + +fn format_duration(d: Duration) -> String { + let seconds = d.as_secs(); + let days = seconds / 86400; + let hours = (seconds % 86400) / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + + if days > 0 { + format!("{}d {}h {}m {}s", days, hours, minutes, secs) + } else if hours > 0 { + format!("{}h {}m {}s", hours, minutes, secs) + } else { + format!("{}m {}s", minutes, secs) + } +} diff --git a/crates/rustapi-core/tests/status_page.rs b/crates/rustapi-core/tests/status_page.rs new file mode 100644 index 0000000..a8b65b8 --- /dev/null +++ b/crates/rustapi-core/tests/status_page.rs @@ -0,0 +1,74 @@ +use rustapi_core::{get, RustApi}; +use std::time::Duration; +use tokio::sync::oneshot; + +#[tokio::test] +async fn test_status_page() { + async fn task_handler() -> &'static str { + "ok" + } + + // Setup app with status page + let app = RustApi::new() + .status_page() // Enable status page + .route("/task", get(task_handler)); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + // Give it a moment to start + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + // 1. Check initial status page + let res = client + .get(format!("{}/status", base_url)) + .send() + .await + .expect("Failed to get status"); + assert_eq!(res.status(), 200); + let body = res.text().await.unwrap(); + assert!(body.contains("System Status")); + assert!(body.contains("Total Requests")); + + // 2. Make some requests to generate metrics + for _ in 0..2 { + let res = client + .get(format!("{}/task", base_url)) + .send() + .await + .expect("Failed to get task"); + assert_eq!(res.status(), 200); + } + + // 3. Check updated status page + let res = client + .get(format!("{}/status", base_url)) + .send() + .await + .expect("Failed to get status"); + assert_eq!(res.status(), 200); + let body = res.text().await.unwrap(); + + // Should show path /task + assert!(body.contains("/task")); + + // Send shutdown signal + tx.send(()).unwrap(); + + // Wait for server to exit + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} diff --git a/crates/rustapi-rs/examples/status_demo.rs b/crates/rustapi-rs/examples/status_demo.rs new file mode 100644 index 0000000..9e0140f --- /dev/null +++ b/crates/rustapi-rs/examples/status_demo.rs @@ -0,0 +1,56 @@ +use rustapi_rs::prelude::*; +use std::time::Duration; +use tokio::time::sleep; + +/// A simple demo to showcase the RustAPI Status Page. +/// +/// Run with: `cargo run --example status_demo` +/// Then verify: +/// - Status Page: http://127.0.0.1:3000/status +/// - Generate Traffic: http://127.0.0.1:3000/api/fast +/// - Generate Errors: http://127.0.0.1:3000/api/slow +#[tokio::main] +async fn main() -> Result<(), Box> { + // 1. Define some handlers to generate metrics + + // A fast endpoint + async fn fast_handler() -> &'static str { + "Fast response!" + } + + // A slow endpoint with random delay to show latency + async fn slow_handler() -> &'static str { + sleep(Duration::from_millis(500)).await; + "Slow response... sleepy..." + } + + // An endpoint that sometimes fails + async fn flaky_handler() -> Result<&'static str, rustapi_rs::Response> { + use std::sync::atomic::{AtomicBool, Ordering}; + static FAILURE: AtomicBool = AtomicBool::new(false); + + // Toggle failure every call + let fail = FAILURE.fetch_xor(true, Ordering::Relaxed); + + if !fail { + Ok("Success!") + } else { + Err(rustapi_rs::StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + } + + // 2. Build the app with status page enabled + println!("Starting Status Page Demo..."); + println!(" -> Open http://127.0.0.1:3000/status to see the dashboard"); + println!(" -> Visit http://127.0.0.1:3000/fast to generate traffic"); + println!(" -> Visit http://127.0.0.1:3000/slow to generate latency"); + println!(" -> Visit http://127.0.0.1:3000/flaky to generate errors"); + + RustApi::auto() + .status_page() // <--- Enable Status Page + .route("/fast", get(fast_handler)) + .route("/slow", get(slow_handler)) + .route("/flaky", get(flaky_handler)) + .run("127.0.0.1:3000") + .await +} diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index ee3dd22..19df7d3 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -36,5 +36,6 @@ - [Production Tuning](recipes/high_performance.md) - [Deployment](recipes/deployment.md) - [HTTP/3 (QUIC)](recipes/http3_quic.md) + - [Automatic Status Page](recipes/status_page.md) diff --git a/docs/cookbook/src/recipes/status_page.md b/docs/cookbook/src/recipes/status_page.md new file mode 100644 index 0000000..1e26dfb --- /dev/null +++ b/docs/cookbook/src/recipes/status_page.md @@ -0,0 +1,136 @@ +# Automatic Status Page + +RustAPI comes with a built-in, zero-configuration status page that gives you instant visibility into your application's health and performance. + +## Enabling the Status Page + +To enable the status page, simply call `.status_page()` on your `RustApi` builder: + +```rust +use rustapi_rs::prelude::*; + +#[rustapi::main] +async fn main() -> Result<()> { + RustApi::auto() + .status_page() // <--- Enable Status Page + .run("127.0.0.1:8080") + .await +} +``` + +By default, the status page is available at `/status`. + +## Full Example + +Here is a complete, runnable example that demonstrates how to set up the status page and generate some traffic to see the metrics in action. + +You can find this example in `crates/rustapi-rs/examples/status_demo.rs`. + +```rust +use rustapi_rs::prelude::*; +use std::time::Duration; +use tokio::time::sleep; + +/// A simple demo to showcase the RustAPI Status Page. +/// +/// Run with: `cargo run -p rustapi-rs --example status_demo` +/// Then verify: +/// - Status Page: http://127.0.0.1:3000/status +/// - Generate Traffic: http://127.0.0.1:3000/api/fast +/// - Generate Errors: http://127.0.0.1:3000/api/slow +#[tokio::main] +async fn main() -> Result<(), Box> { + // 1. Define some handlers to generate metrics + + // A fast endpoint + async fn fast_handler() -> &'static str { + "Fast response!" + } + + // A slow endpoint with random delay to show latency + async fn slow_handler() -> &'static str { + sleep(Duration::from_millis(500)).await; + "Slow response... sleepy..." + } + + // An endpoint that sometimes fails + async fn flaky_handler() -> Result<&'static str, rustapi_rs::Response> { + use std::sync::atomic::{AtomicBool, Ordering}; + static FAILURE: AtomicBool = AtomicBool::new(false); + + // Toggle failure every call + let fail = FAILURE.fetch_xor(true, Ordering::Relaxed); + + if !fail { + Ok("Success!") + } else { + Err(rustapi_rs::StatusCode::INTERNAL_SERVER_ERROR.into_response()) + } + } + + // 2. Build the app with status page enabled + println!("Starting Status Page Demo..."); + println!(" -> Open http://127.0.0.1:3000/status to see the dashboard"); + println!(" -> Visit http://127.0.0.1:3000/fast to generate traffic"); + println!(" -> Visit http://127.0.0.1:3000/slow to generate latency"); + println!(" -> Visit http://127.0.0.1:3000/flaky to generate errors"); + + RustApi::auto() + .status_page() // <--- Enable Status Page + .route("/fast", get(fast_handler)) + .route("/slow", get(slow_handler)) + .route("/flaky", get(flaky_handler)) + .run("127.0.0.1:3000") + .await +} +``` + +## Dashboard Overview + +The status page provides a comprehensive real-time view of your system. + +### 1. Global System Stats +At the top of the dashboard, you'll see high-level metrics for the entire application: +- **System Uptime**: How long the server has been running. +- **Total Requests**: The aggregate number of requests served across all endpoints. +- **Active Endpoints**: The number of distinct routes that have received traffic. +- **Auto-Refresh**: The page automatically updates every 5 seconds, so you can keep it open on a second monitor. + +### 2. Endpoint Metrics Grid +The main section is a detailed table showing granular performance data for every endpoint: + +| Metric | Description | +|--------|-------------| +| **Endpoint** | The path of the route (e.g., `/api/users`). | +| **Requests** | Total number of hits this specific route has received. | +| **Success Rate** | Visual indicator of health.
🟢 **Green**: ≥95% success
🔴 **Red**: <95% success | +| **Avg Latency** | The average time (in milliseconds) it takes to serve a request. | +| **Last Access** | Timestamp of the most recent request to this endpoint. | + +### 3. Visual Design +The dashboard is built with a "zero-dependency" philosophy. It renders a single, self-contained HTML page directly from the binary. +- **Modern UI**: Clean, card-based layout using system fonts. +- **Responsive**: Adapts perfectly to mobile and desktop screens. +- **Lightweight**: No external CSS/JS files to manage or load. + +## Custom Configuration + +If you need more control, you can customize the path and title of the status page: + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::status::StatusConfig; + +#[rustapi::main] +async fn main() -> Result<()> { + // Configure the status page + let config = StatusConfig::new() + .path("/admin/health") // Change URL to /admin/health + .title("Production Node 1"); // Custom title for easy identification + + RustApi::auto() + .status_page_with_config(config) + .run("127.0.0.1:8080") + .await +} +``` diff --git a/tests/integration/main.rs b/tests/integration/main.rs index c9feb5d..c7f2511 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -39,4 +39,7 @@ mod tests { .assert_status(200) .assert_body_contains("Checking echo"); } + + + }