mockforge_ui/handlers/
admin.rs

1//! Admin dashboard and server management handlers
2//!
3//! This module handles admin dashboard operations, server management,
4//! metrics, logs, and configuration.
5
6use axum::{
7    extract::{Query, State},
8    response::Json,
9};
10use chrono::Utc;
11use serde_json::{json, Value};
12use std::collections::HashMap;
13
14use crate::handlers::AdminState;
15use crate::models::*;
16
17/// Request metrics for tracking
18#[derive(Debug, Clone, Default)]
19pub struct RequestMetrics {
20    /// Total requests served
21    pub total_requests: u64,
22    /// Active requests currently being processed
23    pub active_requests: u64,
24    /// Average response time in milliseconds
25    pub average_response_time: f64,
26    /// Request rate per second
27    pub requests_per_second: f64,
28    /// Total errors encountered
29    pub total_errors: u64,
30}
31
32/// Get server information
33pub async fn get_server_info(State(state): State<AdminState>) -> Json<Value> {
34    Json(json!({
35        "http_server": state.http_server_addr.map(|addr| addr.to_string()).unwrap_or_else(|| "disabled".to_string()),
36        "ws_server": state.ws_server_addr.map(|addr| addr.to_string()).unwrap_or_else(|| "disabled".to_string()),
37        "grpc_server": state.grpc_server_addr.map(|addr| addr.to_string()).unwrap_or_else(|| "disabled".to_string()),
38        "graphql_server": state.graphql_server_addr.map(|addr| addr.to_string()).unwrap_or_else(|| "disabled".to_string()),
39        "api_enabled": state.api_enabled,
40    }))
41}
42
43/// Get health check status
44pub async fn get_health() -> Json<HealthCheck> {
45    Json(HealthCheck {
46        status: "healthy".to_string(),
47        services: HashMap::new(),
48        last_check: Utc::now(),
49        issues: Vec::new(),
50    })
51}
52
53/// Get logs
54pub async fn get_logs(
55    State(_state): State<AdminState>,
56    Query(params): Query<HashMap<String, String>>,
57) -> Json<ApiResponse<Vec<LogEntry>>> {
58    // Parse query parameters for filtering
59    let limit = params.get("limit").and_then(|s| s.parse::<usize>().ok()).unwrap_or(100);
60
61    let method_filter = params.get("method").map(|s| s.to_string());
62    let path_filter = params.get("path").map(|s| s.to_string());
63    let status_filter = params.get("status").and_then(|s| s.parse::<u16>().ok());
64
65    // Get recent logs from the centralized logger
66    let request_logs = if let Some(global_logger) = mockforge_core::get_global_logger() {
67        global_logger.get_recent_logs(Some(limit * 2)).await
68    } else {
69        Vec::new()
70    };
71
72    // Convert RequestLogEntry to LogEntry and apply filters
73    let mut log_entries: Vec<LogEntry> = request_logs
74        .into_iter()
75        .filter(|log| {
76            // Only include HTTP logs for now (matching the UI interface)
77            log.server_type == "HTTP"
78        })
79        .filter(|log| {
80            // Apply method filter
81            method_filter.as_ref().is_none_or(|filter| log.method == *filter)
82        })
83        .filter(|log| {
84            // Apply path filter (simple substring match)
85            path_filter.as_ref().is_none_or(|filter| log.path.contains(filter))
86        })
87        .filter(|log| {
88            // Apply status filter
89            status_filter.is_none_or(|filter| log.status_code == filter)
90        })
91        .map(|log| LogEntry {
92            timestamp: log.timestamp,
93            status: log.status_code,
94            method: log.method,
95            url: log.path,
96            response_time: log.response_time_ms,
97            size: log.response_size_bytes,
98        })
99        .take(limit)
100        .collect();
101
102    // Sort by timestamp descending (most recent first)
103    log_entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
104
105    Json(ApiResponse::success(log_entries))
106}
107
108/// Get metrics data
109pub async fn get_metrics(State(state): State<AdminState>) -> Json<ApiResponse<SimpleMetricsData>> {
110    let metrics = state.metrics.read().await;
111    let error_rate = 0.0; // Note: total_errors field doesn't exist in this RequestMetrics, setting to 0.0
112                          // Note: Some fields from the original RequestMetrics aren't available, using defaults
113    Json(ApiResponse::success(SimpleMetricsData {
114        total_requests: metrics.total_requests,
115        active_requests: metrics.active_connections, // Using active_connections as proxy
116        average_response_time: 0.0, // This field doesn't exist in this RequestMetrics
117        error_rate,
118    }))
119}
120
121/// Update latency configuration
122pub async fn update_latency(
123    State(state): State<super::AdminState>,
124    Json(config): Json<Value>,
125) -> Json<ApiResponse<String>> {
126    // Extract latency configuration from the JSON
127    let base_ms = config.get("base_ms").and_then(|v| v.as_u64()).unwrap_or(50);
128    let jitter_ms = config.get("jitter_ms").and_then(|v| v.as_u64()).unwrap_or(20);
129    let tag_overrides = config
130        .get("tag_overrides")
131        .and_then(|v| v.as_object())
132        .map(|obj| obj.iter().filter_map(|(k, v)| v.as_u64().map(|val| (k.clone(), val))).collect())
133        .unwrap_or_default();
134
135    // Update the configuration
136    state.update_latency_config(base_ms, jitter_ms, tag_overrides).await;
137
138    tracing::info!("Updated latency profile: base_ms={}, jitter_ms={}", base_ms, jitter_ms);
139    Json(ApiResponse::success("Latency configuration updated".to_string()))
140}
141
142/// Update fault injection configuration
143pub async fn update_faults(
144    State(state): State<super::AdminState>,
145    Json(config): Json<Value>,
146) -> Json<ApiResponse<String>> {
147    // Extract fault configuration from the JSON
148    let enabled = config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
149    let failure_rate = config.get("failure_rate").and_then(|v| v.as_f64()).unwrap_or(0.0);
150    let status_codes = config
151        .get("status_codes")
152        .and_then(|v| v.as_array())
153        .map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as u16)).collect())
154        .unwrap_or_default();
155
156    // Update the configuration
157    state.update_fault_config(enabled, failure_rate, status_codes).await;
158
159    tracing::info!("Updated fault config: enabled={}, failure_rate={}", enabled, failure_rate);
160    Json(ApiResponse::success("Fault configuration updated".to_string()))
161}
162
163/// Update proxy configuration
164pub async fn update_proxy(
165    State(state): State<super::AdminState>,
166    Json(config): Json<Value>,
167) -> Json<ApiResponse<String>> {
168    // Extract proxy configuration from the JSON
169    let enabled = config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
170    let upstream_url = config.get("upstream_url").and_then(|v| v.as_str()).map(|s| s.to_string());
171    let timeout_seconds = config.get("timeout_seconds").and_then(|v| v.as_u64()).unwrap_or(30);
172
173    // Update the configuration
174    state.update_proxy_config(enabled, upstream_url.clone(), timeout_seconds).await;
175
176    tracing::info!(
177        "Updated proxy config: enabled={}, upstream_url={:?}, timeout_seconds={}",
178        enabled,
179        upstream_url,
180        timeout_seconds
181    );
182    Json(ApiResponse::success("Proxy configuration updated".to_string()))
183}
184
185/// Clear logs
186pub async fn clear_logs(State(_state): State<AdminState>) -> Json<ApiResponse<String>> {
187    if let Some(global_logger) = mockforge_core::get_global_logger() {
188        global_logger.clear_logs().await;
189    }
190    tracing::info!("Request logs cleared via admin UI");
191    Json(ApiResponse::success("Logs cleared".to_string()))
192}
193
194/// Restart servers
195pub async fn restart_servers(State(state): State<super::AdminState>) -> Json<ApiResponse<String>> {
196    // Check if restart is already in progress
197    let current_status = state.get_restart_status().await;
198    if current_status.in_progress {
199        return Json(ApiResponse::error("Server restart already in progress".to_string()));
200    }
201
202    // Initiate restart status
203    if let Err(e) = state
204        .initiate_restart("Manual restart requested via admin UI".to_string())
205        .await
206    {
207        return Json(ApiResponse::error(format!("Failed to initiate restart: {}", e)));
208    }
209
210    // Spawn restart task to avoid blocking the response
211    let state_clone = state.clone();
212    tokio::spawn(async move {
213        if let Err(e) = super::perform_server_restart(&state_clone).await {
214            tracing::error!("Server restart failed: {}", e);
215            state_clone.complete_restart(false).await;
216        } else {
217            tracing::info!("Server restart completed successfully");
218            state_clone.complete_restart(true).await;
219        }
220    });
221
222    tracing::info!("Server restart initiated via admin UI");
223    Json(ApiResponse::success(
224        "Server restart initiated. Please wait for completion.".to_string(),
225    ))
226}
227
228/// Get restart status
229pub async fn get_restart_status(
230    State(state): State<super::AdminState>,
231) -> Json<ApiResponse<super::RestartStatus>> {
232    let status = state.get_restart_status().await;
233    Json(ApiResponse::success(status))
234}
235
236/// Get configuration
237pub async fn get_config(State(state): State<super::AdminState>) -> Json<ApiResponse<Value>> {
238    let config = state.get_config().await;
239    Json(ApiResponse::success(serde_json::to_value(config).unwrap_or_else(|_| json!({}))))
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    fn create_test_state() -> super::AdminState {
247        super::AdminState::new(None, None, None, None, false, 8080, None, None, None, None, None)
248    }
249
250    // ==================== RequestMetrics Tests ====================
251
252    #[test]
253    fn test_request_metrics_default() {
254        let metrics = RequestMetrics::default();
255        assert_eq!(metrics.total_requests, 0);
256        assert_eq!(metrics.active_requests, 0);
257        assert_eq!(metrics.average_response_time, 0.0);
258        assert_eq!(metrics.requests_per_second, 0.0);
259        assert_eq!(metrics.total_errors, 0);
260    }
261
262    #[test]
263    fn test_request_metrics_creation() {
264        let metrics = RequestMetrics {
265            total_requests: 1000,
266            active_requests: 10,
267            average_response_time: 45.5,
268            requests_per_second: 25.0,
269            total_errors: 5,
270        };
271
272        assert_eq!(metrics.total_requests, 1000);
273        assert_eq!(metrics.active_requests, 10);
274        assert!((metrics.average_response_time - 45.5).abs() < 0.001);
275        assert!((metrics.requests_per_second - 25.0).abs() < 0.001);
276        assert_eq!(metrics.total_errors, 5);
277    }
278
279    #[test]
280    fn test_request_metrics_clone() {
281        let metrics = RequestMetrics {
282            total_requests: 500,
283            active_requests: 5,
284            average_response_time: 30.0,
285            requests_per_second: 10.0,
286            total_errors: 2,
287        };
288
289        let cloned = metrics.clone();
290        assert_eq!(cloned.total_requests, 500);
291        assert_eq!(cloned.active_requests, 5);
292    }
293
294    #[test]
295    fn test_request_metrics_debug() {
296        let metrics = RequestMetrics::default();
297        let debug_str = format!("{:?}", metrics);
298        assert!(debug_str.contains("RequestMetrics"));
299        assert!(debug_str.contains("total_requests"));
300    }
301
302    // ==================== Handler Tests ====================
303
304    #[tokio::test]
305    async fn test_get_restart_status() {
306        let state = create_test_state();
307        let response = get_restart_status(axum::extract::State(state)).await;
308
309        assert!(response.0.success);
310    }
311
312    #[tokio::test]
313    async fn test_get_config() {
314        let state = create_test_state();
315        let response = get_config(axum::extract::State(state)).await;
316
317        assert!(response.0.success);
318    }
319
320    #[tokio::test]
321    async fn test_get_health() {
322        let response = get_health().await;
323
324        assert_eq!(response.0.status, "healthy");
325        assert!(response.0.issues.is_empty());
326    }
327
328    #[tokio::test]
329    async fn test_get_server_info() {
330        let state = create_test_state();
331        let response = get_server_info(axum::extract::State(state)).await;
332
333        assert!(response.0.is_object());
334        let obj = response.0.as_object().unwrap();
335        assert!(obj.contains_key("http_server"));
336        assert!(obj.contains_key("ws_server"));
337        assert!(obj.contains_key("grpc_server"));
338        assert!(obj.contains_key("graphql_server"));
339        assert!(obj.contains_key("api_enabled"));
340    }
341
342    #[tokio::test]
343    async fn test_get_server_info_disabled() {
344        let state = create_test_state();
345        let response = get_server_info(axum::extract::State(state)).await;
346
347        // With None addresses, should return "disabled"
348        let obj = response.0.as_object().unwrap();
349        assert_eq!(obj.get("http_server").and_then(|v| v.as_str()), Some("disabled"));
350        assert_eq!(obj.get("ws_server").and_then(|v| v.as_str()), Some("disabled"));
351    }
352
353    #[tokio::test]
354    async fn test_get_metrics() {
355        let state = create_test_state();
356        let response = get_metrics(axum::extract::State(state)).await;
357
358        assert!(response.0.success);
359    }
360
361    #[tokio::test]
362    async fn test_get_logs_empty() {
363        let state = create_test_state();
364        let params = HashMap::new();
365        let response = get_logs(axum::extract::State(state), axum::extract::Query(params)).await;
366
367        assert!(response.0.success);
368    }
369
370    #[tokio::test]
371    async fn test_get_logs_with_limit() {
372        let state = create_test_state();
373        let mut params = HashMap::new();
374        params.insert("limit".to_string(), "10".to_string());
375
376        let response = get_logs(axum::extract::State(state), axum::extract::Query(params)).await;
377
378        assert!(response.0.success);
379    }
380
381    #[tokio::test]
382    async fn test_get_logs_with_method_filter() {
383        let state = create_test_state();
384        let mut params = HashMap::new();
385        params.insert("method".to_string(), "GET".to_string());
386
387        let response = get_logs(axum::extract::State(state), axum::extract::Query(params)).await;
388
389        assert!(response.0.success);
390    }
391
392    #[tokio::test]
393    async fn test_get_logs_with_path_filter() {
394        let state = create_test_state();
395        let mut params = HashMap::new();
396        params.insert("path".to_string(), "/api".to_string());
397
398        let response = get_logs(axum::extract::State(state), axum::extract::Query(params)).await;
399
400        assert!(response.0.success);
401    }
402
403    #[tokio::test]
404    async fn test_get_logs_with_status_filter() {
405        let state = create_test_state();
406        let mut params = HashMap::new();
407        params.insert("status".to_string(), "200".to_string());
408
409        let response = get_logs(axum::extract::State(state), axum::extract::Query(params)).await;
410
411        assert!(response.0.success);
412    }
413
414    #[tokio::test]
415    async fn test_clear_logs() {
416        let state = create_test_state();
417        let response = clear_logs(axum::extract::State(state)).await;
418
419        assert!(response.0.success);
420        assert!(response.0.data.is_some());
421    }
422
423    #[tokio::test]
424    async fn test_update_latency() {
425        let state = create_test_state();
426        let config = json!({
427            "base_ms": 100,
428            "jitter_ms": 20
429        });
430
431        let response =
432            update_latency(axum::extract::State(state), axum::extract::Json(config)).await;
433
434        assert!(response.0.success);
435    }
436
437    #[tokio::test]
438    async fn test_update_latency_with_overrides() {
439        let state = create_test_state();
440        let config = json!({
441            "base_ms": 50,
442            "jitter_ms": 10,
443            "tag_overrides": {
444                "slow": 500,
445                "fast": 10
446            }
447        });
448
449        let response =
450            update_latency(axum::extract::State(state), axum::extract::Json(config)).await;
451
452        assert!(response.0.success);
453    }
454
455    #[tokio::test]
456    async fn test_update_faults() {
457        let state = create_test_state();
458        let config = json!({
459            "enabled": true,
460            "failure_rate": 0.1,
461            "status_codes": [500, 503]
462        });
463
464        let response =
465            update_faults(axum::extract::State(state), axum::extract::Json(config)).await;
466
467        assert!(response.0.success);
468    }
469
470    #[tokio::test]
471    async fn test_update_faults_disabled() {
472        let state = create_test_state();
473        let config = json!({
474            "enabled": false
475        });
476
477        let response =
478            update_faults(axum::extract::State(state), axum::extract::Json(config)).await;
479
480        assert!(response.0.success);
481    }
482
483    #[tokio::test]
484    async fn test_update_proxy() {
485        let state = create_test_state();
486        let config = json!({
487            "enabled": true,
488            "upstream_url": "http://localhost:8000",
489            "timeout_seconds": 60
490        });
491
492        let response = update_proxy(axum::extract::State(state), axum::extract::Json(config)).await;
493
494        assert!(response.0.success);
495    }
496
497    #[tokio::test]
498    async fn test_update_proxy_disabled() {
499        let state = create_test_state();
500        let config = json!({
501            "enabled": false
502        });
503
504        let response = update_proxy(axum::extract::State(state), axum::extract::Json(config)).await;
505
506        assert!(response.0.success);
507    }
508
509    #[tokio::test]
510    async fn test_restart_servers() {
511        let state = create_test_state();
512        let response = restart_servers(axum::extract::State(state)).await;
513
514        // Should succeed to initiate (even if restart won't actually work without real servers)
515        assert!(response.0.success || response.0.error.is_some());
516    }
517}