Skip to main content

termivar_scanner/
dashboard.rs

1//! Dashboard presentation records.
2//!
3//! ## Runtime scope
4//!
5//! - **Build:** opt-in via `platform-models`.
6//! - **Execution:** no repository runtime caller (not on the default scan path).
7//! - **Default `termivar scan`:** no.
8//! - **Support:** experimental/scaffold.
9//!
10//! See `docs/internals/runtime-map.md`.
11//!
12//! This module contains caller-supplied presentation records only. It does not
13//! collect scan state, retain dashboard history, or run a dashboard service.
14
15use serde::{Deserialize, Serialize};
16
17/// Caller-supplied counts for a scan overview.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct DashboardOverview {
20    pub total_scans: u64,
21    pub active_scans: u64,
22    pub completed_scans: u64,
23    pub failed_scans: u64,
24    pub total_findings: u64,
25    pub critical_findings: u64,
26    pub high_findings: u64,
27    pub average_completed_scan_duration_secs: Option<u64>,
28}
29
30impl DashboardOverview {
31    /// Returns `completed_scans / total_scans` only for a non-empty,
32    /// internally consistent overview.
33    ///
34    /// `None` means that the denominator is zero, an addition overflowed, or
35    /// the active, completed, and failed counts do not account for the stated
36    /// total. A returned ratio is therefore always in the inclusive `0..=1`
37    /// range.
38    pub fn completion_ratio(&self) -> Option<f64> {
39        let accounted_scans = self
40            .active_scans
41            .checked_add(self.completed_scans)?
42            .checked_add(self.failed_scans)?;
43
44        if self.total_scans == 0 || accounted_scans != self.total_scans {
45            return None;
46        }
47
48        Some(self.completed_scans as f64 / self.total_scans as f64)
49    }
50}
51
52/// Caller-supplied timeline data for a chart.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct TimelineEntry {
55    pub timestamp: u64,
56    pub scans_completed: u64,
57    pub findings_discovered: u64,
58    pub critical_count: u64,
59    pub high_count: u64,
60}
61
62/// Caller-supplied severity counts for a chart.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct SeverityDistribution {
65    pub critical: u64,
66    pub high: u64,
67    pub medium: u64,
68    pub low: u64,
69}
70
71impl SeverityDistribution {
72    /// Returns the total when the supplied counts can be added without
73    /// overflowing.
74    pub fn checked_total(&self) -> Option<u64> {
75        self.critical
76            .checked_add(self.high)?
77            .checked_add(self.medium)?
78            .checked_add(self.low)
79    }
80}
81
82/// Caller-supplied finding data for presentation.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct FindingCard {
85    pub finding_id: String,
86    pub scan_id: String,
87    pub phase: u8,
88    pub module: String,
89    pub severity: String,
90    pub description: String,
91    pub discovered_at: u64,
92    pub status: FindingStatus,
93}
94
95/// Review status supplied for a finding card.
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97pub enum FindingStatus {
98    #[serde(rename = "new")]
99    New,
100    #[serde(rename = "review")]
101    Review,
102    #[serde(rename = "confirmed")]
103    Confirmed,
104    #[serde(rename = "false_positive")]
105    FalsePositive,
106    #[serde(rename = "resolved")]
107    Resolved,
108}
109
110impl FindingStatus {
111    pub fn as_str(self) -> &'static str {
112        match self {
113            Self::New => "new",
114            Self::Review => "review",
115            Self::Confirmed => "confirmed",
116            Self::FalsePositive => "false_positive",
117            Self::Resolved => "resolved",
118        }
119    }
120}
121
122/// Caller-supplied scan data for presentation.
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124pub struct ScanCard {
125    pub scan_id: String,
126    pub target: String,
127    pub status: String,
128    pub findings: u64,
129    pub critical: u64,
130    pub high: u64,
131    pub started_at: u64,
132    pub completed_at: Option<u64>,
133    pub duration_secs: Option<u64>,
134}
135
136/// Caller-supplied dashboard widget description.
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
138pub struct DashboardWidget {
139    pub id: String,
140    pub title: String,
141    pub widget_type: WidgetType,
142    pub data: serde_json::Value,
143    pub refresh_interval_secs: u32,
144}
145
146/// Widget presentation type.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148pub enum WidgetType {
149    #[serde(rename = "kpi")]
150    Kpi,
151    #[serde(rename = "chart")]
152    Chart,
153    #[serde(rename = "timeline")]
154    Timeline,
155    #[serde(rename = "distribution")]
156    Distribution,
157    #[serde(rename = "table")]
158    Table,
159}
160
161impl WidgetType {
162    pub fn as_str(self) -> &'static str {
163        match self {
164            Self::Kpi => "kpi",
165            Self::Chart => "chart",
166            Self::Timeline => "timeline",
167            Self::Distribution => "distribution",
168            Self::Table => "table",
169        }
170    }
171}
172
173/// Caller-supplied dashboard presentation configuration.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct DashboardConfig {
176    pub title: String,
177    pub refresh_interval_secs: u32,
178    pub widgets: Vec<String>,
179    pub theme: String,
180    pub timezone: String,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn overview() -> DashboardOverview {
188        DashboardOverview {
189            total_scans: 10,
190            active_scans: 1,
191            completed_scans: 8,
192            failed_scans: 1,
193            total_findings: 50,
194            critical_findings: 5,
195            high_findings: 15,
196            average_completed_scan_duration_secs: Some(300),
197        }
198    }
199
200    #[test]
201    fn completion_ratio_is_bounded_for_consistent_counts() {
202        let ratio = overview()
203            .completion_ratio()
204            .expect("counts are consistent");
205
206        assert_eq!(ratio, 0.8);
207        assert!((0.0..=1.0).contains(&ratio));
208    }
209
210    #[test]
211    fn completion_ratio_rejects_empty_or_inconsistent_counts() {
212        let mut supplied = overview();
213        supplied.total_scans = 0;
214        supplied.active_scans = 0;
215        supplied.completed_scans = 0;
216        supplied.failed_scans = 0;
217        assert_eq!(supplied.completion_ratio(), None);
218
219        supplied.total_scans = 3;
220        supplied.completed_scans = 4;
221        assert_eq!(supplied.completion_ratio(), None);
222    }
223
224    #[test]
225    fn completion_ratio_rejects_count_overflow() {
226        let supplied = DashboardOverview {
227            total_scans: u64::MAX,
228            active_scans: u64::MAX,
229            completed_scans: 1,
230            failed_scans: 0,
231            total_findings: 0,
232            critical_findings: 0,
233            high_findings: 0,
234            average_completed_scan_duration_secs: None,
235        };
236
237        assert_eq!(supplied.completion_ratio(), None);
238    }
239
240    #[test]
241    fn severity_total_is_checked() {
242        let supplied = SeverityDistribution {
243            critical: 10,
244            high: 20,
245            medium: 30,
246            low: 40,
247        };
248        assert_eq!(supplied.checked_total(), Some(100));
249
250        let overflowing = SeverityDistribution {
251            critical: u64::MAX,
252            high: 1,
253            medium: 0,
254            low: 0,
255        };
256        assert_eq!(overflowing.checked_total(), None);
257    }
258
259    #[test]
260    fn presentation_enums_have_stable_labels() {
261        for (status, label) in [
262            (FindingStatus::New, "new"),
263            (FindingStatus::Review, "review"),
264            (FindingStatus::Confirmed, "confirmed"),
265            (FindingStatus::FalsePositive, "false_positive"),
266            (FindingStatus::Resolved, "resolved"),
267        ] {
268            assert_eq!(status.as_str(), label);
269        }
270        for (widget, label) in [
271            (WidgetType::Kpi, "kpi"),
272            (WidgetType::Chart, "chart"),
273            (WidgetType::Timeline, "timeline"),
274            (WidgetType::Distribution, "distribution"),
275            (WidgetType::Table, "table"),
276        ] {
277            assert_eq!(widget.as_str(), label);
278        }
279    }
280}