Skip to main content

oximedia_workflow/
workflow_dashboard.rs

1//! Workflow dashboard data provider.
2//!
3//! Aggregates metrics from the workflow orchestration engine into a structured
4//! snapshot suitable for rendering in a web UI or reporting tool. The dashboard
5//! provider collects per-workflow and global statistics, task-level health
6//! metrics, queue depths, error histograms, and throughput windows — all
7//! without assuming any particular frontend technology.
8
9use crate::task::TaskState;
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12
13// ---------------------------------------------------------------------------
14// WorkflowStatus summary
15// ---------------------------------------------------------------------------
16
17/// Aggregate status counts for a single workflow instance.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct WorkflowStatusSummary {
20    /// Workflow identifier (string to remain portable).
21    pub workflow_id: String,
22    /// Human-readable name.
23    pub name: String,
24    /// Current high-level state label (e.g. "running", "completed", "failed").
25    pub state: String,
26    /// Total tasks in this workflow.
27    pub total_tasks: usize,
28    /// Tasks that have completed successfully.
29    pub completed_tasks: usize,
30    /// Tasks currently executing.
31    pub running_tasks: usize,
32    /// Tasks waiting for dependencies.
33    pub pending_tasks: usize,
34    /// Tasks that failed.
35    pub failed_tasks: usize,
36    /// Tasks that were skipped.
37    pub skipped_tasks: usize,
38    /// Progress percentage `[0.0, 100.0]`.
39    pub progress_pct: f64,
40    /// Unix timestamp (seconds) when the workflow started, if known.
41    pub started_at_secs: Option<u64>,
42    /// Elapsed wall-clock seconds since start, if known.
43    pub elapsed_secs: Option<u64>,
44    /// Estimated seconds remaining, if available.
45    pub eta_secs: Option<u64>,
46}
47
48impl WorkflowStatusSummary {
49    /// Compute progress percentage from task counts.
50    ///
51    /// Considers completed + failed + skipped as "done".
52    #[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// ---------------------------------------------------------------------------
62// TaskHealthEntry
63// ---------------------------------------------------------------------------
64
65/// Per-task health record for dashboard display.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct TaskHealthEntry {
68    /// Task name.
69    pub task_name: String,
70    /// Current state.
71    pub state: String,
72    /// Number of retry attempts so far.
73    pub retry_count: u32,
74    /// Last error message, if any.
75    pub last_error: Option<String>,
76    /// Average duration in seconds over recent runs.
77    pub avg_duration_secs: f64,
78    /// Whether the task is considered healthy (no errors, low retry count).
79    pub healthy: bool,
80}
81
82impl TaskHealthEntry {
83    /// Construct a health entry from raw data.
84    #[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// ---------------------------------------------------------------------------
105// ErrorHistogram
106// ---------------------------------------------------------------------------
107
108/// Bucketed error counts grouped by error category.
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct ErrorHistogram {
111    /// Map from error category string to count.
112    pub buckets: HashMap<String, u64>,
113    /// Total errors across all categories.
114    pub total: u64,
115}
116
117impl ErrorHistogram {
118    /// Create an empty histogram.
119    #[must_use]
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// Increment the counter for `category`.
125    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    /// Return the most common error category, if any.
132    #[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    /// Return sorted entries from most to least frequent.
141    #[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// ---------------------------------------------------------------------------
151// ThroughputWindow
152// ---------------------------------------------------------------------------
153
154/// Rolling-window throughput statistics.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ThroughputWindow {
157    /// Window duration in seconds.
158    pub window_secs: u64,
159    /// Tasks completed within the window.
160    pub completed_in_window: u64,
161    /// Tasks failed within the window.
162    pub failed_in_window: u64,
163    /// Tasks per second (completed / window_secs).
164    pub tasks_per_second: f64,
165    /// Success rate within the window `[0.0, 1.0]`.
166    pub success_rate: f64,
167}
168
169impl ThroughputWindow {
170    /// Compute a throughput window from raw counts.
171    #[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// ---------------------------------------------------------------------------
195// QueueDepthSnapshot
196// ---------------------------------------------------------------------------
197
198/// Current depth of each priority tier in the task queue.
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200pub struct QueueDepthSnapshot {
201    /// Critical priority tasks.
202    pub critical: usize,
203    /// High priority tasks.
204    pub high: usize,
205    /// Normal priority tasks.
206    pub normal: usize,
207    /// Low priority tasks.
208    pub low: usize,
209}
210
211impl QueueDepthSnapshot {
212    /// Total tasks across all priority tiers.
213    #[must_use]
214    pub fn total(&self) -> usize {
215        self.critical + self.high + self.normal + self.low
216    }
217}
218
219// ---------------------------------------------------------------------------
220// DashboardSnapshot
221// ---------------------------------------------------------------------------
222
223/// A point-in-time snapshot of the full dashboard state.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct DashboardSnapshot {
226    /// Snapshot timestamp (seconds since Unix epoch).
227    pub timestamp_secs: u64,
228    /// Overall counts.
229    pub global: GlobalCounters,
230    /// Per-workflow status summaries.
231    pub workflows: Vec<WorkflowStatusSummary>,
232    /// Task health entries.
233    pub task_health: Vec<TaskHealthEntry>,
234    /// Error distribution.
235    pub error_histogram: ErrorHistogram,
236    /// Short-window throughput (last 60 seconds).
237    pub throughput_1m: ThroughputWindow,
238    /// Medium-window throughput (last 300 seconds).
239    pub throughput_5m: ThroughputWindow,
240    /// Current queue depth.
241    pub queue_depth: QueueDepthSnapshot,
242    /// Custom metric key-values for extension.
243    pub custom_metrics: HashMap<String, serde_json::Value>,
244}
245
246/// Global workflow engine counters.
247#[derive(Debug, Clone, Default, Serialize, Deserialize)]
248pub struct GlobalCounters {
249    /// Total workflows submitted since start.
250    pub total_workflows: u64,
251    /// Workflows currently running.
252    pub running_workflows: u64,
253    /// Workflows completed successfully (all time).
254    pub completed_workflows: u64,
255    /// Workflows that failed (all time).
256    pub failed_workflows: u64,
257    /// Total tasks executed (all time).
258    pub total_tasks_executed: u64,
259    /// Tasks currently running.
260    pub running_tasks: u64,
261}
262
263impl GlobalCounters {
264    /// Overall workflow success rate `[0.0, 1.0]`.
265    #[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// ---------------------------------------------------------------------------
276// DashboardDataProvider
277// ---------------------------------------------------------------------------
278
279/// Aggregates metrics from workflow engine state into `DashboardSnapshot`s.
280///
281/// The provider holds an internal event log and counters that are updated
282/// as workflows and tasks progress. Call [`DashboardDataProvider::snapshot`]
283/// to obtain a consistent read of all metrics at a given timestamp.
284#[derive(Debug, Default)]
285pub struct DashboardDataProvider {
286    /// Global counters.
287    counters: GlobalCounters,
288    /// Per-workflow status records (workflow_id → summary).
289    workflow_records: HashMap<String, WorkflowStatusSummary>,
290    /// Per-task health records (task_name → entry).
291    task_health: HashMap<String, TaskHealthEntry>,
292    /// Error histogram.
293    error_histogram: ErrorHistogram,
294    /// Completed event timestamps (unix secs) for throughput windows.
295    completed_events: Vec<u64>,
296    /// Failed event timestamps (unix secs) for throughput windows.
297    failed_events: Vec<u64>,
298    /// Current queue depth.
299    queue_depth: QueueDepthSnapshot,
300    /// Custom metrics.
301    custom_metrics: HashMap<String, serde_json::Value>,
302    /// Maximum event history to retain.
303    max_event_history: usize,
304}
305
306impl DashboardDataProvider {
307    /// Create a new dashboard data provider with defaults.
308    #[must_use]
309    pub fn new() -> Self {
310        Self {
311            max_event_history: 10_000,
312            ..Default::default()
313        }
314    }
315
316    /// Register a workflow starting.
317    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    /// Record a workflow completing (successfully or otherwise).
347    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    /// Record a task completing.
367    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        // Update workflow record
389        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            // Compute ETA from elapsed and progress
399            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        // Update task health
410        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            // Running average: (prev * n + new) / (n+1) — use simple EWMA with α=0.3
432            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    /// Record a task failure with an error message.
438    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    /// Update the queue depth snapshot.
459    pub fn update_queue_depth(&mut self, snapshot: QueueDepthSnapshot) {
460        self.queue_depth = snapshot;
461    }
462
463    /// Set a custom metric value.
464    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    /// Build a `DashboardSnapshot` at the given timestamp.
469    #[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    /// Compute throughput window metrics for the given window in seconds.
488    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    /// Trim event history older than 10 minutes to bound memory usage.
500    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        // Also enforce max_event_history
506        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// ---------------------------------------------------------------------------
519// Tests
520// ---------------------------------------------------------------------------
521
522#[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        // Complete tasks at t=1050 (within 60-second window at t=1100)
674        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        // All events are within 60s window
681        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}