Skip to main content

hammerwork_web/api/
stats.rs

1//! Statistics and monitoring API endpoints.
2//!
3//! This module provides comprehensive monitoring and analytics endpoints for tracking
4//! system health, performance metrics, and operational insights across all job queues.
5//!
6//! # API Endpoints
7//!
8//! - `GET /api/stats/overview` - System overview with key metrics
9//! - `GET /api/stats/detailed` - Detailed statistics with historical trends
10//! - `GET /api/stats/trends` - Hourly/daily trend analysis
11//! - `GET /api/stats/health` - System health check and alerts
12//!
13//! # Examples
14//!
15//! ## System Overview
16//!
17//! ```rust
18//! use hammerwork_web::api::stats::{SystemOverview, SystemHealth, SystemAlert};
19//! use chrono::Utc;
20//!
21//! let overview = SystemOverview {
22//!     total_queues: 5,
23//!     total_jobs: 10000,
24//!     pending_jobs: 50,
25//!     running_jobs: 10,
26//!     completed_jobs: 9800,
27//!     failed_jobs: 125,
28//!     dead_jobs: 15,
29//!     overall_throughput: 150.5,
30//!     overall_error_rate: 0.0125,
31//!     avg_processing_time_ms: 250.0,
32//!     system_health: SystemHealth {
33//!         status: "healthy".to_string(),
34//!         database_healthy: true,
35//!         high_error_rate: false,
36//!         queue_backlog: false,
37//!         slow_processing: false,
38//!         alerts: vec![],
39//!     },
40//!     uptime_seconds: 86400,
41//!     last_updated: Utc::now(),
42//! };
43//!
44//! assert_eq!(overview.total_queues, 5);
45//! assert_eq!(overview.overall_error_rate, 0.0125);
46//! assert_eq!(overview.system_health.status, "healthy");
47//! ```
48//!
49//! ## Statistics Queries
50//!
51//! ```rust
52//! use hammerwork_web::api::stats::{StatsQuery, TimeRange};
53//! use chrono::{Utc, Duration};
54//!
55//! let time_range = TimeRange {
56//!     start: Utc::now() - Duration::hours(24),
57//!     end: Utc::now(),
58//! };
59//!
60//! let query = StatsQuery {
61//!     time_range: Some(time_range),
62//!     queues: Some(vec!["email".to_string(), "notifications".to_string()]),
63//!     granularity: Some("hour".to_string()),
64//!     hours: None,
65//! };
66//!
67//! assert!(query.time_range.is_some());
68//! assert_eq!(query.queues.as_ref().unwrap().len(), 2);
69//! assert_eq!(query.granularity, Some("hour".to_string()));
70//! ```
71//!
72//! ## System Alerts
73//!
74//! ```rust
75//! use hammerwork_web::api::stats::SystemAlert;
76//! use chrono::Utc;
77//!
78//! let alert = SystemAlert {
79//!     severity: "warning".to_string(),
80//!     message: "Queue backlog detected".to_string(),
81//!     queue: Some("image_processing".to_string()),
82//!     metric: Some("pending_count".to_string()),
83//!     value: Some(1500.0),
84//!     threshold: Some(1000.0),
85//!     timestamp: Utc::now(),
86//! };
87//!
88//! assert_eq!(alert.severity, "warning");
89//! assert_eq!(alert.queue, Some("image_processing".to_string()));
90//! assert_eq!(alert.value, Some(1500.0));
91//! ```
92//!
93//! ## Performance Metrics
94//!
95//! ```rust
96//! use hammerwork_web::api::stats::PerformanceMetrics;
97//!
98//! let metrics = PerformanceMetrics {
99//!     database_response_time_ms: Some(5.2),
100//!     average_queue_depth: 15.5,
101//!     jobs_per_second: 8.3,
102//!     memory_usage_mb: Some(512.0),
103//!     cpu_usage_percent: Some(45.2),
104//!     active_workers: None, // not tracked: there is no worker registry
105//!     worker_utilization: None,
106//! };
107//!
108//! assert_eq!(metrics.database_response_time_ms, Some(5.2));
109//! assert_eq!(metrics.active_workers, None);
110//! ```
111
112use super::history::{ErrorGroup, JobHistory, MAX_BUCKETS, hour_floor};
113use super::{ApiResponse, error_reply, json_reply, with_shared};
114use hammerwork::queue::DatabaseQueue;
115use serde::{Deserialize, Serialize};
116use std::collections::HashMap;
117use std::sync::Arc;
118use warp::http::StatusCode;
119use warp::{Filter, Reply};
120
121/// System overview statistics
122#[derive(Debug, Serialize)]
123pub struct SystemOverview {
124    pub total_queues: u32,
125    pub total_jobs: u64,
126    pub pending_jobs: u64,
127    pub running_jobs: u64,
128    pub completed_jobs: u64,
129    pub failed_jobs: u64,
130    pub dead_jobs: u64,
131    pub overall_throughput: f64,
132    pub overall_error_rate: f64,
133    pub avg_processing_time_ms: f64,
134    pub system_health: SystemHealth,
135    pub uptime_seconds: u64,
136    pub last_updated: chrono::DateTime<chrono::Utc>,
137}
138
139/// System health status
140#[derive(Debug, Serialize)]
141pub struct SystemHealth {
142    pub status: String, // "healthy", "degraded", "critical"
143    pub database_healthy: bool,
144    pub high_error_rate: bool,
145    pub queue_backlog: bool,
146    pub slow_processing: bool,
147    pub alerts: Vec<SystemAlert>,
148}
149
150/// System alert
151#[derive(Debug, Serialize)]
152pub struct SystemAlert {
153    pub severity: String, // "info", "warning", "error", "critical"
154    pub message: String,
155    pub queue: Option<String>,
156    pub metric: Option<String>,
157    pub value: Option<f64>,
158    pub threshold: Option<f64>,
159    pub timestamp: chrono::DateTime<chrono::Utc>,
160}
161
162/// Detailed statistics for monitoring
163#[derive(Debug, Serialize)]
164pub struct DetailedStats {
165    pub overview: SystemOverview,
166    pub queue_stats: Vec<QueueStats>,
167    pub hourly_trends: Vec<HourlyTrend>,
168    pub error_patterns: Vec<ErrorPattern>,
169    pub performance_metrics: PerformanceMetrics,
170}
171
172/// Queue statistics
173#[derive(Debug, Serialize)]
174pub struct QueueStats {
175    pub name: String,
176    pub pending: u64,
177    pub running: u64,
178    pub completed_total: u64,
179    pub failed_total: u64,
180    pub dead_total: u64,
181    pub throughput_per_minute: f64,
182    pub avg_processing_time_ms: f64,
183    pub error_rate: f64,
184    pub oldest_pending_age_seconds: Option<u64>,
185    pub priority_distribution: HashMap<String, f32>,
186}
187
188/// Hourly trend data
189#[derive(Debug, Serialize)]
190pub struct HourlyTrend {
191    pub hour: chrono::DateTime<chrono::Utc>,
192    pub completed: u64,
193    pub failed: u64,
194    pub throughput: f64,
195    /// Mean run time of jobs completed in the hour; `null` when none completed.
196    pub avg_processing_time_ms: Option<f64>,
197    pub error_rate: f64,
198}
199
200/// Error pattern analysis
201#[derive(Debug, Serialize)]
202pub struct ErrorPattern {
203    pub error_type: String,
204    pub count: u64,
205    pub percentage: f64,
206    pub sample_message: String,
207    pub first_seen: chrono::DateTime<chrono::Utc>,
208    pub last_seen: chrono::DateTime<chrono::Utc>,
209    pub affected_queues: Vec<String>,
210}
211
212/// Performance metrics
213#[derive(Debug, Serialize)]
214pub struct PerformanceMetrics {
215    /// Measured time to fetch the queue statistics for this response.
216    pub database_response_time_ms: Option<f64>,
217    pub average_queue_depth: f64,
218    pub jobs_per_second: f64,
219    pub memory_usage_mb: Option<f64>,
220    pub cpu_usage_percent: Option<f64>,
221    /// Always `null`: Hammerwork has no worker registry, so worker counts are unknown.
222    pub active_workers: Option<u32>,
223    /// Always `null`, see `active_workers`.
224    pub worker_utilization: Option<f64>,
225}
226
227/// Time range for statistics queries
228#[derive(Debug, Deserialize)]
229pub struct TimeRange {
230    pub start: chrono::DateTime<chrono::Utc>,
231    pub end: chrono::DateTime<chrono::Utc>,
232}
233
234/// Statistics query parameters
235#[derive(Debug, Deserialize)]
236pub struct StatsQuery {
237    pub time_range: Option<TimeRange>,
238    pub queues: Option<Vec<String>>,
239    pub granularity: Option<String>, // "hour", "day", "week"
240    /// The last N hours (alternative to `time_range`, which a query string cannot carry).
241    pub hours: Option<u32>,
242}
243
244/// Create statistics routes
245pub fn routes<T>(
246    queue: Arc<T>,
247    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
248) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
249where
250    T: JobHistory + 'static,
251{
252    let queue_filter = with_shared(queue);
253    let state_filter = with_shared(system_state);
254
255    let overview = warp::path("stats")
256        .and(warp::path("overview"))
257        .and(warp::path::end())
258        .and(warp::get())
259        .and(queue_filter.clone())
260        .and(state_filter.clone())
261        .and_then(overview_handler);
262
263    let detailed = warp::path("stats")
264        .and(warp::path("detailed"))
265        .and(warp::path::end())
266        .and(warp::get())
267        .and(queue_filter.clone())
268        .and(state_filter.clone())
269        .and(warp::query::<StatsQuery>())
270        .and_then(detailed_stats_handler);
271
272    let trends = warp::path("stats")
273        .and(warp::path("trends"))
274        .and(warp::path::end())
275        .and(warp::get())
276        .and(queue_filter.clone())
277        .and(warp::query::<StatsQuery>())
278        .and_then(trends_handler);
279
280    let health = warp::path("stats")
281        .and(warp::path("health"))
282        .and(warp::path::end())
283        .and(warp::get())
284        .and(queue_filter)
285        .and_then(health_handler);
286
287    overview.or(detailed).or(trends).or(health)
288}
289
290/// Handler for system overview statistics
291async fn overview_handler<T>(
292    queue: Arc<T>,
293    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
294) -> Result<impl Reply, warp::Rejection>
295where
296    T: DatabaseQueue + Send + Sync,
297{
298    match queue.get_all_queue_stats().await {
299        Ok(all_stats) => {
300            let uptime_seconds =
301                u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
302            let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
303            Ok(json_reply(&ApiResponse::success(overview)))
304        }
305        Err(e) => Ok(error_reply(
306            StatusCode::INTERNAL_SERVER_ERROR,
307            format!("Failed to get statistics: {}", e),
308        )),
309    }
310}
311
312/// Handler for detailed statistics
313async fn detailed_stats_handler<T>(
314    queue: Arc<T>,
315    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
316    query: StatsQuery,
317) -> Result<impl Reply, warp::Rejection>
318where
319    T: JobHistory,
320{
321    let now = chrono::Utc::now();
322    let (start, end) = match resolve_range(&query, now) {
323        Ok(range) => range,
324        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
325    };
326
327    let db_started = std::time::Instant::now();
328    match queue.get_all_queue_stats().await {
329        Ok(all_stats) => {
330            let db_ms = db_started.elapsed().as_secs_f64() * 1000.0;
331            // Convert hammerwork stats to our API format
332            let mut queue_stats: Vec<QueueStats> = Vec::new();
333            for stats in all_stats.iter() {
334                // Calculate oldest pending age seconds
335                let oldest_pending_age_seconds = try_api!(
336                    calculate_oldest_pending_age(&queue, &stats.queue_name).await,
337                    "Failed to get oldest pending job age"
338                );
339
340                // Get priority distribution from priority stats
341                let priority_distribution = try_api!(
342                    get_priority_distribution(&queue, &stats.queue_name).await,
343                    "Failed to get priority distribution"
344                );
345
346                queue_stats.push(QueueStats {
347                    name: stats.queue_name.clone(),
348                    pending: stats.pending_count,
349                    running: stats.running_count,
350                    completed_total: stats.completed_count,
351                    failed_total: super::failed_job_count(stats),
352                    dead_total: stats.dead_count,
353                    throughput_per_minute: stats.statistics.throughput_per_minute,
354                    avg_processing_time_ms: stats.statistics.avg_processing_time_ms,
355                    error_rate: stats.statistics.error_rate,
356                    oldest_pending_age_seconds,
357                    priority_distribution,
358                });
359            }
360
361            let hourly_trends = try_api!(
362                compute_trends(&*queue, start, end).await,
363                "Failed to compute hourly trends"
364            );
365            let error_patterns = try_api!(
366                generate_error_patterns(&*queue, start).await,
367                "Failed to compute error patterns"
368            );
369            let performance_metrics = calculate_performance_metrics(&all_stats, db_ms);
370
371            let uptime_seconds =
372                u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
373            let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
374
375            let detailed = DetailedStats {
376                overview,
377                queue_stats,
378                hourly_trends,
379                error_patterns,
380                performance_metrics,
381            };
382
383            Ok(json_reply(&ApiResponse::success(detailed)))
384        }
385        Err(e) => Ok(error_reply(
386            StatusCode::INTERNAL_SERVER_ERROR,
387            format!("Failed to get detailed statistics: {}", e),
388        )),
389    }
390}
391
392/// The `[start, end)` window of a stats request: `time_range` when given (validated),
393/// otherwise the last 24 hours (23 whole hours plus the current one).
394fn resolve_range(
395    query: &StatsQuery,
396    now: chrono::DateTime<chrono::Utc>,
397) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
398    if let Some(hours) = query.hours {
399        if hours == 0 || i64::from(hours) > MAX_BUCKETS {
400            return Err(format!("hours must be between 1 and {}", MAX_BUCKETS));
401        }
402        return Ok((
403            hour_floor(now) - chrono::Duration::hours(i64::from(hours) - 1),
404            now,
405        ));
406    }
407    match &query.time_range {
408        Some(range) => {
409            if range.start >= range.end {
410                Err("time_range.start must be before time_range.end".to_string())
411            } else if range.end - range.start > chrono::Duration::hours(MAX_BUCKETS) {
412                Err(format!("time_range may span at most {} hours", MAX_BUCKETS))
413            } else {
414                Ok((range.start, range.end))
415            }
416        }
417        None => Ok((hour_floor(now) - chrono::Duration::hours(23), now)),
418    }
419}
420
421/// Handler for trend analysis: hourly completed/failed counts from the database.
422async fn trends_handler<T>(queue: Arc<T>, query: StatsQuery) -> Result<impl Reply, warp::Rejection>
423where
424    T: JobHistory,
425{
426    let (start, end) = match resolve_range(&query, chrono::Utc::now()) {
427        Ok(range) => range,
428        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
429    };
430    let trends = try_api!(
431        compute_trends(&*queue, start, end).await,
432        "Failed to compute hourly trends"
433    );
434    Ok(json_reply(&ApiResponse::success(trends)))
435}
436
437/// Handler for system health check
438async fn health_handler<T>(queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
439where
440    T: DatabaseQueue + Send + Sync,
441{
442    match queue.get_all_queue_stats().await {
443        Ok(all_stats) => {
444            let health = assess_system_health(&all_stats);
445            Ok(json_reply(&ApiResponse::success(health)))
446        }
447        Err(e) => {
448            let health = SystemHealth {
449                status: "critical".to_string(),
450                database_healthy: false,
451                high_error_rate: false,
452                queue_backlog: false,
453                slow_processing: false,
454                alerts: vec![SystemAlert {
455                    severity: "critical".to_string(),
456                    message: format!("Database connection failed: {}", e),
457                    queue: None,
458                    metric: Some("database_connectivity".to_string()),
459                    value: None,
460                    threshold: None,
461                    timestamp: chrono::Utc::now(),
462                }],
463            };
464            Ok(json_reply(&ApiResponse::success(health)))
465        }
466    }
467}
468
469/// Assess overall system health based on queue statistics
470fn assess_system_health(stats: &[hammerwork::stats::QueueStats]) -> SystemHealth {
471    let mut alerts = Vec::new();
472    let mut high_error_rate = false;
473    let mut queue_backlog = false;
474    let mut slow_processing = false;
475
476    for stat in stats {
477        // Check error rate
478        if stat.statistics.error_rate > 0.1 {
479            // > 10% error rate
480            high_error_rate = true;
481            alerts.push(SystemAlert {
482                severity: "warning".to_string(),
483                message: format!("High error rate in queue '{}'", stat.queue_name),
484                queue: Some(stat.queue_name.clone()),
485                metric: Some("error_rate".to_string()),
486                value: Some(stat.statistics.error_rate),
487                threshold: Some(0.1),
488                timestamp: chrono::Utc::now(),
489            });
490        }
491
492        // Check queue backlog
493        if stat.pending_count > 1000 {
494            queue_backlog = true;
495            alerts.push(SystemAlert {
496                severity: "warning".to_string(),
497                message: format!("Large backlog in queue '{}'", stat.queue_name),
498                queue: Some(stat.queue_name.clone()),
499                metric: Some("pending_count".to_string()),
500                value: Some(stat.pending_count as f64),
501                threshold: Some(1000.0),
502                timestamp: chrono::Utc::now(),
503            });
504        }
505
506        // Check processing time
507        if stat.statistics.avg_processing_time_ms > 30000.0 {
508            // > 30 seconds
509            slow_processing = true;
510            alerts.push(SystemAlert {
511                severity: "info".to_string(),
512                message: format!("Slow processing in queue '{}'", stat.queue_name),
513                queue: Some(stat.queue_name.clone()),
514                metric: Some("avg_processing_time_ms".to_string()),
515                value: Some(stat.statistics.avg_processing_time_ms),
516                threshold: Some(30000.0),
517                timestamp: chrono::Utc::now(),
518            });
519        }
520    }
521
522    let status = if alerts.iter().any(|a| a.severity == "critical") {
523        "critical"
524    } else if alerts.iter().any(|a| a.severity == "warning") {
525        "degraded"
526    } else {
527        "healthy"
528    };
529
530    SystemHealth {
531        status: status.to_string(),
532        database_healthy: true, // If we got here, DB is accessible
533        high_error_rate,
534        queue_backlog,
535        slow_processing,
536        alerts,
537    }
538}
539
540/// Generate system overview from queue statistics
541fn generate_overview_from_stats(
542    stats: &[hammerwork::stats::QueueStats],
543    uptime_seconds: u64,
544) -> SystemOverview {
545    let mut total_pending = 0;
546    let mut total_running = 0;
547    let mut total_completed = 0;
548    let mut total_failed = 0;
549    let mut total_dead = 0;
550    let mut total_throughput = 0.0;
551    let mut total_processing_time = 0.0;
552    let queue_count = stats.len();
553
554    for stat in stats {
555        total_pending += stat.pending_count;
556        total_running += stat.running_count;
557        total_completed += stat.completed_count;
558        total_failed += super::failed_job_count(stat);
559        total_dead += stat.dead_count;
560        total_throughput += stat.statistics.throughput_per_minute;
561        total_processing_time += stat.statistics.avg_processing_time_ms;
562    }
563
564    let avg_processing_time = if queue_count > 0 {
565        total_processing_time / queue_count as f64
566    } else {
567        0.0
568    };
569
570    let total_jobs = total_pending + total_running + total_completed + total_failed;
571    let overall_error_rate = if total_jobs > 0 {
572        total_failed as f64 / total_jobs as f64
573    } else {
574        0.0
575    };
576
577    let health = assess_system_health(stats);
578
579    SystemOverview {
580        total_queues: queue_count as u32,
581        total_jobs,
582        pending_jobs: total_pending,
583        running_jobs: total_running,
584        completed_jobs: total_completed,
585        failed_jobs: total_failed,
586        dead_jobs: total_dead,
587        overall_throughput: total_throughput,
588        overall_error_rate,
589        avg_processing_time_ms: avg_processing_time,
590        system_health: health,
591        uptime_seconds,
592        last_updated: chrono::Utc::now(),
593    }
594}
595
596/// Calculate the oldest pending job age in seconds for a queue
597async fn calculate_oldest_pending_age<T>(
598    queue: &Arc<T>,
599    queue_name: &str,
600) -> hammerwork::Result<Option<u64>>
601where
602    T: DatabaseQueue + Send + Sync,
603{
604    // Get ready jobs (pending jobs) and find the oldest
605    let jobs = queue.get_ready_jobs(queue_name, 100).await?;
606    let now = chrono::Utc::now();
607    Ok(jobs
608        .iter()
609        .filter(|job| matches!(job.status, hammerwork::job::JobStatus::Pending))
610        .map(|job| age_seconds(now, job.created_at))
611        .max())
612}
613
614/// Age in whole seconds between `created_at` and `now`, clamped to zero when
615/// `created_at` is in the future (clock skew) instead of wrapping to ~1.8e19.
616fn age_seconds(
617    now: chrono::DateTime<chrono::Utc>,
618    created_at: chrono::DateTime<chrono::Utc>,
619) -> u64 {
620    u64::try_from((now - created_at).num_seconds()).unwrap_or(0)
621}
622
623/// Get priority distribution from priority stats for a queue
624async fn get_priority_distribution<T>(
625    queue: &Arc<T>,
626    queue_name: &str,
627) -> hammerwork::Result<HashMap<String, f32>>
628where
629    T: DatabaseQueue + Send + Sync,
630{
631    let priority_stats = queue.get_priority_stats(queue_name).await?;
632    Ok(priority_stats
633        .priority_distribution
634        .into_iter()
635        .map(|(priority, percentage)| (priority.to_string(), percentage))
636        .collect())
637}
638
639/// Hourly trends for `[start, end)` computed from the database (zero-filled).
640async fn compute_trends<T>(
641    queue: &T,
642    start: chrono::DateTime<chrono::Utc>,
643    end: chrono::DateTime<chrono::Utc>,
644) -> hammerwork::Result<Vec<HourlyTrend>>
645where
646    T: JobHistory,
647{
648    let buckets = queue.hourly_activity(None, start, end).await?;
649    Ok(buckets.into_iter().map(trend_from_bucket).collect())
650}
651
652fn trend_from_bucket(bucket: super::history::HourBucket) -> HourlyTrend {
653    let total = bucket.completed + bucket.failed;
654    HourlyTrend {
655        hour: bucket.hour,
656        completed: bucket.completed,
657        failed: bucket.failed,
658        throughput: total as f64 / 3600.0,
659        avg_processing_time_ms: bucket.avg_processing_time_ms,
660        error_rate: if total > 0 {
661            bucket.failed as f64 / total as f64
662        } else {
663            0.0
664        },
665    }
666}
667
668/// Error patterns of failures since `since`, grouped by error type.
669///
670/// Built from the 500 most frequent distinct error messages, so counts and percentages
671/// are exact unless the failure set has more distinct messages than that.
672async fn generate_error_patterns<T>(
673    queue: &T,
674    since: chrono::DateTime<chrono::Utc>,
675) -> hammerwork::Result<Vec<ErrorPattern>>
676where
677    T: JobHistory,
678{
679    Ok(group_error_patterns(queue.error_groups(since, 500).await?))
680}
681
682/// Folds per-(queue, message) groups into one pattern per error type with real
683/// first/last-seen times and the set of affected queues.
684fn group_error_patterns(groups: Vec<ErrorGroup>) -> Vec<ErrorPattern> {
685    use std::collections::{BTreeMap, BTreeSet};
686
687    struct Agg {
688        count: u64,
689        sample: (u64, String),
690        first_seen: chrono::DateTime<chrono::Utc>,
691        last_seen: chrono::DateTime<chrono::Utc>,
692        queues: BTreeSet<String>,
693    }
694
695    let total: u64 = groups.iter().map(|g| g.count).sum();
696    let mut by_type: BTreeMap<String, Agg> = BTreeMap::new();
697    for group in groups {
698        let error_type = extract_error_type(&group.message);
699        let agg = by_type.entry(error_type).or_insert_with(|| Agg {
700            count: 0,
701            sample: (0, group.message.clone()),
702            first_seen: group.first_seen,
703            last_seen: group.last_seen,
704            queues: BTreeSet::new(),
705        });
706        agg.count += group.count;
707        if group.count > agg.sample.0 {
708            agg.sample = (group.count, group.message.clone());
709        }
710        agg.first_seen = agg.first_seen.min(group.first_seen);
711        agg.last_seen = agg.last_seen.max(group.last_seen);
712        agg.queues.insert(group.queue_name);
713    }
714
715    let mut patterns: Vec<ErrorPattern> = by_type
716        .into_iter()
717        .map(|(error_type, agg)| ErrorPattern {
718            error_type,
719            count: agg.count,
720            percentage: if total > 0 {
721                agg.count as f64 / total as f64 * 100.0
722            } else {
723                0.0
724            },
725            sample_message: agg.sample.1,
726            first_seen: agg.first_seen,
727            last_seen: agg.last_seen,
728            affected_queues: agg.queues.into_iter().collect(),
729        })
730        .collect();
731    patterns.sort_by(|a, b| {
732        b.count
733            .cmp(&a.count)
734            .then_with(|| a.error_type.cmp(&b.error_type))
735    });
736    patterns
737}
738
739/// Calculate performance metrics from queue statistics.
740///
741/// `database_response_time_ms` is the measured time of the statistics query. Worker
742/// counts and utilization are `None`: running jobs are not workers, and there is no
743/// worker registry to count them from.
744fn calculate_performance_metrics(
745    all_stats: &[hammerwork::stats::QueueStats],
746    database_response_time_ms: f64,
747) -> PerformanceMetrics {
748    let total_throughput = all_stats
749        .iter()
750        .map(|s| s.statistics.throughput_per_minute)
751        .sum::<f64>();
752
753    let average_queue_depth = if !all_stats.is_empty() {
754        all_stats
755            .iter()
756            .map(|s| s.pending_count as f64)
757            .sum::<f64>()
758            / all_stats.len() as f64
759    } else {
760        0.0
761    };
762
763    PerformanceMetrics {
764        database_response_time_ms: Some(database_response_time_ms),
765        average_queue_depth,
766        jobs_per_second: total_throughput / 60.0, // Convert from per minute to per second
767        memory_usage_mb: None,
768        cpu_usage_percent: None,
769        active_workers: None,
770        worker_utilization: None,
771    }
772}
773
774/// Extract error type from error message for grouping
775fn extract_error_type(error_msg: &str) -> String {
776    // Simple error classification logic
777    if error_msg.contains("timeout") || error_msg.contains("Timeout") {
778        "Timeout Error".to_string()
779    } else if error_msg.contains("connection") || error_msg.contains("Connection") {
780        "Connection Error".to_string()
781    } else if error_msg.contains("parse")
782        || error_msg.contains("Parse")
783        || error_msg.contains("invalid")
784    {
785        "Parse Error".to_string()
786    } else if error_msg.contains("permission")
787        || error_msg.contains("Permission")
788        || error_msg.contains("forbidden")
789    {
790        "Permission Error".to_string()
791    } else if error_msg.contains("not found") || error_msg.contains("Not Found") {
792        "Not Found Error".to_string()
793    } else {
794        // Use first word of error message as type
795        error_msg
796            .split_whitespace()
797            .next()
798            .map(|s| format!("{} Error", s))
799            .unwrap_or_else(|| "Unknown Error".to_string())
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use crate::api::test_support::{body_json, unreachable_queue};
807
808    #[test]
809    fn test_overview_counts_failed_dead_and_timed_out_jobs_as_failed() {
810        let mut stats = hammerwork::stats::QueueStats {
811            queue_name: "q".to_string(),
812            pending_count: 1,
813            running_count: 0,
814            dead_count: 2,
815            timed_out_count: 3,
816            completed_count: 4,
817            statistics: Default::default(),
818        };
819        // Jobs in status `Failed` (a failed dependency, a fail-fast batch, `fail_job`)
820        stats.statistics.failed = 5;
821        let overview = generate_overview_from_stats(&[stats], 0);
822        assert_eq!(overview.failed_jobs, 5 + 2 + 3);
823        assert_eq!(overview.dead_jobs, 2);
824        assert_eq!(overview.total_jobs, 1 + 4 + 10);
825        assert!((overview.overall_error_rate - 10.0 / 15.0).abs() < 1e-9);
826    }
827
828    fn state() -> Arc<tokio::sync::RwLock<crate::api::system::SystemState>> {
829        Arc::new(tokio::sync::RwLock::new(
830            crate::api::system::SystemState::new(
831                crate::DashboardConfig::default(),
832                "PostgreSQL".to_string(),
833                1,
834            ),
835        ))
836    }
837
838    fn empty_query() -> StatsQuery {
839        serde_json::from_value(serde_json::json!({})).unwrap()
840    }
841
842    #[tokio::test]
843    async fn test_detailed_stats_returns_500_when_database_is_down() {
844        let response = detailed_stats_handler(unreachable_queue(), state(), empty_query())
845            .await
846            .unwrap()
847            .into_response();
848        let (status, body) = body_json(response).await;
849        assert_eq!(status, 500);
850        assert_eq!(body["success"], false);
851    }
852
853    #[tokio::test]
854    async fn test_trends_returns_500_when_database_is_down() {
855        let response = trends_handler(unreachable_queue(), empty_query())
856            .await
857            .unwrap()
858            .into_response();
859        let (status, body) = body_json(response).await;
860        assert_eq!(status, 500);
861        assert_eq!(body["success"], false);
862    }
863
864    #[tokio::test]
865    async fn test_trends_rejects_invalid_time_range() {
866        let inverted: StatsQuery = serde_json::from_value(serde_json::json!({
867            "time_range": {"start": "2024-01-02T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
868        }))
869        .unwrap();
870        let response = trends_handler(unreachable_queue(), inverted)
871            .await
872            .unwrap()
873            .into_response();
874        assert_eq!(body_json(response).await.0, 400);
875
876        let too_long: StatsQuery = serde_json::from_value(serde_json::json!({
877            "time_range": {"start": "2020-01-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
878        }))
879        .unwrap();
880        let response = trends_handler(unreachable_queue(), too_long)
881            .await
882            .unwrap()
883            .into_response();
884        assert_eq!(body_json(response).await.0, 400);
885    }
886
887    #[test]
888    fn test_default_range_is_24_hourly_buckets() {
889        let now = chrono::Utc::now();
890        let (start, end) = resolve_range(&empty_query(), now).unwrap();
891        assert_eq!(end, now);
892        assert_eq!(crate::api::history::hour_range(start, end).len(), 24);
893    }
894
895    #[test]
896    fn test_trend_from_bucket_computes_rates_without_inventing_values() {
897        let hour = hour_floor(chrono::Utc::now());
898        let busy = trend_from_bucket(crate::api::history::HourBucket {
899            hour,
900            completed: 3,
901            failed: 1,
902            avg_processing_time_ms: Some(40.0),
903        });
904        assert_eq!(busy.error_rate, 0.25);
905        assert_eq!(busy.throughput, 4.0 / 3600.0);
906        let idle = trend_from_bucket(crate::api::history::HourBucket {
907            hour,
908            completed: 0,
909            failed: 0,
910            avg_processing_time_ms: None,
911        });
912        assert_eq!(idle.error_rate, 0.0);
913        assert_eq!(idle.avg_processing_time_ms, None);
914    }
915
916    #[test]
917    fn test_group_error_patterns_uses_real_times_and_queues() {
918        let now = chrono::Utc::now();
919        let t = |h| now - chrono::Duration::hours(h);
920        let group = |queue: &str, msg: &str, count, first, last| ErrorGroup {
921            queue_name: queue.to_string(),
922            message: msg.to_string(),
923            count,
924            first_seen: t(first),
925            last_seen: t(last),
926        };
927        let patterns = group_error_patterns(vec![
928            group("emails", "connection refused by host a", 6, 10, 2),
929            group("reports", "connection reset", 2, 8, 1),
930            group("emails", "request timeout", 2, 5, 3),
931        ]);
932        assert_eq!(patterns.len(), 2);
933        let conn = &patterns[0];
934        assert_eq!(conn.error_type, "Connection Error");
935        assert_eq!(conn.count, 8);
936        assert_eq!(conn.percentage, 80.0);
937        assert_eq!(conn.sample_message, "connection refused by host a");
938        assert_eq!(conn.affected_queues, vec!["emails", "reports"]);
939        assert_eq!(conn.first_seen, t(10));
940        assert_eq!(conn.last_seen, t(1));
941        assert_eq!(patterns[1].affected_queues, vec!["emails"]);
942        assert!(group_error_patterns(Vec::new()).is_empty());
943    }
944
945    #[test]
946    fn test_performance_metrics_report_unknowns_as_none() {
947        let metrics = calculate_performance_metrics(&[], 3.5);
948        assert_eq!(metrics.database_response_time_ms, Some(3.5));
949        assert_eq!(metrics.active_workers, None);
950        assert_eq!(metrics.worker_utilization, None);
951        assert_eq!(metrics.memory_usage_mb, None);
952    }
953
954    #[test]
955    fn test_age_seconds_clamps_future_timestamps_to_zero() {
956        let now = chrono::Utc::now();
957        assert_eq!(age_seconds(now, now + chrono::Duration::seconds(30)), 0);
958        assert_eq!(age_seconds(now, now - chrono::Duration::seconds(30)), 30);
959    }
960
961    #[test]
962    fn test_stats_query_deserialization() {
963        let json = r#"{
964            "time_range": {
965                "start": "2024-01-01T00:00:00Z",
966                "end": "2024-01-02T00:00:00Z"
967            },
968            "queues": ["email", "data-processing"],
969            "granularity": "hour"
970        }"#;
971
972        let query: StatsQuery = serde_json::from_str(json).unwrap();
973        assert!(query.time_range.is_some());
974        assert_eq!(query.queues.as_ref().unwrap().len(), 2);
975        assert_eq!(query.granularity, Some("hour".to_string()));
976    }
977
978    #[test]
979    fn test_system_alert_serialization() {
980        let alert = SystemAlert {
981            severity: "warning".to_string(),
982            message: "High error rate detected".to_string(),
983            queue: Some("email".to_string()),
984            metric: Some("error_rate".to_string()),
985            value: Some(0.15),
986            threshold: Some(0.1),
987            timestamp: chrono::Utc::now(),
988        };
989
990        let json = serde_json::to_string(&alert).unwrap();
991        assert!(json.contains("warning"));
992        assert!(json.contains("High error rate"));
993    }
994}