1use 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#[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#[derive(Debug, Serialize)]
141pub struct SystemHealth {
142 pub status: String, 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#[derive(Debug, Serialize)]
152pub struct SystemAlert {
153 pub severity: String, 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#[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#[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#[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 pub avg_processing_time_ms: Option<f64>,
197 pub error_rate: f64,
198}
199
200#[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#[derive(Debug, Serialize)]
214pub struct PerformanceMetrics {
215 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 pub active_workers: Option<u32>,
223 pub worker_utilization: Option<f64>,
225}
226
227#[derive(Debug, Deserialize)]
229pub struct TimeRange {
230 pub start: chrono::DateTime<chrono::Utc>,
231 pub end: chrono::DateTime<chrono::Utc>,
232}
233
234#[derive(Debug, Deserialize)]
236pub struct StatsQuery {
237 pub time_range: Option<TimeRange>,
238 pub queues: Option<Vec<String>>,
239 pub granularity: Option<String>, pub hours: Option<u32>,
242}
243
244pub 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
290async 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
312async 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 let mut queue_stats: Vec<QueueStats> = Vec::new();
333 for stats in all_stats.iter() {
334 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 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
392fn 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
421async 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
437async 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
469fn 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 if stat.statistics.error_rate > 0.1 {
479 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 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 if stat.statistics.avg_processing_time_ms > 30000.0 {
508 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, high_error_rate,
534 queue_backlog,
535 slow_processing,
536 alerts,
537 }
538}
539
540fn 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
596async 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 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
614fn 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
623async 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
639async 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
668async 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
682fn 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
739fn 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, memory_usage_mb: None,
768 cpu_usage_percent: None,
769 active_workers: None,
770 worker_utilization: None,
771 }
772}
773
774fn extract_error_type(error_msg: &str) -> String {
776 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 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 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}