1use crate::task::TaskState;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WorkflowStatusSummary {
20 pub workflow_id: String,
22 pub name: String,
24 pub state: String,
26 pub total_tasks: usize,
28 pub completed_tasks: usize,
30 pub running_tasks: usize,
32 pub pending_tasks: usize,
34 pub failed_tasks: usize,
36 pub skipped_tasks: usize,
38 pub progress_pct: f64,
40 pub started_at_secs: Option<u64>,
42 pub elapsed_secs: Option<u64>,
44 pub eta_secs: Option<u64>,
46}
47
48impl WorkflowStatusSummary {
49 #[must_use]
53 pub fn compute_progress(done: usize, total: usize) -> f64 {
54 if total == 0 {
55 return 100.0;
56 }
57 ((done as f64) / (total as f64) * 100.0).min(100.0)
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct TaskHealthEntry {
68 pub task_name: String,
70 pub state: String,
72 pub retry_count: u32,
74 pub last_error: Option<String>,
76 pub avg_duration_secs: f64,
78 pub healthy: bool,
80}
81
82impl TaskHealthEntry {
83 #[must_use]
85 pub fn new(
86 task_name: impl Into<String>,
87 state: TaskState,
88 retry_count: u32,
89 last_error: Option<String>,
90 avg_duration_secs: f64,
91 ) -> Self {
92 let healthy = !matches!(state, TaskState::Failed) && retry_count < 3;
93 Self {
94 task_name: task_name.into(),
95 state: format!("{state:?}"),
96 retry_count,
97 last_error,
98 avg_duration_secs,
99 healthy,
100 }
101 }
102}
103
104#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ErrorHistogram {
111 pub buckets: HashMap<String, u64>,
113 pub total: u64,
115}
116
117impl ErrorHistogram {
118 #[must_use]
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn record(&mut self, category: impl Into<String>) {
126 let count = self.buckets.entry(category.into()).or_insert(0);
127 *count += 1;
128 self.total += 1;
129 }
130
131 #[must_use]
133 pub fn top_category(&self) -> Option<(&str, u64)> {
134 self.buckets
135 .iter()
136 .max_by_key(|(_, &v)| v)
137 .map(|(k, &v)| (k.as_str(), v))
138 }
139
140 #[must_use]
142 pub fn sorted_entries(&self) -> Vec<(&str, u64)> {
143 let mut entries: Vec<(&str, u64)> =
144 self.buckets.iter().map(|(k, &v)| (k.as_str(), v)).collect();
145 entries.sort_by(|a, b| b.1.cmp(&a.1));
146 entries
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ThroughputWindow {
157 pub window_secs: u64,
159 pub completed_in_window: u64,
161 pub failed_in_window: u64,
163 pub tasks_per_second: f64,
165 pub success_rate: f64,
167}
168
169impl ThroughputWindow {
170 #[must_use]
172 pub fn compute(completed: u64, failed: u64, window_secs: u64) -> Self {
173 let tasks_per_second = if window_secs > 0 {
174 completed as f64 / window_secs as f64
175 } else {
176 0.0
177 };
178 let total = completed + failed;
179 let success_rate = if total > 0 {
180 completed as f64 / total as f64
181 } else {
182 1.0
183 };
184 Self {
185 window_secs,
186 completed_in_window: completed,
187 failed_in_window: failed,
188 tasks_per_second,
189 success_rate,
190 }
191 }
192}
193
194#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct QueueDepthSnapshot {
201 pub critical: usize,
203 pub high: usize,
205 pub normal: usize,
207 pub low: usize,
209}
210
211impl QueueDepthSnapshot {
212 #[must_use]
214 pub fn total(&self) -> usize {
215 self.critical + self.high + self.normal + self.low
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct DashboardSnapshot {
226 pub timestamp_secs: u64,
228 pub global: GlobalCounters,
230 pub workflows: Vec<WorkflowStatusSummary>,
232 pub task_health: Vec<TaskHealthEntry>,
234 pub error_histogram: ErrorHistogram,
236 pub throughput_1m: ThroughputWindow,
238 pub throughput_5m: ThroughputWindow,
240 pub queue_depth: QueueDepthSnapshot,
242 pub custom_metrics: HashMap<String, serde_json::Value>,
244}
245
246#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct GlobalCounters {
249 pub total_workflows: u64,
251 pub running_workflows: u64,
253 pub completed_workflows: u64,
255 pub failed_workflows: u64,
257 pub total_tasks_executed: u64,
259 pub running_tasks: u64,
261}
262
263impl GlobalCounters {
264 #[must_use]
266 pub fn workflow_success_rate(&self) -> f64 {
267 let finished = self.completed_workflows + self.failed_workflows;
268 if finished == 0 {
269 return 1.0;
270 }
271 self.completed_workflows as f64 / finished as f64
272 }
273}
274
275#[derive(Debug, Default)]
285pub struct DashboardDataProvider {
286 counters: GlobalCounters,
288 workflow_records: HashMap<String, WorkflowStatusSummary>,
290 task_health: HashMap<String, TaskHealthEntry>,
292 error_histogram: ErrorHistogram,
294 completed_events: Vec<u64>,
296 failed_events: Vec<u64>,
298 queue_depth: QueueDepthSnapshot,
300 custom_metrics: HashMap<String, serde_json::Value>,
302 max_event_history: usize,
304}
305
306impl DashboardDataProvider {
307 #[must_use]
309 pub fn new() -> Self {
310 Self {
311 max_event_history: 10_000,
312 ..Default::default()
313 }
314 }
315
316 pub fn on_workflow_started(
318 &mut self,
319 workflow_id: impl Into<String>,
320 name: impl Into<String>,
321 total_tasks: usize,
322 started_at_secs: u64,
323 ) {
324 self.counters.total_workflows += 1;
325 self.counters.running_workflows += 1;
326
327 let id = workflow_id.into();
328 let summary = WorkflowStatusSummary {
329 workflow_id: id.clone(),
330 name: name.into(),
331 state: "running".to_string(),
332 total_tasks,
333 completed_tasks: 0,
334 running_tasks: 0,
335 pending_tasks: total_tasks,
336 failed_tasks: 0,
337 skipped_tasks: 0,
338 progress_pct: 0.0,
339 started_at_secs: Some(started_at_secs),
340 elapsed_secs: None,
341 eta_secs: None,
342 };
343 self.workflow_records.insert(id, summary);
344 }
345
346 pub fn on_workflow_completed(&mut self, workflow_id: &str, success: bool, now_secs: u64) {
348 if self.counters.running_workflows > 0 {
349 self.counters.running_workflows -= 1;
350 }
351 if success {
352 self.counters.completed_workflows += 1;
353 } else {
354 self.counters.failed_workflows += 1;
355 }
356
357 if let Some(record) = self.workflow_records.get_mut(workflow_id) {
358 record.state = if success { "completed" } else { "failed" }.to_string();
359 record.progress_pct = 100.0;
360 if let Some(start) = record.started_at_secs {
361 record.elapsed_secs = Some(now_secs.saturating_sub(start));
362 }
363 }
364 }
365
366 pub fn on_task_completed(
368 &mut self,
369 workflow_id: &str,
370 task_name: &str,
371 success: bool,
372 duration_secs: f64,
373 now_secs: u64,
374 ) {
375 self.counters.total_tasks_executed += 1;
376 if self.counters.running_tasks > 0 {
377 self.counters.running_tasks -= 1;
378 }
379
380 if success {
381 self.completed_events.push(now_secs);
382 } else {
383 self.failed_events.push(now_secs);
384 self.error_histogram.record(task_name);
385 }
386 self.trim_event_history(now_secs);
387
388 if let Some(record) = self.workflow_records.get_mut(workflow_id) {
390 if success {
391 record.completed_tasks += 1;
392 } else {
393 record.failed_tasks += 1;
394 }
395 let done = record.completed_tasks + record.failed_tasks + record.skipped_tasks;
396 record.progress_pct = WorkflowStatusSummary::compute_progress(done, record.total_tasks);
397
398 if let Some(start) = record.started_at_secs {
400 let elapsed = now_secs.saturating_sub(start);
401 record.elapsed_secs = Some(elapsed);
402 if record.progress_pct > 0.0 && record.progress_pct < 100.0 {
403 let total_estimated = (elapsed as f64 / (record.progress_pct / 100.0)) as u64;
404 record.eta_secs = Some(total_estimated.saturating_sub(elapsed));
405 }
406 }
407 }
408
409 let entry = self
411 .task_health
412 .entry(task_name.to_string())
413 .or_insert_with(|| TaskHealthEntry {
414 task_name: task_name.to_string(),
415 state: "pending".to_string(),
416 retry_count: 0,
417 last_error: None,
418 avg_duration_secs: 0.0,
419 healthy: true,
420 });
421 let state = if success {
422 TaskState::Completed
423 } else {
424 TaskState::Failed
425 };
426 let new_entry = TaskHealthEntry::new(
427 task_name,
428 state,
429 entry.retry_count,
430 entry.last_error.clone(),
431 entry.avg_duration_secs * 0.7 + duration_secs * 0.3,
433 );
434 self.task_health.insert(task_name.to_string(), new_entry);
435 }
436
437 pub fn on_task_failed(&mut self, task_name: &str, error: impl Into<String>, retry_count: u32) {
439 let error_msg = error.into();
440 self.error_histogram.record(task_name);
441 let entry = self
442 .task_health
443 .entry(task_name.to_string())
444 .or_insert_with(|| TaskHealthEntry {
445 task_name: task_name.to_string(),
446 state: "failed".to_string(),
447 retry_count: 0,
448 last_error: None,
449 avg_duration_secs: 0.0,
450 healthy: false,
451 });
452 entry.state = "failed".to_string();
453 entry.last_error = Some(error_msg);
454 entry.retry_count = retry_count;
455 entry.healthy = false;
456 }
457
458 pub fn update_queue_depth(&mut self, snapshot: QueueDepthSnapshot) {
460 self.queue_depth = snapshot;
461 }
462
463 pub fn set_custom_metric(&mut self, key: impl Into<String>, value: serde_json::Value) {
465 self.custom_metrics.insert(key.into(), value);
466 }
467
468 #[must_use]
470 pub fn snapshot(&self, now_secs: u64) -> DashboardSnapshot {
471 let throughput_1m = self.throughput_window(now_secs, 60);
472 let throughput_5m = self.throughput_window(now_secs, 300);
473
474 DashboardSnapshot {
475 timestamp_secs: now_secs,
476 global: self.counters.clone(),
477 workflows: self.workflow_records.values().cloned().collect(),
478 task_health: self.task_health.values().cloned().collect(),
479 error_histogram: self.error_histogram.clone(),
480 throughput_1m,
481 throughput_5m,
482 queue_depth: self.queue_depth.clone(),
483 custom_metrics: self.custom_metrics.clone(),
484 }
485 }
486
487 fn throughput_window(&self, now_secs: u64, window_secs: u64) -> ThroughputWindow {
489 let cutoff = now_secs.saturating_sub(window_secs);
490 let completed = self
491 .completed_events
492 .iter()
493 .filter(|&&t| t >= cutoff)
494 .count() as u64;
495 let failed = self.failed_events.iter().filter(|&&t| t >= cutoff).count() as u64;
496 ThroughputWindow::compute(completed, failed, window_secs)
497 }
498
499 fn trim_event_history(&mut self, now_secs: u64) {
501 let cutoff = now_secs.saturating_sub(600);
502 self.completed_events.retain(|&t| t >= cutoff);
503 self.failed_events.retain(|&t| t >= cutoff);
504
505 let max = self.max_event_history;
507 if self.completed_events.len() > max {
508 let excess = self.completed_events.len() - max;
509 self.completed_events.drain(0..excess);
510 }
511 if self.failed_events.len() > max {
512 let excess = self.failed_events.len() - max;
513 self.failed_events.drain(0..excess);
514 }
515 }
516}
517
518#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn test_compute_progress_zero_total() {
528 assert_eq!(WorkflowStatusSummary::compute_progress(0, 0), 100.0);
529 }
530
531 #[test]
532 fn test_compute_progress_half() {
533 let p = WorkflowStatusSummary::compute_progress(5, 10);
534 assert!((p - 50.0).abs() < 0.001);
535 }
536
537 #[test]
538 fn test_compute_progress_full() {
539 let p = WorkflowStatusSummary::compute_progress(10, 10);
540 assert!((p - 100.0).abs() < 0.001);
541 }
542
543 #[test]
544 fn test_error_histogram_record_and_total() {
545 let mut h = ErrorHistogram::new();
546 h.record("timeout");
547 h.record("timeout");
548 h.record("io_error");
549 assert_eq!(h.total, 3);
550 assert_eq!(h.buckets["timeout"], 2);
551 assert_eq!(h.buckets["io_error"], 1);
552 }
553
554 #[test]
555 fn test_error_histogram_top_category() {
556 let mut h = ErrorHistogram::new();
557 h.record("network");
558 h.record("network");
559 h.record("disk");
560 let top = h.top_category();
561 assert!(top.is_some());
562 assert_eq!(top.expect("should have entry").0, "network");
563 }
564
565 #[test]
566 fn test_error_histogram_sorted_entries() {
567 let mut h = ErrorHistogram::new();
568 h.record("a");
569 h.record("b");
570 h.record("b");
571 h.record("c");
572 h.record("c");
573 h.record("c");
574 let entries = h.sorted_entries();
575 assert_eq!(entries[0].0, "c");
576 assert_eq!(entries[0].1, 3);
577 assert_eq!(entries[1].0, "b");
578 }
579
580 #[test]
581 fn test_throughput_window_compute() {
582 let tw = ThroughputWindow::compute(60, 5, 60);
583 assert!((tw.tasks_per_second - 1.0).abs() < 0.001);
584 let expected_rate = 60.0 / 65.0;
585 assert!((tw.success_rate - expected_rate).abs() < 0.001);
586 }
587
588 #[test]
589 fn test_throughput_window_zero_window() {
590 let tw = ThroughputWindow::compute(10, 0, 0);
591 assert_eq!(tw.tasks_per_second, 0.0);
592 }
593
594 #[test]
595 fn test_throughput_window_all_success() {
596 let tw = ThroughputWindow::compute(10, 0, 60);
597 assert!((tw.success_rate - 1.0).abs() < 0.001);
598 }
599
600 #[test]
601 fn test_queue_depth_total() {
602 let q = QueueDepthSnapshot {
603 critical: 1,
604 high: 2,
605 normal: 3,
606 low: 4,
607 };
608 assert_eq!(q.total(), 10);
609 }
610
611 #[test]
612 fn test_global_counters_success_rate() {
613 let mut g = GlobalCounters::default();
614 g.completed_workflows = 8;
615 g.failed_workflows = 2;
616 assert!((g.workflow_success_rate() - 0.8).abs() < 0.001);
617 }
618
619 #[test]
620 fn test_global_counters_success_rate_empty() {
621 let g = GlobalCounters::default();
622 assert!((g.workflow_success_rate() - 1.0).abs() < 0.001);
623 }
624
625 #[test]
626 fn test_dashboard_provider_workflow_lifecycle() {
627 let mut provider = DashboardDataProvider::new();
628 provider.on_workflow_started("wf-1", "Test Workflow", 4, 1000);
629
630 {
631 let snap = provider.snapshot(1010);
632 assert_eq!(snap.global.total_workflows, 1);
633 assert_eq!(snap.global.running_workflows, 1);
634 assert_eq!(snap.workflows.len(), 1);
635 }
636
637 provider.on_task_completed("wf-1", "encode", true, 2.0, 1010);
638 provider.on_task_completed("wf-1", "qc", true, 1.0, 1015);
639 provider.on_workflow_completed("wf-1", true, 1020);
640
641 let snap = provider.snapshot(1020);
642 assert_eq!(snap.global.completed_workflows, 1);
643 assert_eq!(snap.global.running_workflows, 0);
644 let wf = snap
645 .workflows
646 .iter()
647 .find(|w| w.workflow_id == "wf-1")
648 .expect("workflow in snapshot");
649 assert_eq!(wf.state, "completed");
650 }
651
652 #[test]
653 fn test_dashboard_provider_task_failure() {
654 let mut provider = DashboardDataProvider::new();
655 provider.on_workflow_started("wf-2", "Failing WF", 2, 2000);
656 provider.on_task_failed("transcode", "codec error", 1);
657
658 let snap = provider.snapshot(2010);
659 let health = snap
660 .task_health
661 .iter()
662 .find(|h| h.task_name == "transcode")
663 .expect("health entry");
664 assert!(!health.healthy);
665 assert_eq!(health.retry_count, 1);
666 assert!(health.last_error.is_some());
667 }
668
669 #[test]
670 fn test_dashboard_provider_throughput_window() {
671 let mut provider = DashboardDataProvider::new();
672 provider.on_workflow_started("wf-3", "Throughput Test", 5, 1000);
673 for _ in 0..3 {
675 provider.on_task_completed("wf-3", "task", true, 1.0, 1050);
676 }
677 provider.on_task_completed("wf-3", "task2", false, 1.0, 1050);
678
679 let snap = provider.snapshot(1100);
680 assert_eq!(snap.throughput_1m.completed_in_window, 3);
682 assert_eq!(snap.throughput_1m.failed_in_window, 1);
683 }
684
685 #[test]
686 fn test_dashboard_provider_custom_metrics() {
687 let mut provider = DashboardDataProvider::new();
688 provider.set_custom_metric("gpu_utilization", serde_json::json!(0.85));
689
690 let snap = provider.snapshot(5000);
691 assert_eq!(
692 snap.custom_metrics.get("gpu_utilization"),
693 Some(&serde_json::json!(0.85))
694 );
695 }
696
697 #[test]
698 fn test_queue_depth_update() {
699 let mut provider = DashboardDataProvider::new();
700 provider.update_queue_depth(QueueDepthSnapshot {
701 critical: 2,
702 high: 5,
703 normal: 10,
704 low: 1,
705 });
706
707 let snap = provider.snapshot(1000);
708 assert_eq!(snap.queue_depth.total(), 18);
709 assert_eq!(snap.queue_depth.critical, 2);
710 }
711
712 #[test]
713 fn test_task_health_entry_healthy() {
714 let entry = TaskHealthEntry::new("encode", TaskState::Completed, 0, None, 2.5);
715 assert!(entry.healthy);
716 }
717
718 #[test]
719 fn test_task_health_entry_unhealthy_failed() {
720 let entry = TaskHealthEntry::new(
721 "encode",
722 TaskState::Failed,
723 0,
724 Some("timeout".to_string()),
725 2.5,
726 );
727 assert!(!entry.healthy);
728 }
729
730 #[test]
731 fn test_task_health_entry_unhealthy_many_retries() {
732 let entry = TaskHealthEntry::new("encode", TaskState::Running, 5, None, 2.5);
733 assert!(!entry.healthy);
734 }
735
736 #[test]
737 fn test_dashboard_snapshot_serialization() {
738 let mut provider = DashboardDataProvider::new();
739 provider.on_workflow_started("wf-s", "Serialize Test", 2, 9000);
740 let snap = provider.snapshot(9010);
741 let json = serde_json::to_string(&snap).expect("serialize");
742 let _decoded: DashboardSnapshot = serde_json::from_str(&json).expect("deserialize");
743 }
744}