Skip to main content

hammerwork_web/api/
system.rs

1//! System information and administration API endpoints.
2//!
3//! This module provides comprehensive system administration and information endpoints
4//! for monitoring the web dashboard service, runtime metrics, and maintenance operations.
5//!
6//! # API Endpoints
7//!
8//! - `GET /api/system/info` - Complete system information including build and runtime details
9//! - `GET /api/system/config` - Current server configuration
10//! - `GET /api/system/metrics` - Metrics and monitoring information
11//! - `POST /api/system/maintenance` - Perform maintenance operations
12//! - `GET /api/version` - Basic version information
13//!
14//! # Examples
15//!
16//! ## System Information
17//!
18//! ```rust
19//! use hammerwork_web::api::system::{SystemInfo, BuildInfo, RuntimeInfo, DatabaseInfo};
20//! use chrono::Utc;
21//!
22//! let build_info = BuildInfo {
23//!     version: "1.3.0".to_string(),
24//!     git_commit: Some("a1b2c3d".to_string()),
25//!     build_date: Some("2024-01-15".to_string()),
26//!     rust_version: "1.70.0".to_string(),
27//!     target_triple: "x86_64-unknown-linux-gnu".to_string(),
28//! };
29//!
30//! let runtime_info = RuntimeInfo {
31//!     process_id: 12345,
32//!     memory_usage_bytes: Some(134217728), // 128MB
33//!     cpu_usage_percent: Some(5.2),
34//!     thread_count: Some(8),
35//!     gc_collections: None, // Not applicable for Rust
36//! };
37//!
38//! let database_info = DatabaseInfo {
39//!     database_type: "PostgreSQL".to_string(),
40//!     connection_url: "***masked***".to_string(),
41//!     pool_size: 10,
42//!     active_connections: Some(3),
43//!     connection_health: true,
44//!     last_migration: Some("20240101_initial".to_string()),
45//! };
46//!
47//! let system_info = SystemInfo {
48//!     version: "1.3.0".to_string(),
49//!     build_info,
50//!     runtime_info,
51//!     database_info,
52//!     features: vec!["postgres".to_string(), "auth".to_string()],
53//!     uptime_seconds: 86400,
54//!     started_at: Utc::now(),
55//! };
56//!
57//! assert_eq!(system_info.version, "1.3.0");
58//! assert!(system_info.features.contains(&"postgres".to_string()));
59//! assert_eq!(system_info.runtime_info.process_id, 12345);
60//! ```
61//!
62//! ## Maintenance Operations
63//!
64//! ```rust
65//! use hammerwork_web::api::system::MaintenanceRequest;
66//! use serde_json::json;
67//!
68//! let cleanup_request = MaintenanceRequest {
69//!     operation: "cleanup".to_string(),
70//!     target: Some("old_jobs".to_string()),
71//!     dry_run: Some(true),
72//! };
73//!
74//! let vacuum_request = MaintenanceRequest {
75//!     operation: "vacuum".to_string(),
76//!     target: Some("hammerwork_jobs".to_string()),
77//!     dry_run: Some(false),
78//! };
79//!
80//! assert_eq!(cleanup_request.operation, "cleanup");
81//! assert_eq!(cleanup_request.dry_run, Some(true));
82//! assert_eq!(vacuum_request.operation, "vacuum");
83//! ```
84//!
85//! ## Server Configuration
86//!
87//! ```rust
88//! use hammerwork_web::api::system::ServerConfig;
89//!
90//! let config = ServerConfig {
91//!     bind_address: "0.0.0.0".to_string(),
92//!     port: 8080,
93//!     authentication_enabled: true,
94//!     cors_enabled: false,
95//!     websocket_max_connections: 100,
96//!     static_assets_path: "/var/www/dashboard".to_string(),
97//! };
98//!
99//! assert_eq!(config.bind_address, "0.0.0.0");
100//! assert_eq!(config.port, 8080);
101//! assert!(config.authentication_enabled);
102//! assert!(!config.cors_enabled);
103//! ```
104//!
105//! ## Metrics Information
106//!
107//! ```rust
108//! use hammerwork_web::api::system::MetricsInfo;
109//! use chrono::Utc;
110//!
111//! let metrics_info = MetricsInfo {
112//!     prometheus_enabled: true,
113//!     metrics_endpoint: "/metrics".to_string(),
114//!     custom_metrics_count: Some(15),
115//!     last_scrape: Some(Utc::now()),
116//! };
117//!
118//! assert!(metrics_info.prometheus_enabled);
119//! assert_eq!(metrics_info.metrics_endpoint, "/metrics");
120//! assert_eq!(metrics_info.custom_metrics_count, Some(15));
121//! ```
122
123use super::history::{FinishedKind, JobHistory, TableMaintenance, maintenance_tables};
124use super::{ApiResponse, error_reply, json_reply, with_shared};
125use hammerwork::queue::DatabaseQueue;
126use serde::{Deserialize, Serialize};
127use std::sync::Arc;
128use tokio::sync::RwLock;
129use warp::http::StatusCode;
130use warp::{Filter, Reply};
131
132/// Shared system state for tracking runtime information
133#[derive(Clone)]
134pub struct SystemState {
135    /// Application start time
136    pub started_at: chrono::DateTime<chrono::Utc>,
137    /// Server configuration
138    pub config: crate::DashboardConfig,
139    /// Database type (detected at runtime)
140    pub database_type: String,
141    /// Pool size from actual connection pool
142    pub pool_size: u32,
143}
144
145impl SystemState {
146    pub fn new(config: crate::DashboardConfig, database_type: String, pool_size: u32) -> Self {
147        Self {
148            started_at: chrono::Utc::now(),
149            config,
150            database_type,
151            pool_size,
152        }
153    }
154
155    /// Seconds since the dashboard started; never negative, even if the clock went
156    /// backwards since.
157    pub fn uptime_seconds(&self) -> i64 {
158        (chrono::Utc::now() - self.started_at).num_seconds().max(0)
159    }
160}
161
162/// System information
163#[derive(Debug, Serialize)]
164pub struct SystemInfo {
165    pub version: String,
166    pub build_info: BuildInfo,
167    pub runtime_info: RuntimeInfo,
168    pub database_info: DatabaseInfo,
169    pub features: Vec<String>,
170    pub uptime_seconds: u64,
171    pub started_at: chrono::DateTime<chrono::Utc>,
172}
173
174/// Build information
175#[derive(Debug, Serialize)]
176pub struct BuildInfo {
177    pub version: String,
178    pub git_commit: Option<String>,
179    pub build_date: Option<String>,
180    pub rust_version: String,
181    pub target_triple: String,
182}
183
184/// Runtime information
185#[derive(Debug, Serialize)]
186pub struct RuntimeInfo {
187    pub process_id: u32,
188    pub memory_usage_bytes: Option<u64>,
189    pub cpu_usage_percent: Option<f64>,
190    pub thread_count: Option<usize>,
191    pub gc_collections: Option<u64>,
192}
193
194/// Database information
195#[derive(Debug, Serialize)]
196pub struct DatabaseInfo {
197    pub database_type: String,
198    pub connection_url: String, // Masked for security
199    pub pool_size: u32,
200    pub active_connections: Option<u32>,
201    pub connection_health: bool,
202    pub last_migration: Option<String>,
203}
204
205/// Server configuration
206#[derive(Debug, Serialize)]
207pub struct ServerConfig {
208    pub bind_address: String,
209    pub port: u16,
210    pub authentication_enabled: bool,
211    pub cors_enabled: bool,
212    pub websocket_max_connections: usize,
213    pub static_assets_path: String,
214}
215
216/// Metrics endpoint information
217#[derive(Debug, Serialize)]
218pub struct MetricsInfo {
219    pub prometheus_enabled: bool,
220    /// Deprecated and not served: the dashboard has no `/metrics` route (it always reports
221    /// `"/metrics"` for compatibility). Scrape the metrics of the application that embeds
222    /// the library instead.
223    pub metrics_endpoint: String,
224    /// `null`: the dashboard keeps no metrics registry, so there is nothing to count.
225    pub custom_metrics_count: Option<u32>,
226    pub last_scrape: Option<chrono::DateTime<chrono::Utc>>,
227}
228
229/// Configuration update request
230#[derive(Debug, Deserialize)]
231pub struct ConfigUpdateRequest {
232    pub setting: String,
233    pub value: serde_json::Value,
234}
235
236/// Maintenance operation request.
237///
238/// - `cleanup`: deletes dead jobs older than 7 days.
239/// - `vacuum`, `reindex`, `optimize`: table maintenance on the Hammerwork tables, see
240///   [`TableMaintenance`] for the statements each runs per backend. `target` may name one
241///   Hammerwork table (e.g. `hammerwork_jobs`); any other name is rejected. The response
242///   lists the statements that ran, or with `dry_run` the ones that would run.
243#[derive(Debug, Deserialize)]
244pub struct MaintenanceRequest {
245    /// `"cleanup"`, `"vacuum"`, `"reindex"` or `"optimize"`.
246    pub operation: String,
247    /// For table maintenance, a single Hammerwork table name; all of them when absent.
248    pub target: Option<String>,
249    pub dry_run: Option<bool>,
250}
251
252/// Create system routes
253pub fn routes<T>(
254    queue: Arc<T>,
255    system_state: Arc<RwLock<SystemState>>,
256) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
257where
258    T: JobHistory + 'static,
259{
260    let queue_filter = with_shared(queue);
261    let state_filter = with_shared(system_state);
262
263    let info = warp::path("system")
264        .and(warp::path("info"))
265        .and(warp::path::end())
266        .and(warp::get())
267        .and(queue_filter.clone())
268        .and(state_filter.clone())
269        .and_then(system_info_handler);
270
271    let config = warp::path("system")
272        .and(warp::path("config"))
273        .and(warp::path::end())
274        .and(warp::get())
275        .and(state_filter.clone())
276        .and_then(system_config_handler);
277
278    let metrics_info = warp::path("system")
279        .and(warp::path("metrics"))
280        .and(warp::path::end())
281        .and(warp::get())
282        .and(state_filter.clone())
283        .and_then(metrics_info_handler);
284
285    let maintenance = warp::path("system")
286        .and(warp::path("maintenance"))
287        .and(warp::path::end())
288        .and(warp::post())
289        .and(queue_filter.clone())
290        .and(crate::security::json_body())
291        .and_then(maintenance_handler);
292
293    let version = warp::path("version")
294        .and(warp::path::end())
295        .and(warp::get())
296        .and_then(version_handler);
297
298    info.or(config).or(metrics_info).or(maintenance).or(version)
299}
300
301/// Handler for system information
302async fn system_info_handler<T>(
303    queue: Arc<T>,
304    system_state: Arc<RwLock<SystemState>>,
305) -> Result<impl Reply, warp::Rejection>
306where
307    T: DatabaseQueue + Send + Sync,
308{
309    // Test database connection
310    let database_healthy = queue.get_all_queue_stats().await.is_ok();
311
312    let build_info = BuildInfo {
313        version: env!("CARGO_PKG_VERSION").to_string(),
314        git_commit: option_env!("GIT_COMMIT").map(|s| s.to_string()),
315        build_date: option_env!("BUILD_DATE").map(|s| s.to_string()),
316        rust_version: get_rust_version(),
317        target_triple: get_target_triple(),
318    };
319
320    let runtime_info = RuntimeInfo {
321        process_id: std::process::id(),
322        memory_usage_bytes: get_memory_usage(),
323        cpu_usage_percent: None, // unavailable
324        thread_count: None,      // Would need thread monitoring
325        gc_collections: None,    // Not applicable for Rust
326    };
327
328    let state = system_state.read().await;
329
330    let database_info = DatabaseInfo {
331        database_type: state.database_type.clone(),
332        connection_url: "***masked***".to_string(),
333        pool_size: state.pool_size,
334        active_connections: None, // Would need pool access
335        connection_health: database_healthy,
336        last_migration: None, // Would need migration table query
337    };
338
339    let features = vec![
340        #[cfg(feature = "postgres")]
341        "postgres".to_string(),
342        #[cfg(feature = "mysql")]
343        "mysql".to_string(),
344        #[cfg(feature = "auth")]
345        "auth".to_string(),
346    ];
347
348    let system_info = SystemInfo {
349        version: env!("CARGO_PKG_VERSION").to_string(),
350        build_info,
351        runtime_info,
352        database_info,
353        features,
354        uptime_seconds: u64::try_from(state.uptime_seconds()).unwrap_or(0),
355        started_at: state.started_at,
356    };
357
358    Ok(json_reply(&ApiResponse::success(system_info)))
359}
360
361/// Handler for system configuration
362async fn system_config_handler(
363    system_state: Arc<RwLock<SystemState>>,
364) -> Result<impl Reply, warp::Rejection> {
365    let state = system_state.read().await;
366    let config = ServerConfig {
367        bind_address: state.config.bind_address.clone(),
368        port: state.config.port,
369        authentication_enabled: state.config.auth.enabled,
370        cors_enabled: state.config.enable_cors,
371        websocket_max_connections: state.config.websocket.max_connections,
372        static_assets_path: state.config.static_dir.to_string_lossy().to_string(),
373    };
374
375    Ok(json_reply(&ApiResponse::success(config)))
376}
377
378/// Handler for metrics information
379async fn metrics_info_handler(
380    _system_state: Arc<RwLock<SystemState>>,
381) -> Result<impl Reply, warp::Rejection> {
382    let metrics_info = MetricsInfo {
383        // hammerwork-web does not serve a Prometheus endpoint itself (there is no
384        // `metrics` feature on this crate), so this is always false.
385        prometheus_enabled: false,
386        metrics_endpoint: "/metrics".to_string(),
387        custom_metrics_count: None,
388        last_scrape: None,
389    };
390
391    Ok(json_reply(&ApiResponse::success(metrics_info)))
392}
393
394/// Handler for maintenance operations
395async fn maintenance_handler<T>(
396    queue: Arc<T>,
397    request: MaintenanceRequest,
398) -> Result<impl Reply, warp::Rejection>
399where
400    T: JobHistory,
401{
402    let dry_run = request.dry_run.unwrap_or(false);
403
404    match request.operation.as_str() {
405        "cleanup" => {
406            let older_than = chrono::Utc::now() - chrono::Duration::days(7); // Remove jobs older than 7 days
407            if dry_run {
408                match queue
409                    .count_jobs(None, FinishedKind::Dead, Some(older_than))
410                    .await
411                {
412                    Ok(count) => {
413                        let response = ApiResponse::success(serde_json::json!({
414                            "operation": "cleanup",
415                            "dry_run": true,
416                            "message": format!("Dry run: would clean up {} dead jobs older than 7 days", count),
417                            "estimated_deletions": count
418                        }));
419                        Ok(json_reply(&response))
420                    }
421                    Err(e) => Ok(error_reply(
422                        StatusCode::INTERNAL_SERVER_ERROR,
423                        format!("Cleanup dry run failed: {}", e),
424                    )),
425                }
426            } else {
427                // The same predicate the dry run counts with (`FinishedKind::Dead`), so the
428                // dry-run count is what is deleted, including dead rows without `failed_at`.
429                match queue
430                    .delete_jobs(None, FinishedKind::Dead, Some(older_than))
431                    .await
432                {
433                    Ok(count) => {
434                        let response = ApiResponse::success(serde_json::json!({
435                            "operation": "cleanup",
436                            "dry_run": false,
437                            "message": format!("Cleaned up {} dead jobs", count),
438                            "deletions": count
439                        }));
440                        Ok(json_reply(&response))
441                    }
442                    Err(e) => Ok(error_reply(
443                        StatusCode::INTERNAL_SERVER_ERROR,
444                        format!("Cleanup failed: {}", e),
445                    )),
446                }
447            }
448        }
449        name => {
450            let Some(operation) = TableMaintenance::parse(name) else {
451                return Ok(error_reply(
452                    StatusCode::BAD_REQUEST,
453                    format!("Unknown maintenance operation: {}", request.operation),
454                ));
455            };
456            // `target` only selects among the fixed Hammerwork table names; it is never
457            // put into SQL.
458            let tables = match maintenance_tables(request.target.as_deref()) {
459                Ok(tables) => tables,
460                Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
461            };
462            match queue.table_maintenance(operation, &tables, dry_run).await {
463                Ok(statements) => {
464                    let message = if dry_run {
465                        format!("Dry run: would run {} statements", statements.len())
466                    } else {
467                        format!("Ran {} on {} tables", name, statements.len())
468                    };
469                    Ok(json_reply(&ApiResponse::success(serde_json::json!({
470                        "operation": operation,
471                        "dry_run": dry_run,
472                        "message": message,
473                        "statements": statements,
474                    }))))
475                }
476                Err(e) => Ok(error_reply(
477                    StatusCode::INTERNAL_SERVER_ERROR,
478                    format!("{} failed: {}", name, e),
479                )),
480            }
481        }
482    }
483}
484
485/// Handler for version information
486async fn version_handler() -> Result<impl Reply, warp::Rejection> {
487    let version_info = serde_json::json!({
488        "version": env!("CARGO_PKG_VERSION"),
489        "name": env!("CARGO_PKG_NAME"),
490        "description": env!("CARGO_PKG_DESCRIPTION"),
491        "authors": env!("CARGO_PKG_AUTHORS").split(':').collect::<Vec<_>>(),
492        "repository": env!("CARGO_PKG_REPOSITORY"),
493        "license": env!("CARGO_PKG_LICENSE"),
494        "rust_version": get_rust_version(),
495        "build_target": get_target_triple(),
496    });
497
498    Ok(json_reply(&ApiResponse::success(version_info)))
499}
500
501/// Get Rust compiler version
502fn get_rust_version() -> String {
503    option_env!("RUSTC_VERSION")
504        .unwrap_or("unknown")
505        .to_string()
506}
507
508/// Get target triple
509fn get_target_triple() -> String {
510    std::env::consts::ARCH.to_string() + "-" + std::env::consts::OS
511}
512
513/// Get memory usage (platform-specific)
514fn get_memory_usage() -> Option<u64> {
515    #[cfg(target_os = "linux")]
516    {
517        use std::fs;
518        if let Ok(contents) = fs::read_to_string("/proc/self/status") {
519            for line in contents.lines() {
520                if let Some(rest) = line.strip_prefix("VmRSS:")
521                    && let Some(kb) = rest
522                        .split_whitespace()
523                        .next()
524                        .and_then(|s| s.parse::<u64>().ok())
525                {
526                    return Some(kb.saturating_mul(1024)); // Convert KB to bytes
527                }
528            }
529        }
530    }
531
532    // Other platforms: current resident memory has no portable std API (macOS and Windows
533    // need platform calls; `getrusage` only reports the peak), so report it as unavailable
534    // rather than invent a number.
535    None
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    /// #94: a clock that went backwards gives zero uptime, not a wrapped `u64`.
543    #[test]
544    fn uptime_is_never_negative() {
545        let mut state = SystemState::new(crate::DashboardConfig::default(), "pg".into(), 1);
546        state.started_at = chrono::Utc::now() + chrono::Duration::hours(1);
547        assert_eq!(state.uptime_seconds(), 0);
548        state.started_at = chrono::Utc::now() - chrono::Duration::seconds(90);
549        assert!(state.uptime_seconds() >= 90);
550    }
551
552    #[test]
553    fn test_maintenance_request_deserialization() {
554        let json = r#"{
555            "operation": "cleanup",
556            "target": "completed_jobs",
557            "dry_run": true
558        }"#;
559
560        let request: MaintenanceRequest = serde_json::from_str(json).unwrap();
561        assert_eq!(request.operation, "cleanup");
562        assert_eq!(request.target, Some("completed_jobs".to_string()));
563        assert_eq!(request.dry_run, Some(true));
564    }
565
566    #[test]
567    fn test_build_info_creation() {
568        let build_info = BuildInfo {
569            version: "1.0.0".to_string(),
570            git_commit: Some("abc123".to_string()),
571            build_date: Some("2024-01-01".to_string()),
572            rust_version: "1.70.0".to_string(),
573            target_triple: "x86_64-unknown-linux-gnu".to_string(),
574        };
575
576        let json = serde_json::to_string(&build_info).unwrap();
577        assert!(json.contains("1.0.0"));
578        assert!(json.contains("abc123"));
579    }
580
581    #[test]
582    fn test_unavailable_metrics_are_none_not_zero() {
583        let json = serde_json::to_value(MetricsInfo {
584            prometheus_enabled: false,
585            metrics_endpoint: "/metrics".to_string(),
586            custom_metrics_count: None,
587            last_scrape: None,
588        })
589        .unwrap();
590        assert!(json["custom_metrics_count"].is_null());
591        assert!(json["last_scrape"].is_null());
592    }
593
594    #[tokio::test]
595    async fn test_cleanup_dry_run_reports_database_failure() {
596        let response = maintenance_handler(
597            crate::api::test_support::unreachable_queue(),
598            MaintenanceRequest {
599                operation: "cleanup".to_string(),
600                target: None,
601                dry_run: Some(true),
602            },
603        )
604        .await
605        .unwrap()
606        .into_response();
607        let (status, _) = crate::api::test_support::body_json(response).await;
608        assert_eq!(status, 500);
609    }
610
611    async fn maintenance_status(operation: &str, target: Option<&str>) -> u16 {
612        let response = maintenance_handler(
613            crate::api::test_support::unreachable_queue(),
614            MaintenanceRequest {
615                operation: operation.to_string(),
616                target: target.map(str::to_string),
617                dry_run: Some(false),
618            },
619        )
620        .await
621        .unwrap()
622        .into_response();
623        crate::api::test_support::body_json(response).await.0
624    }
625
626    #[tokio::test]
627    async fn test_table_maintenance_rejects_foreign_tables_before_the_database() {
628        for operation in ["vacuum", "reindex", "optimize"] {
629            assert_eq!(maintenance_status(operation, Some("users")).await, 400);
630            // A Hammerwork table passes validation and then fails on the dead database.
631            assert_eq!(
632                maintenance_status(operation, Some("hammerwork_jobs")).await,
633                500
634            );
635        }
636        assert_eq!(maintenance_status("defrag", None).await, 400);
637    }
638
639    #[test]
640    fn test_get_rust_version() {
641        let version = get_rust_version();
642        assert!(!version.is_empty());
643    }
644
645    #[test]
646    fn test_get_target_triple() {
647        let target = get_target_triple();
648        assert!(!target.is_empty());
649        assert!(target.contains("-"));
650    }
651}