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)]
241pub struct StatsQuery {
242 #[serde(default, deserialize_with = "de_time_range")]
243 pub time_range: Option<TimeRange>,
244 #[serde(default, deserialize_with = "de_queues")]
246 pub queues: Option<Vec<String>>,
247 pub granularity: Option<String>,
250 pub hours: Option<u32>,
252}
253
254fn de_queues<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
256where
257 D: serde::Deserializer<'de>,
258{
259 #[derive(Deserialize)]
260 #[serde(untagged)]
261 enum Raw {
262 List(Vec<String>),
263 Csv(String),
264 }
265 Ok(Option::<Raw>::deserialize(deserializer)?
266 .map(|raw| match raw {
267 Raw::List(names) => names,
268 Raw::Csv(csv) => csv.split(',').map(str::to_string).collect(),
269 })
270 .map(|names| {
271 names
272 .into_iter()
273 .map(|name| name.trim().to_string())
274 .filter(|name| !name.is_empty())
275 .collect()
276 }))
277}
278
279fn de_time_range<'de, D>(deserializer: D) -> Result<Option<TimeRange>, D::Error>
281where
282 D: serde::Deserializer<'de>,
283{
284 use serde::de::Error;
285
286 #[derive(Deserialize)]
287 #[serde(untagged)]
288 enum Raw {
289 Range(TimeRange),
290 Pair(String),
291 }
292 match Option::<Raw>::deserialize(deserializer)? {
293 None => Ok(None),
294 Some(Raw::Range(range)) => Ok(Some(range)),
295 Some(Raw::Pair(pair)) => {
296 let parse = |text: &str| {
297 chrono::DateTime::parse_from_rfc3339(text.trim())
298 .map(|time| time.with_timezone(&chrono::Utc))
299 .map_err(|e| D::Error::custom(format!("invalid time_range `{text}`: {e}")))
300 };
301 let (start, end) = pair.split_once(',').ok_or_else(|| {
302 D::Error::custom("time_range must be `START,END` (RFC 3339 timestamps)")
303 })?;
304 Ok(Some(TimeRange {
305 start: parse(start)?,
306 end: parse(end)?,
307 }))
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314enum Granularity {
315 Hour,
316 Day,
317 Week,
318}
319
320impl Granularity {
321 fn parse(name: Option<&str>) -> Result<Self, String> {
322 match name.map(|name| name.trim().to_lowercase()).as_deref() {
323 None | Some("") | Some("hour") => Ok(Self::Hour),
324 Some("day") => Ok(Self::Day),
325 Some("week") => Ok(Self::Week),
326 Some(other) => Err(format!(
327 "Invalid granularity '{other}'. Valid options: hour, day, week"
328 )),
329 }
330 }
331
332 fn bucket_start(self, hour: chrono::DateTime<chrono::Utc>) -> chrono::DateTime<chrono::Utc> {
334 use chrono::{Datelike, TimeZone};
335 let day = hour.date_naive();
336 let day = match self {
337 Self::Hour => return hour,
338 Self::Day => day,
339 Self::Week => {
340 day - chrono::Duration::days(i64::from(day.weekday().num_days_from_monday()))
341 }
342 };
343 chrono::Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).unwrap_or_default())
344 }
345}
346
347fn roll_up(trends: Vec<HourlyTrend>, granularity: Granularity) -> Vec<HourlyTrend> {
350 if granularity == Granularity::Hour {
351 return trends;
352 }
353 struct Acc {
354 hour: chrono::DateTime<chrono::Utc>,
355 hours: u64,
356 completed: u64,
357 failed: u64,
358 processing_ms: f64,
359 }
360 let mut buckets: Vec<Acc> = Vec::new();
361 for trend in trends {
362 let start = granularity.bucket_start(trend.hour);
363 if buckets.last().is_none_or(|bucket| bucket.hour != start) {
364 buckets.push(Acc {
365 hour: start,
366 hours: 0,
367 completed: 0,
368 failed: 0,
369 processing_ms: 0.0,
370 });
371 }
372 if let Some(bucket) = buckets.last_mut() {
373 bucket.hours += 1;
374 bucket.completed += trend.completed;
375 bucket.failed += trend.failed;
376 bucket.processing_ms +=
377 trend.avg_processing_time_ms.unwrap_or(0.0) * trend.completed as f64;
378 }
379 }
380 buckets
381 .into_iter()
382 .map(|bucket| {
383 let total = bucket.completed + bucket.failed;
384 HourlyTrend {
385 hour: bucket.hour,
386 completed: bucket.completed,
387 failed: bucket.failed,
388 throughput: total as f64 / (bucket.hours * 3600) as f64,
389 avg_processing_time_ms: (bucket.completed > 0)
390 .then(|| bucket.processing_ms / bucket.completed as f64),
391 error_rate: if total > 0 {
392 bucket.failed as f64 / total as f64
393 } else {
394 0.0
395 },
396 }
397 })
398 .collect()
399}
400
401fn queue_selected(queues: &Option<Vec<String>>, queue_name: &str) -> bool {
403 match queues {
404 Some(names) if !names.is_empty() => names.iter().any(|name| name == queue_name),
405 _ => true,
406 }
407}
408
409pub fn routes<T>(
411 queue: Arc<T>,
412 system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
413) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
414where
415 T: JobHistory + 'static,
416{
417 let queue_filter = with_shared(queue);
418 let state_filter = with_shared(system_state);
419
420 let overview = warp::path("stats")
421 .and(warp::path("overview"))
422 .and(warp::path::end())
423 .and(warp::get())
424 .and(queue_filter.clone())
425 .and(state_filter.clone())
426 .and_then(overview_handler);
427
428 let detailed = warp::path("stats")
429 .and(warp::path("detailed"))
430 .and(warp::path::end())
431 .and(warp::get())
432 .and(queue_filter.clone())
433 .and(state_filter.clone())
434 .and(warp::query::<StatsQuery>())
435 .and_then(detailed_stats_handler);
436
437 let trends = warp::path("stats")
438 .and(warp::path("trends"))
439 .and(warp::path::end())
440 .and(warp::get())
441 .and(queue_filter.clone())
442 .and(warp::query::<StatsQuery>())
443 .and_then(trends_handler);
444
445 let health = warp::path("stats")
446 .and(warp::path("health"))
447 .and(warp::path::end())
448 .and(warp::get())
449 .and(queue_filter)
450 .and_then(health_handler);
451
452 overview.or(detailed).or(trends).or(health)
453}
454
455async fn overview_handler<T>(
457 queue: Arc<T>,
458 system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
459) -> Result<impl Reply, warp::Rejection>
460where
461 T: DatabaseQueue + Send + Sync,
462{
463 match queue.get_all_queue_stats().await {
464 Ok(all_stats) => {
465 let uptime_seconds =
466 u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
467 let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
468 Ok(json_reply(&ApiResponse::success(overview)))
469 }
470 Err(e) => Ok(error_reply(
471 StatusCode::INTERNAL_SERVER_ERROR,
472 format!("Failed to get statistics: {}", e),
473 )),
474 }
475}
476
477async fn detailed_stats_handler<T>(
479 queue: Arc<T>,
480 system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
481 query: StatsQuery,
482) -> Result<impl Reply, warp::Rejection>
483where
484 T: JobHistory,
485{
486 let now = chrono::Utc::now();
487 let (start, end) = match resolve_range(&query, now) {
488 Ok(range) => range,
489 Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
490 };
491 let granularity = match Granularity::parse(query.granularity.as_deref()) {
492 Ok(granularity) => granularity,
493 Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
494 };
495
496 let db_started = std::time::Instant::now();
497 match queue.get_all_queue_stats().await {
498 Ok(mut all_stats) => {
499 all_stats.retain(|stats| queue_selected(&query.queues, &stats.queue_name));
500 let db_ms = db_started.elapsed().as_secs_f64() * 1000.0;
501 let mut queue_stats: Vec<QueueStats> = Vec::new();
503 for stats in all_stats.iter() {
504 let oldest_pending_age_seconds = try_api!(
506 calculate_oldest_pending_age(&queue, &stats.queue_name).await,
507 "Failed to get oldest pending job age"
508 );
509
510 let priority_distribution = try_api!(
512 get_priority_distribution(&queue, &stats.queue_name).await,
513 "Failed to get priority distribution"
514 );
515
516 queue_stats.push(QueueStats {
517 name: stats.queue_name.clone(),
518 pending: stats.pending_count,
519 running: stats.running_count,
520 completed_total: stats.completed_count,
521 failed_total: super::failed_job_count(stats),
522 dead_total: stats.dead_count,
523 throughput_per_minute: stats.statistics.throughput_per_minute,
524 avg_processing_time_ms: stats.statistics.avg_processing_time_ms,
525 error_rate: stats.statistics.error_rate,
526 oldest_pending_age_seconds,
527 priority_distribution,
528 });
529 }
530
531 let hourly_trends = try_api!(
532 compute_trends(&*queue, start, end, &query.queues, granularity).await,
533 "Failed to compute hourly trends"
534 );
535 let error_patterns = try_api!(
536 generate_error_patterns(&*queue, start, &query.queues).await,
537 "Failed to compute error patterns"
538 );
539 let performance_metrics = calculate_performance_metrics(&all_stats, db_ms);
540
541 let uptime_seconds =
542 u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
543 let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
544
545 let detailed = DetailedStats {
546 overview,
547 queue_stats,
548 hourly_trends,
549 error_patterns,
550 performance_metrics,
551 };
552
553 Ok(json_reply(&ApiResponse::success(detailed)))
554 }
555 Err(e) => Ok(error_reply(
556 StatusCode::INTERNAL_SERVER_ERROR,
557 format!("Failed to get detailed statistics: {}", e),
558 )),
559 }
560}
561
562fn resolve_range(
565 query: &StatsQuery,
566 now: chrono::DateTime<chrono::Utc>,
567) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
568 if let Some(hours) = query.hours {
569 if hours == 0 || i64::from(hours) > MAX_BUCKETS {
570 return Err(format!("hours must be between 1 and {}", MAX_BUCKETS));
571 }
572 return Ok((
573 hour_floor(now) - chrono::Duration::hours(i64::from(hours) - 1),
574 now,
575 ));
576 }
577 match &query.time_range {
578 Some(range) => {
579 if range.start >= range.end {
580 Err("time_range.start must be before time_range.end".to_string())
581 } else if range.end - range.start > chrono::Duration::hours(MAX_BUCKETS) {
582 Err(format!("time_range may span at most {} hours", MAX_BUCKETS))
583 } else {
584 Ok((range.start, range.end))
585 }
586 }
587 None => Ok((hour_floor(now) - chrono::Duration::hours(23), now)),
588 }
589}
590
591async fn trends_handler<T>(queue: Arc<T>, query: StatsQuery) -> Result<impl Reply, warp::Rejection>
593where
594 T: JobHistory,
595{
596 let (start, end) = match resolve_range(&query, chrono::Utc::now()) {
597 Ok(range) => range,
598 Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
599 };
600 let granularity = match Granularity::parse(query.granularity.as_deref()) {
601 Ok(granularity) => granularity,
602 Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
603 };
604 let trends = try_api!(
605 compute_trends(&*queue, start, end, &query.queues, granularity).await,
606 "Failed to compute hourly trends"
607 );
608 Ok(json_reply(&ApiResponse::success(trends)))
609}
610
611async fn health_handler<T>(queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
613where
614 T: DatabaseQueue + Send + Sync,
615{
616 match queue.get_all_queue_stats().await {
617 Ok(all_stats) => {
618 let health = assess_system_health(&all_stats);
619 Ok(json_reply(&ApiResponse::success(health)))
620 }
621 Err(e) => {
622 let health = SystemHealth {
623 status: "critical".to_string(),
624 database_healthy: false,
625 high_error_rate: false,
626 queue_backlog: false,
627 slow_processing: false,
628 alerts: vec![SystemAlert {
629 severity: "critical".to_string(),
630 message: format!("Database connection failed: {}", e),
631 queue: None,
632 metric: Some("database_connectivity".to_string()),
633 value: None,
634 threshold: None,
635 timestamp: chrono::Utc::now(),
636 }],
637 };
638 Ok(json_reply(&ApiResponse::success(health)))
639 }
640 }
641}
642
643fn assess_system_health(stats: &[hammerwork::stats::QueueStats]) -> SystemHealth {
645 let mut alerts = Vec::new();
646 let mut high_error_rate = false;
647 let mut queue_backlog = false;
648 let mut slow_processing = false;
649
650 for stat in stats {
651 if stat.statistics.error_rate > 0.1 {
653 high_error_rate = true;
655 alerts.push(SystemAlert {
656 severity: "warning".to_string(),
657 message: format!("High error rate in queue '{}'", stat.queue_name),
658 queue: Some(stat.queue_name.clone()),
659 metric: Some("error_rate".to_string()),
660 value: Some(stat.statistics.error_rate),
661 threshold: Some(0.1),
662 timestamp: chrono::Utc::now(),
663 });
664 }
665
666 if stat.pending_count > 1000 {
668 queue_backlog = true;
669 alerts.push(SystemAlert {
670 severity: "warning".to_string(),
671 message: format!("Large backlog in queue '{}'", stat.queue_name),
672 queue: Some(stat.queue_name.clone()),
673 metric: Some("pending_count".to_string()),
674 value: Some(stat.pending_count as f64),
675 threshold: Some(1000.0),
676 timestamp: chrono::Utc::now(),
677 });
678 }
679
680 if stat.statistics.avg_processing_time_ms > 30000.0 {
682 slow_processing = true;
684 alerts.push(SystemAlert {
685 severity: "info".to_string(),
686 message: format!("Slow processing in queue '{}'", stat.queue_name),
687 queue: Some(stat.queue_name.clone()),
688 metric: Some("avg_processing_time_ms".to_string()),
689 value: Some(stat.statistics.avg_processing_time_ms),
690 threshold: Some(30000.0),
691 timestamp: chrono::Utc::now(),
692 });
693 }
694 }
695
696 let status = if alerts.iter().any(|a| a.severity == "critical") {
697 "critical"
698 } else if alerts.iter().any(|a| a.severity == "warning") {
699 "degraded"
700 } else {
701 "healthy"
702 };
703
704 SystemHealth {
705 status: status.to_string(),
706 database_healthy: true, high_error_rate,
708 queue_backlog,
709 slow_processing,
710 alerts,
711 }
712}
713
714fn generate_overview_from_stats(
716 stats: &[hammerwork::stats::QueueStats],
717 uptime_seconds: u64,
718) -> SystemOverview {
719 let mut total_pending = 0;
720 let mut total_running = 0;
721 let mut total_completed = 0;
722 let mut total_failed = 0;
723 let mut total_dead = 0;
724 let mut total_throughput = 0.0;
725 let mut total_processing_time = 0.0;
726 let queue_count = stats.len();
727
728 for stat in stats {
729 total_pending += stat.pending_count;
730 total_running += stat.running_count;
731 total_completed += stat.completed_count;
732 total_failed += super::failed_job_count(stat);
733 total_dead += stat.dead_count;
734 total_throughput += stat.statistics.throughput_per_minute;
735 total_processing_time += stat.statistics.avg_processing_time_ms;
736 }
737
738 let avg_processing_time = if queue_count > 0 {
739 total_processing_time / queue_count as f64
740 } else {
741 0.0
742 };
743
744 let total_jobs = total_pending + total_running + total_completed + total_failed;
745 let overall_error_rate = if total_jobs > 0 {
746 total_failed as f64 / total_jobs as f64
747 } else {
748 0.0
749 };
750
751 let health = assess_system_health(stats);
752
753 SystemOverview {
754 total_queues: queue_count as u32,
755 total_jobs,
756 pending_jobs: total_pending,
757 running_jobs: total_running,
758 completed_jobs: total_completed,
759 failed_jobs: total_failed,
760 dead_jobs: total_dead,
761 overall_throughput: total_throughput,
762 overall_error_rate,
763 avg_processing_time_ms: avg_processing_time,
764 system_health: health,
765 uptime_seconds,
766 last_updated: chrono::Utc::now(),
767 }
768}
769
770async fn calculate_oldest_pending_age<T>(
772 queue: &Arc<T>,
773 queue_name: &str,
774) -> hammerwork::Result<Option<u64>>
775where
776 T: DatabaseQueue + Send + Sync,
777{
778 let jobs = queue.get_ready_jobs(queue_name, 100).await?;
780 let now = chrono::Utc::now();
781 Ok(jobs
782 .iter()
783 .filter(|job| matches!(job.status, hammerwork::job::JobStatus::Pending))
784 .map(|job| age_seconds(now, job.created_at))
785 .max())
786}
787
788fn age_seconds(
791 now: chrono::DateTime<chrono::Utc>,
792 created_at: chrono::DateTime<chrono::Utc>,
793) -> u64 {
794 u64::try_from((now - created_at).num_seconds()).unwrap_or(0)
795}
796
797async fn get_priority_distribution<T>(
799 queue: &Arc<T>,
800 queue_name: &str,
801) -> hammerwork::Result<HashMap<String, f32>>
802where
803 T: DatabaseQueue + Send + Sync,
804{
805 let priority_stats = queue.get_priority_stats(queue_name).await?;
806 Ok(priority_stats
807 .priority_distribution
808 .into_iter()
809 .map(|(priority, percentage)| (priority.to_string(), percentage))
810 .collect())
811}
812
813async fn compute_trends<T>(
815 queue: &T,
816 start: chrono::DateTime<chrono::Utc>,
817 end: chrono::DateTime<chrono::Utc>,
818 queues: &Option<Vec<String>>,
819 granularity: Granularity,
820) -> hammerwork::Result<Vec<HourlyTrend>>
821where
822 T: JobHistory,
823{
824 let buckets = match queues {
825 Some(names) if !names.is_empty() => {
826 let mut merged: Vec<super::history::HourBucket> = Vec::new();
828 for name in names {
829 let queue_buckets = queue.hourly_activity(Some(name), start, end).await?;
830 if merged.is_empty() {
831 merged = queue_buckets;
832 continue;
833 }
834 for (total, bucket) in merged.iter_mut().zip(queue_buckets) {
835 let done = total.completed + bucket.completed;
836 total.avg_processing_time_ms = if done == 0 {
837 None
838 } else {
839 Some(
840 (total.avg_processing_time_ms.unwrap_or(0.0) * total.completed as f64
841 + bucket.avg_processing_time_ms.unwrap_or(0.0)
842 * bucket.completed as f64)
843 / done as f64,
844 )
845 };
846 total.completed = done;
847 total.failed += bucket.failed;
848 }
849 }
850 merged
851 }
852 _ => queue.hourly_activity(None, start, end).await?,
853 };
854 Ok(roll_up(
855 buckets.into_iter().map(trend_from_bucket).collect(),
856 granularity,
857 ))
858}
859
860fn trend_from_bucket(bucket: super::history::HourBucket) -> HourlyTrend {
861 let total = bucket.completed + bucket.failed;
862 HourlyTrend {
863 hour: bucket.hour,
864 completed: bucket.completed,
865 failed: bucket.failed,
866 throughput: total as f64 / 3600.0,
867 avg_processing_time_ms: bucket.avg_processing_time_ms,
868 error_rate: if total > 0 {
869 bucket.failed as f64 / total as f64
870 } else {
871 0.0
872 },
873 }
874}
875
876async fn generate_error_patterns<T>(
881 queue: &T,
882 since: chrono::DateTime<chrono::Utc>,
883 queues: &Option<Vec<String>>,
884) -> hammerwork::Result<Vec<ErrorPattern>>
885where
886 T: JobHistory,
887{
888 let mut groups = queue.error_groups(since, 500).await?;
889 groups.retain(|group| queue_selected(queues, &group.queue_name));
890 Ok(group_error_patterns(groups))
891}
892
893fn group_error_patterns(groups: Vec<ErrorGroup>) -> Vec<ErrorPattern> {
896 use std::collections::{BTreeMap, BTreeSet};
897
898 struct Agg {
899 count: u64,
900 sample: (u64, String),
901 first_seen: chrono::DateTime<chrono::Utc>,
902 last_seen: chrono::DateTime<chrono::Utc>,
903 queues: BTreeSet<String>,
904 }
905
906 let total: u64 = groups.iter().map(|g| g.count).sum();
907 let mut by_type: BTreeMap<String, Agg> = BTreeMap::new();
908 for group in groups {
909 let error_type = extract_error_type(&group.message);
910 let agg = by_type.entry(error_type).or_insert_with(|| Agg {
911 count: 0,
912 sample: (0, group.message.clone()),
913 first_seen: group.first_seen,
914 last_seen: group.last_seen,
915 queues: BTreeSet::new(),
916 });
917 agg.count += group.count;
918 if group.count > agg.sample.0 {
919 agg.sample = (group.count, group.message.clone());
920 }
921 agg.first_seen = agg.first_seen.min(group.first_seen);
922 agg.last_seen = agg.last_seen.max(group.last_seen);
923 agg.queues.insert(group.queue_name);
924 }
925
926 let mut patterns: Vec<ErrorPattern> = by_type
927 .into_iter()
928 .map(|(error_type, agg)| ErrorPattern {
929 error_type,
930 count: agg.count,
931 percentage: if total > 0 {
932 agg.count as f64 / total as f64 * 100.0
933 } else {
934 0.0
935 },
936 sample_message: agg.sample.1,
937 first_seen: agg.first_seen,
938 last_seen: agg.last_seen,
939 affected_queues: agg.queues.into_iter().collect(),
940 })
941 .collect();
942 patterns.sort_by(|a, b| {
943 b.count
944 .cmp(&a.count)
945 .then_with(|| a.error_type.cmp(&b.error_type))
946 });
947 patterns
948}
949
950fn calculate_performance_metrics(
956 all_stats: &[hammerwork::stats::QueueStats],
957 database_response_time_ms: f64,
958) -> PerformanceMetrics {
959 let total_throughput = all_stats
960 .iter()
961 .map(|s| s.statistics.throughput_per_minute)
962 .sum::<f64>();
963
964 let average_queue_depth = if !all_stats.is_empty() {
965 all_stats
966 .iter()
967 .map(|s| s.pending_count as f64)
968 .sum::<f64>()
969 / all_stats.len() as f64
970 } else {
971 0.0
972 };
973
974 PerformanceMetrics {
975 database_response_time_ms: Some(database_response_time_ms),
976 average_queue_depth,
977 jobs_per_second: total_throughput / 60.0, memory_usage_mb: None,
979 cpu_usage_percent: None,
980 active_workers: None,
981 worker_utilization: None,
982 }
983}
984
985fn extract_error_type(error_msg: &str) -> String {
987 if error_msg.contains("timeout") || error_msg.contains("Timeout") {
989 "Timeout Error".to_string()
990 } else if error_msg.contains("connection") || error_msg.contains("Connection") {
991 "Connection Error".to_string()
992 } else if error_msg.contains("parse")
993 || error_msg.contains("Parse")
994 || error_msg.contains("invalid")
995 {
996 "Parse Error".to_string()
997 } else if error_msg.contains("permission")
998 || error_msg.contains("Permission")
999 || error_msg.contains("forbidden")
1000 {
1001 "Permission Error".to_string()
1002 } else if error_msg.contains("not found") || error_msg.contains("Not Found") {
1003 "Not Found Error".to_string()
1004 } else {
1005 error_msg
1007 .split_whitespace()
1008 .next()
1009 .map(|s| format!("{} Error", s))
1010 .unwrap_or_else(|| "Unknown Error".to_string())
1011 }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016 use super::*;
1017 use crate::api::test_support::{body_json, unreachable_queue};
1018 use chrono::TimeZone;
1019
1020 #[test]
1021 fn test_overview_counts_failed_dead_and_timed_out_jobs_as_failed() {
1022 let mut stats = hammerwork::stats::QueueStats {
1023 queue_name: "q".to_string(),
1024 pending_count: 1,
1025 running_count: 0,
1026 dead_count: 2,
1027 timed_out_count: 3,
1028 completed_count: 4,
1029 statistics: Default::default(),
1030 };
1031 stats.statistics.failed = 5;
1033 let overview = generate_overview_from_stats(&[stats], 0);
1034 assert_eq!(overview.failed_jobs, 5 + 2 + 3);
1035 assert_eq!(overview.dead_jobs, 2);
1036 assert_eq!(overview.total_jobs, 1 + 4 + 10);
1037 assert!((overview.overall_error_rate - 10.0 / 15.0).abs() < 1e-9);
1038 }
1039
1040 fn state() -> Arc<tokio::sync::RwLock<crate::api::system::SystemState>> {
1041 Arc::new(tokio::sync::RwLock::new(
1042 crate::api::system::SystemState::new(
1043 crate::DashboardConfig::default(),
1044 "PostgreSQL".to_string(),
1045 1,
1046 ),
1047 ))
1048 }
1049
1050 fn empty_query() -> StatsQuery {
1051 serde_json::from_value(serde_json::json!({})).unwrap()
1052 }
1053
1054 #[tokio::test]
1055 async fn test_detailed_stats_returns_500_when_database_is_down() {
1056 let response = detailed_stats_handler(unreachable_queue(), state(), empty_query())
1057 .await
1058 .unwrap()
1059 .into_response();
1060 let (status, body) = body_json(response).await;
1061 assert_eq!(status, 500);
1062 assert_eq!(body["success"], false);
1063 }
1064
1065 #[tokio::test]
1066 async fn test_trends_returns_500_when_database_is_down() {
1067 let response = trends_handler(unreachable_queue(), empty_query())
1068 .await
1069 .unwrap()
1070 .into_response();
1071 let (status, body) = body_json(response).await;
1072 assert_eq!(status, 500);
1073 assert_eq!(body["success"], false);
1074 }
1075
1076 #[tokio::test]
1077 async fn test_trends_rejects_invalid_time_range() {
1078 let inverted: StatsQuery = serde_json::from_value(serde_json::json!({
1079 "time_range": {"start": "2024-01-02T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
1080 }))
1081 .unwrap();
1082 let response = trends_handler(unreachable_queue(), inverted)
1083 .await
1084 .unwrap()
1085 .into_response();
1086 assert_eq!(body_json(response).await.0, 400);
1087
1088 let too_long: StatsQuery = serde_json::from_value(serde_json::json!({
1089 "time_range": {"start": "2020-01-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
1090 }))
1091 .unwrap();
1092 let response = trends_handler(unreachable_queue(), too_long)
1093 .await
1094 .unwrap()
1095 .into_response();
1096 assert_eq!(body_json(response).await.0, 400);
1097 }
1098
1099 #[test]
1100 fn test_default_range_is_24_hourly_buckets() {
1101 let now = chrono::Utc::now();
1102 let (start, end) = resolve_range(&empty_query(), now).unwrap();
1103 assert_eq!(end, now);
1104 assert_eq!(crate::api::history::hour_range(start, end).len(), 24);
1105 }
1106
1107 #[test]
1108 fn test_trend_from_bucket_computes_rates_without_inventing_values() {
1109 let hour = hour_floor(chrono::Utc::now());
1110 let busy = trend_from_bucket(crate::api::history::HourBucket {
1111 hour,
1112 completed: 3,
1113 failed: 1,
1114 avg_processing_time_ms: Some(40.0),
1115 });
1116 assert_eq!(busy.error_rate, 0.25);
1117 assert_eq!(busy.throughput, 4.0 / 3600.0);
1118 let idle = trend_from_bucket(crate::api::history::HourBucket {
1119 hour,
1120 completed: 0,
1121 failed: 0,
1122 avg_processing_time_ms: None,
1123 });
1124 assert_eq!(idle.error_rate, 0.0);
1125 assert_eq!(idle.avg_processing_time_ms, None);
1126 }
1127
1128 #[test]
1129 fn test_group_error_patterns_uses_real_times_and_queues() {
1130 let now = chrono::Utc::now();
1131 let t = |h| now - chrono::Duration::hours(h);
1132 let group = |queue: &str, msg: &str, count, first, last| ErrorGroup {
1133 queue_name: queue.to_string(),
1134 message: msg.to_string(),
1135 count,
1136 first_seen: t(first),
1137 last_seen: t(last),
1138 };
1139 let patterns = group_error_patterns(vec![
1140 group("emails", "connection refused by host a", 6, 10, 2),
1141 group("reports", "connection reset", 2, 8, 1),
1142 group("emails", "request timeout", 2, 5, 3),
1143 ]);
1144 assert_eq!(patterns.len(), 2);
1145 let conn = &patterns[0];
1146 assert_eq!(conn.error_type, "Connection Error");
1147 assert_eq!(conn.count, 8);
1148 assert_eq!(conn.percentage, 80.0);
1149 assert_eq!(conn.sample_message, "connection refused by host a");
1150 assert_eq!(conn.affected_queues, vec!["emails", "reports"]);
1151 assert_eq!(conn.first_seen, t(10));
1152 assert_eq!(conn.last_seen, t(1));
1153 assert_eq!(patterns[1].affected_queues, vec!["emails"]);
1154 assert!(group_error_patterns(Vec::new()).is_empty());
1155 }
1156
1157 #[test]
1158 fn test_performance_metrics_report_unknowns_as_none() {
1159 let metrics = calculate_performance_metrics(&[], 3.5);
1160 assert_eq!(metrics.database_response_time_ms, Some(3.5));
1161 assert_eq!(metrics.active_workers, None);
1162 assert_eq!(metrics.worker_utilization, None);
1163 assert_eq!(metrics.memory_usage_mb, None);
1164 }
1165
1166 #[test]
1167 fn test_age_seconds_clamps_future_timestamps_to_zero() {
1168 let now = chrono::Utc::now();
1169 assert_eq!(age_seconds(now, now + chrono::Duration::seconds(30)), 0);
1170 assert_eq!(age_seconds(now, now - chrono::Duration::seconds(30)), 30);
1171 }
1172
1173 #[test]
1174 fn test_stats_query_deserialization() {
1175 let json = r#"{
1176 "time_range": {
1177 "start": "2024-01-01T00:00:00Z",
1178 "end": "2024-01-02T00:00:00Z"
1179 },
1180 "queues": ["email", "data-processing"],
1181 "granularity": "hour"
1182 }"#;
1183
1184 let query: StatsQuery = serde_json::from_str(json).unwrap();
1185 assert!(query.time_range.is_some());
1186 assert_eq!(query.queues.as_ref().unwrap().len(), 2);
1187 assert_eq!(query.granularity, Some("hour".to_string()));
1188 }
1189
1190 #[tokio::test]
1192 async fn test_stats_query_decodes_from_a_query_string() {
1193 let decode = |query: &str| {
1194 let path = format!("/stats?{query}");
1195 async move {
1196 warp::test::request()
1197 .path(&path)
1198 .filter(&warp::query::<StatsQuery>())
1199 .await
1200 }
1201 };
1202 let query = decode(
1203 "queues=email,%20reports,&granularity=day\
1204 &time_range=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z",
1205 )
1206 .await
1207 .unwrap();
1208 assert_eq!(query.queues.unwrap(), vec!["email", "reports"]);
1209 assert_eq!(query.granularity.as_deref(), Some("day"));
1210 let range = query.time_range.unwrap();
1211 assert_eq!(range.start.to_rfc3339(), "2024-01-01T00:00:00+00:00");
1212 assert_eq!(range.end - range.start, chrono::Duration::days(1));
1213
1214 let bare = decode("hours=6").await.unwrap();
1215 assert!(bare.time_range.is_none() && bare.queues.is_none());
1216 assert!(decode("time_range=yesterday").await.is_err());
1217 assert!(decode("time_range=2024-01-01T00:00:00Z").await.is_err());
1218 }
1219
1220 #[tokio::test]
1221 async fn test_stats_routes_accept_query_string_filters() {
1222 let routes = routes(Arc::new(hammerwork::queue::test::TestQueue::new()), state());
1223 let status = |path: String| {
1224 let routes = routes.clone();
1225 async move {
1226 warp::test::request()
1227 .path(&path)
1228 .reply(&routes)
1229 .await
1230 .status()
1231 .as_u16()
1232 }
1233 };
1234 let range = "time_range=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z";
1235 assert_eq!(
1236 status(format!("/stats/trends?{range}&queues=a,b&granularity=week")).await,
1237 200
1238 );
1239 assert_eq!(
1240 status("/stats/trends?granularity=month".to_string()).await,
1241 400
1242 );
1243 assert_eq!(
1244 status("/stats/trends?time_range=2024-01-02T00:00:00Z,2024-01-01T00:00:00Z".into())
1245 .await,
1246 400
1247 );
1248 }
1249
1250 #[test]
1251 fn test_roll_up_merges_hours_into_days_and_weeks() {
1252 let at = |day: u32, hour: u32| {
1253 chrono::Utc
1254 .with_ymd_and_hms(2024, 1, day, hour, 0, 0)
1255 .unwrap()
1256 };
1257 let trend = |hour, completed, failed, avg| HourlyTrend {
1258 hour,
1259 completed,
1260 failed,
1261 throughput: 0.0,
1262 avg_processing_time_ms: avg,
1263 error_rate: 0.0,
1264 };
1265 let hours = || {
1267 vec![
1268 trend(at(7, 22), 1, 0, Some(100.0)),
1269 trend(at(7, 23), 3, 1, Some(200.0)),
1270 trend(at(8, 0), 0, 0, None),
1271 ]
1272 };
1273
1274 assert_eq!(roll_up(hours(), Granularity::Hour).len(), 3);
1275
1276 let days = roll_up(hours(), Granularity::Day);
1277 assert_eq!(days.len(), 2);
1278 assert_eq!(days[0].hour, at(7, 0));
1279 assert_eq!((days[0].completed, days[0].failed), (4, 1));
1280 assert!((days[0].avg_processing_time_ms.unwrap() - 175.0).abs() < 1e-9);
1281 assert!((days[0].throughput - 5.0 / 7200.0).abs() < 1e-12);
1282 assert!((days[0].error_rate - 0.2).abs() < 1e-12);
1283 assert_eq!(days[1].hour, at(8, 0));
1284 assert_eq!(days[1].avg_processing_time_ms, None);
1285
1286 let weeks = roll_up(hours(), Granularity::Week);
1287 assert_eq!(
1288 weeks.len(),
1289 2,
1290 "the Sunday and the Monday are in different weeks"
1291 );
1292 assert_eq!(weeks[0].hour, at(1, 0), "Monday the 1st");
1293 assert_eq!(weeks[1].hour, at(8, 0));
1294 }
1295
1296 #[test]
1297 fn test_granularity_and_queue_filters() {
1298 assert_eq!(Granularity::parse(None), Ok(Granularity::Hour));
1299 assert_eq!(Granularity::parse(Some(" DAY ")), Ok(Granularity::Day));
1300 assert!(Granularity::parse(Some("month")).is_err());
1301 let all: Option<Vec<String>> = None;
1302 let some = Some(vec!["a".to_string()]);
1303 assert!(queue_selected(&all, "x") && queue_selected(&Some(vec![]), "x"));
1304 assert!(queue_selected(&some, "a") && !queue_selected(&some, "b"));
1305 }
1306
1307 #[test]
1308 fn test_system_alert_serialization() {
1309 let alert = SystemAlert {
1310 severity: "warning".to_string(),
1311 message: "High error rate detected".to_string(),
1312 queue: Some("email".to_string()),
1313 metric: Some("error_rate".to_string()),
1314 value: Some(0.15),
1315 threshold: Some(0.1),
1316 timestamp: chrono::Utc::now(),
1317 };
1318
1319 let json = serde_json::to_string(&alert).unwrap();
1320 assert!(json.contains("warning"));
1321 assert!(json.contains("High error rate"));
1322 }
1323}