1use serde::{Deserialize, Serialize};
16
17#[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 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#[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#[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 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#[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#[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#[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#[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#[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#[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}