1use super::escape::{escape_html, escape_html_opt};
4use super::{ReportConfig, ReportError, ReportFormat, ReportGenerator, ReportType};
5use crate::diff::{DiffResult, SlaStatus, VulnerabilityDetail};
6use crate::model::NormalizedSbom;
7use std::fmt::Write;
8
9type VulnRow<'a> = (&'a str, &'a Option<crate::model::Severity>, Option<f32>, &'a str, Option<&'a str>);
11
12pub struct HtmlReporter {
14 include_styles: bool,
16}
17
18impl HtmlReporter {
19 pub fn new() -> Self {
21 Self {
22 include_styles: true,
23 }
24 }
25
26 fn get_styles(&self) -> &'static str {
27 r#"
28 <style>
29 :root {
30 --bg-color: #1e1e2e;
31 --text-color: #cdd6f4;
32 --accent-color: #89b4fa;
33 --success-color: #a6e3a1;
34 --warning-color: #f9e2af;
35 --error-color: #f38ba8;
36 --border-color: #45475a;
37 --card-bg: #313244;
38 }
39
40 body {
41 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
42 background-color: var(--bg-color);
43 color: var(--text-color);
44 margin: 0;
45 padding: 20px;
46 line-height: 1.6;
47 }
48
49 .container {
50 max-width: 1200px;
51 margin: 0 auto;
52 }
53
54 h1, h2, h3 {
55 color: var(--accent-color);
56 }
57
58 .header {
59 border-bottom: 2px solid var(--border-color);
60 padding-bottom: 20px;
61 margin-bottom: 30px;
62 }
63
64 .summary-cards {
65 display: grid;
66 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
67 gap: 20px;
68 margin-bottom: 30px;
69 }
70
71 .card {
72 background-color: var(--card-bg);
73 border-radius: 8px;
74 padding: 20px;
75 border: 1px solid var(--border-color);
76 }
77
78 .card-title {
79 font-size: 0.9em;
80 color: #a6adc8;
81 margin-bottom: 10px;
82 }
83
84 .card-value {
85 font-size: 2em;
86 font-weight: bold;
87 }
88
89 .card-value.added { color: var(--success-color); }
90 .card-value.removed { color: var(--error-color); }
91 .card-value.modified { color: var(--warning-color); }
92 .card-value.critical { color: var(--error-color); }
93
94 table {
95 width: 100%;
96 border-collapse: collapse;
97 margin-bottom: 30px;
98 background-color: var(--card-bg);
99 border-radius: 8px;
100 overflow: hidden;
101 }
102
103 th, td {
104 padding: 12px 15px;
105 text-align: left;
106 border-bottom: 1px solid var(--border-color);
107 }
108
109 th {
110 background-color: #45475a;
111 font-weight: 600;
112 }
113
114 tr:hover {
115 background-color: #3b3d4d;
116 }
117
118 .badge {
119 display: inline-block;
120 padding: 2px 8px;
121 border-radius: 4px;
122 font-size: 0.85em;
123 font-weight: 500;
124 }
125
126 .badge-added { background-color: rgba(166, 227, 161, 0.2); color: var(--success-color); }
127 .badge-removed { background-color: rgba(243, 139, 168, 0.2); color: var(--error-color); }
128 .badge-modified { background-color: rgba(249, 226, 175, 0.2); color: var(--warning-color); }
129 .badge-critical { background-color: rgba(243, 139, 168, 0.3); color: var(--error-color); }
130 .badge-high { background-color: rgba(250, 179, 135, 0.3); color: #fab387; }
131 .badge-medium { background-color: rgba(249, 226, 175, 0.3); color: var(--warning-color); }
132 .badge-low { background-color: rgba(148, 226, 213, 0.3); color: #94e2d5; }
133 .badge-direct { background-color: rgba(46, 160, 67, 0.3); color: #2ea043; }
134 .badge-transitive { background-color: rgba(110, 118, 129, 0.3); color: #6e7681; }
135 .sla-overdue { background-color: rgba(248, 81, 73, 0.2); color: #f85149; font-weight: bold; }
136 .sla-due-soon { background-color: rgba(227, 179, 65, 0.2); color: #e3b341; }
137 .sla-on-track { color: #8b949e; }
138 .sla-unknown { color: #8b949e; }
139
140 .section {
141 margin-bottom: 40px;
142 }
143
144 .tabs {
145 display: flex;
146 border-bottom: 2px solid var(--border-color);
147 margin-bottom: 20px;
148 }
149
150 .tab {
151 padding: 10px 20px;
152 cursor: pointer;
153 border-bottom: 2px solid transparent;
154 margin-bottom: -2px;
155 }
156
157 .tab:hover {
158 color: var(--accent-color);
159 }
160
161 .tab.active {
162 border-bottom-color: var(--accent-color);
163 color: var(--accent-color);
164 }
165
166 .footer {
167 margin-top: 40px;
168 padding-top: 20px;
169 border-top: 1px solid var(--border-color);
170 font-size: 0.9em;
171 color: #a6adc8;
172 }
173 </style>
174 "#
175 }
176}
177
178impl Default for HtmlReporter {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl ReportGenerator for HtmlReporter {
185 fn generate_diff_report(
186 &self,
187 result: &DiffResult,
188 _old_sbom: &NormalizedSbom,
189 _new_sbom: &NormalizedSbom,
190 config: &ReportConfig,
191 ) -> Result<String, ReportError> {
192 let mut html = String::new();
193
194 let title = config
195 .title
196 .clone()
197 .unwrap_or_else(|| "SBOM Diff Report".to_string());
198
199 writeln!(html, "<!DOCTYPE html>")?;
201 writeln!(html, "<html lang=\"en\">")?;
202 writeln!(html, "<head>")?;
203 writeln!(html, " <meta charset=\"UTF-8\">")?;
204 writeln!(
205 html,
206 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
207 )?;
208 writeln!(html, " <title>{}</title>", escape_html(&title))?;
209 if self.include_styles {
210 writeln!(html, "{}", self.get_styles())?;
211 }
212 writeln!(html, "</head>")?;
213 writeln!(html, "<body>")?;
214 writeln!(html, "<div class=\"container\">")?;
215
216 writeln!(html, "<div class=\"header\">")?;
218 writeln!(html, " <h1>{}</h1>", escape_html(&title))?;
219 writeln!(
220 html,
221 " <p>Generated by sbom-tools v{} on {}</p>",
222 env!("CARGO_PKG_VERSION"),
223 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
224 )?;
225 writeln!(html, "</div>")?;
226
227 writeln!(html, "<div class=\"summary-cards\">")?;
229 writeln!(html, " <div class=\"card\">")?;
230 writeln!(
231 html,
232 " <div class=\"card-title\">Components Added</div>"
233 )?;
234 writeln!(
235 html,
236 " <div class=\"card-value added\">+{}</div>",
237 result.summary.components_added
238 )?;
239 writeln!(html, " </div>")?;
240
241 writeln!(html, " <div class=\"card\">")?;
242 writeln!(
243 html,
244 " <div class=\"card-title\">Components Removed</div>"
245 )?;
246 writeln!(
247 html,
248 " <div class=\"card-value removed\">-{}</div>",
249 result.summary.components_removed
250 )?;
251 writeln!(html, " </div>")?;
252
253 writeln!(html, " <div class=\"card\">")?;
254 writeln!(
255 html,
256 " <div class=\"card-title\">Components Modified</div>"
257 )?;
258 writeln!(
259 html,
260 " <div class=\"card-value modified\">~{}</div>",
261 result.summary.components_modified
262 )?;
263 writeln!(html, " </div>")?;
264
265 writeln!(html, " <div class=\"card\">")?;
266 writeln!(
267 html,
268 " <div class=\"card-title\">Vulns Introduced</div>"
269 )?;
270 writeln!(
271 html,
272 " <div class=\"card-value critical\">{}</div>",
273 result.summary.vulnerabilities_introduced
274 )?;
275 writeln!(html, " </div>")?;
276
277 writeln!(html, " <div class=\"card\">")?;
278 writeln!(
279 html,
280 " <div class=\"card-title\">Semantic Score</div>"
281 )?;
282 writeln!(
283 html,
284 " <div class=\"card-value\">{:.1}</div>",
285 result.semantic_score
286 )?;
287 writeln!(html, " </div>")?;
288 writeln!(html, "</div>")?;
289
290 if config.includes(ReportType::Components) && !result.components.is_empty() {
292 writeln!(html, "<div class=\"section\">")?;
293 writeln!(html, " <h2>Component Changes</h2>")?;
294 writeln!(html, " <table>")?;
295 writeln!(html, " <thead>")?;
296 writeln!(html, " <tr>")?;
297 writeln!(html, " <th>Status</th>")?;
298 writeln!(html, " <th>Name</th>")?;
299 writeln!(html, " <th>Old Version</th>")?;
300 writeln!(html, " <th>New Version</th>")?;
301 writeln!(html, " <th>Ecosystem</th>")?;
302 writeln!(html, " </tr>")?;
303 writeln!(html, " </thead>")?;
304 writeln!(html, " <tbody>")?;
305
306 for comp in &result.components.added {
307 writeln!(html, " <tr>")?;
308 writeln!(
309 html,
310 " <td><span class=\"badge badge-added\">Added</span></td>"
311 )?;
312 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
313 writeln!(html, " <td>-</td>")?;
314 writeln!(
315 html,
316 " <td>{}</td>",
317 escape_html_opt(comp.new_version.as_deref())
318 )?;
319 writeln!(
320 html,
321 " <td>{}</td>",
322 escape_html_opt(comp.ecosystem.as_deref())
323 )?;
324 writeln!(html, " </tr>")?;
325 }
326
327 for comp in &result.components.removed {
328 writeln!(html, " <tr>")?;
329 writeln!(
330 html,
331 " <td><span class=\"badge badge-removed\">Removed</span></td>"
332 )?;
333 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
334 writeln!(
335 html,
336 " <td>{}</td>",
337 escape_html_opt(comp.old_version.as_deref())
338 )?;
339 writeln!(html, " <td>-</td>")?;
340 writeln!(
341 html,
342 " <td>{}</td>",
343 escape_html_opt(comp.ecosystem.as_deref())
344 )?;
345 writeln!(html, " </tr>")?;
346 }
347
348 for comp in &result.components.modified {
349 writeln!(html, " <tr>")?;
350 writeln!(
351 html,
352 " <td><span class=\"badge badge-modified\">Modified</span></td>"
353 )?;
354 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
355 writeln!(
356 html,
357 " <td>{}</td>",
358 escape_html_opt(comp.old_version.as_deref())
359 )?;
360 writeln!(
361 html,
362 " <td>{}</td>",
363 escape_html_opt(comp.new_version.as_deref())
364 )?;
365 writeln!(
366 html,
367 " <td>{}</td>",
368 escape_html_opt(comp.ecosystem.as_deref())
369 )?;
370 writeln!(html, " </tr>")?;
371 }
372
373 writeln!(html, " </tbody>")?;
374 writeln!(html, " </table>")?;
375 writeln!(html, "</div>")?;
376 }
377
378 if config.includes(ReportType::Vulnerabilities)
380 && !result.vulnerabilities.introduced.is_empty()
381 {
382 writeln!(html, "<div class=\"section\">")?;
383 writeln!(html, " <h2>Introduced Vulnerabilities</h2>")?;
384 writeln!(html, " <table>")?;
385 writeln!(html, " <thead>")?;
386 writeln!(html, " <tr>")?;
387 writeln!(html, " <th>ID</th>")?;
388 writeln!(html, " <th>Severity</th>")?;
389 writeln!(html, " <th>CVSS</th>")?;
390 writeln!(html, " <th>SLA</th>")?;
391 writeln!(html, " <th>Type</th>")?;
392 writeln!(html, " <th>Component</th>")?;
393 writeln!(html, " <th>Version</th>")?;
394 writeln!(html, " </tr>")?;
395 writeln!(html, " </thead>")?;
396 writeln!(html, " <tbody>")?;
397
398 for vuln in &result.vulnerabilities.introduced {
399 let badge_class = match vuln.severity.to_lowercase().as_str() {
400 "critical" => "badge-critical",
401 "high" => "badge-high",
402 "medium" => "badge-medium",
403 _ => "badge-low",
404 };
405 let (depth_label, depth_class) = match vuln.component_depth {
406 Some(1) => ("Direct", "badge-direct"),
407 Some(_) => ("Transitive", "badge-transitive"),
408 None => ("-", ""),
409 };
410 writeln!(html, " <tr>")?;
411 writeln!(html, " <td>{}</td>", escape_html(&vuln.id))?;
412 writeln!(
413 html,
414 " <td><span class=\"badge {}\">{}</span></td>",
415 badge_class,
416 escape_html(&vuln.severity)
417 )?;
418 writeln!(
419 html,
420 " <td>{}</td>",
421 vuln.cvss_score
422 .map(|s| format!("{:.1}", s))
423 .unwrap_or_else(|| "-".to_string())
424 )?;
425 let (sla_text, sla_class) = format_sla_html(vuln);
427 if sla_class.is_empty() {
428 writeln!(html, " <td>{}</td>", sla_text)?;
429 } else {
430 writeln!(
431 html,
432 " <td><span class=\"{}\">{}</span></td>",
433 sla_class, sla_text
434 )?;
435 }
436 if depth_class.is_empty() {
437 writeln!(html, " <td>{}</td>", depth_label)?;
438 } else {
439 writeln!(
440 html,
441 " <td><span class=\"badge {}\">{}</span></td>",
442 depth_class, depth_label
443 )?;
444 }
445 writeln!(
446 html,
447 " <td>{}</td>",
448 escape_html(&vuln.component_name)
449 )?;
450 writeln!(
451 html,
452 " <td>{}</td>",
453 escape_html_opt(vuln.version.as_deref())
454 )?;
455 writeln!(html, " </tr>")?;
456 }
457
458 writeln!(html, " </tbody>")?;
459 writeln!(html, " </table>")?;
460 writeln!(html, "</div>")?;
461 }
462
463 writeln!(html, "<div class=\"footer\">")?;
465 writeln!(html, " <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>")?;
466 writeln!(html, "</div>")?;
467
468 writeln!(html, "</div>")?;
469 writeln!(html, "</body>")?;
470 writeln!(html, "</html>")?;
471
472 Ok(html)
473 }
474
475 fn generate_view_report(
476 &self,
477 sbom: &NormalizedSbom,
478 config: &ReportConfig,
479 ) -> Result<String, ReportError> {
480 use std::collections::HashSet;
481
482 let mut html = String::new();
483
484 let title = config
485 .title
486 .clone()
487 .unwrap_or_else(|| "SBOM Report".to_string());
488
489 let total_components = sbom.component_count();
491 let vulnerable_components: Vec<_> = sbom
492 .components
493 .values()
494 .filter(|c| !c.vulnerabilities.is_empty())
495 .collect();
496 let vuln_component_count = vulnerable_components.len();
497 let total_vulns: usize = sbom
498 .components
499 .values()
500 .map(|c| c.vulnerabilities.len())
501 .sum();
502 let ecosystems: HashSet<_> = sbom
503 .components
504 .values()
505 .filter_map(|c| c.ecosystem.as_ref())
506 .collect();
507 let licenses: HashSet<String> = sbom
508 .components
509 .values()
510 .flat_map(|c| c.licenses.declared.iter().map(|l| l.expression.clone()))
511 .collect();
512
513 writeln!(html, "<!DOCTYPE html>")?;
515 writeln!(html, "<html lang=\"en\">")?;
516 writeln!(html, "<head>")?;
517 writeln!(html, " <meta charset=\"UTF-8\">")?;
518 writeln!(
519 html,
520 " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
521 )?;
522 writeln!(html, " <title>{}</title>", escape_html(&title))?;
523 if self.include_styles {
524 writeln!(html, "{}", self.get_styles())?;
525 }
526 writeln!(html, "</head>")?;
527 writeln!(html, "<body>")?;
528 writeln!(html, "<div class=\"container\">")?;
529
530 writeln!(html, "<div class=\"header\">")?;
532 writeln!(html, " <h1>{}</h1>", escape_html(&title))?;
533 if let Some(ref name) = sbom.document.name {
534 writeln!(html, " <p>Document: {}</p>", escape_html(name))?;
535 }
536 writeln!(
537 html,
538 " <p>Generated by sbom-tools v{} on {}</p>",
539 env!("CARGO_PKG_VERSION"),
540 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
541 )?;
542 writeln!(html, "</div>")?;
543
544 writeln!(html, "<div class=\"summary-cards\">")?;
546
547 writeln!(html, " <div class=\"card\">")?;
548 writeln!(
549 html,
550 " <div class=\"card-title\">Total Components</div>"
551 )?;
552 writeln!(
553 html,
554 " <div class=\"card-value\">{}</div>",
555 total_components
556 )?;
557 writeln!(html, " </div>")?;
558
559 writeln!(html, " <div class=\"card\">")?;
560 writeln!(
561 html,
562 " <div class=\"card-title\">Vulnerable Components</div>"
563 )?;
564 let vuln_class = if vuln_component_count > 0 {
565 "critical"
566 } else {
567 ""
568 };
569 writeln!(
570 html,
571 " <div class=\"card-value {}\">{}</div>",
572 vuln_class, vuln_component_count
573 )?;
574 writeln!(html, " </div>")?;
575
576 writeln!(html, " <div class=\"card\">")?;
577 writeln!(
578 html,
579 " <div class=\"card-title\">Total Vulnerabilities</div>"
580 )?;
581 let total_vuln_class = if total_vulns > 0 { "critical" } else { "" };
582 writeln!(
583 html,
584 " <div class=\"card-value {}\">{}</div>",
585 total_vuln_class, total_vulns
586 )?;
587 writeln!(html, " </div>")?;
588
589 writeln!(html, " <div class=\"card\">")?;
590 writeln!(html, " <div class=\"card-title\">Ecosystems</div>")?;
591 writeln!(
592 html,
593 " <div class=\"card-value\">{}</div>",
594 ecosystems.len()
595 )?;
596 writeln!(html, " </div>")?;
597
598 writeln!(html, " <div class=\"card\">")?;
599 writeln!(
600 html,
601 " <div class=\"card-title\">Unique Licenses</div>"
602 )?;
603 writeln!(
604 html,
605 " <div class=\"card-value\">{}</div>",
606 licenses.len()
607 )?;
608 writeln!(html, " </div>")?;
609
610 writeln!(html, "</div>")?;
611
612 if config.includes(ReportType::Components) && total_components > 0 {
614 writeln!(html, "<div class=\"section\">")?;
615 writeln!(html, " <h2>Components</h2>")?;
616 writeln!(html, " <table>")?;
617 writeln!(html, " <thead>")?;
618 writeln!(html, " <tr>")?;
619 writeln!(html, " <th>Name</th>")?;
620 writeln!(html, " <th>Version</th>")?;
621 writeln!(html, " <th>Ecosystem</th>")?;
622 writeln!(html, " <th>License</th>")?;
623 writeln!(html, " <th>Vulnerabilities</th>")?;
624 writeln!(html, " </tr>")?;
625 writeln!(html, " </thead>")?;
626 writeln!(html, " <tbody>")?;
627
628 let mut components: Vec<_> = sbom.components.values().collect();
630 components.sort_by(|a, b| a.name.cmp(&b.name));
631
632 for comp in components {
633 let license_str = comp
634 .licenses
635 .declared
636 .first()
637 .map(|l| l.expression.as_str())
638 .unwrap_or("-");
639 let vuln_count = comp.vulnerabilities.len();
640 let vuln_badge = if vuln_count > 0 {
641 format!(
642 "<span class=\"badge badge-critical\">{}</span>",
643 vuln_count
644 )
645 } else {
646 "0".to_string()
647 };
648
649 writeln!(html, " <tr>")?;
650 writeln!(html, " <td>{}</td>", escape_html(&comp.name))?;
651 writeln!(
652 html,
653 " <td>{}</td>",
654 escape_html_opt(comp.version.as_deref())
655 )?;
656 writeln!(
657 html,
658 " <td>{}</td>",
659 comp.ecosystem
660 .as_ref()
661 .map(|e| escape_html(&format!("{:?}", e)))
662 .unwrap_or_else(|| "-".to_string())
663 )?;
664 writeln!(html, " <td>{}</td>", escape_html(license_str))?;
665 writeln!(html, " <td>{}</td>", vuln_badge)?;
666 writeln!(html, " </tr>")?;
667 }
668
669 writeln!(html, " </tbody>")?;
670 writeln!(html, " </table>")?;
671 writeln!(html, "</div>")?;
672 }
673
674 if config.includes(ReportType::Vulnerabilities) && total_vulns > 0 {
676 writeln!(html, "<div class=\"section\">")?;
677 writeln!(html, " <h2>Vulnerabilities</h2>")?;
678 writeln!(html, " <table>")?;
679 writeln!(html, " <thead>")?;
680 writeln!(html, " <tr>")?;
681 writeln!(html, " <th>ID</th>")?;
682 writeln!(html, " <th>Severity</th>")?;
683 writeln!(html, " <th>CVSS</th>")?;
684 writeln!(html, " <th>Component</th>")?;
685 writeln!(html, " <th>Version</th>")?;
686 writeln!(html, " </tr>")?;
687 writeln!(html, " </thead>")?;
688 writeln!(html, " <tbody>")?;
689
690 let mut all_vulns: Vec<VulnRow<'_>> = sbom
692 .components
693 .values()
694 .flat_map(|comp| {
695 comp.vulnerabilities.iter().map(move |v| {
696 (
697 v.id.as_str(),
698 &v.severity,
699 v.cvss.first().map(|c| c.base_score),
700 comp.name.as_str(),
701 comp.version.as_deref(),
702 )
703 })
704 })
705 .collect();
706
707 all_vulns.sort_by(|a, b| {
709 let sev_order = |s: &Option<crate::model::Severity>| match s {
710 Some(crate::model::Severity::Critical) => 0,
711 Some(crate::model::Severity::High) => 1,
712 Some(crate::model::Severity::Medium) => 2,
713 Some(crate::model::Severity::Low) => 3,
714 Some(crate::model::Severity::Info) => 4,
715 _ => 5,
716 };
717 sev_order(a.1).cmp(&sev_order(b.1))
718 });
719
720 for (id, severity, cvss, comp_name, version) in all_vulns {
721 let (badge_class, sev_str) = match severity {
722 Some(crate::model::Severity::Critical) => ("badge-critical", "Critical"),
723 Some(crate::model::Severity::High) => ("badge-high", "High"),
724 Some(crate::model::Severity::Medium) => ("badge-medium", "Medium"),
725 Some(crate::model::Severity::Low) => ("badge-low", "Low"),
726 Some(crate::model::Severity::Info) => ("badge-low", "Info"),
727 _ => ("badge-low", "Unknown"),
728 };
729
730 writeln!(html, " <tr>")?;
731 writeln!(html, " <td>{}</td>", escape_html(id))?;
732 writeln!(
733 html,
734 " <td><span class=\"badge {}\">{}</span></td>",
735 badge_class, sev_str
736 )?;
737 writeln!(
738 html,
739 " <td>{}</td>",
740 cvss.map(|s| format!("{:.1}", s))
741 .unwrap_or_else(|| "-".to_string())
742 )?;
743 writeln!(html, " <td>{}</td>", escape_html(comp_name))?;
744 writeln!(
745 html,
746 " <td>{}</td>",
747 escape_html_opt(version)
748 )?;
749 writeln!(html, " </tr>")?;
750 }
751
752 writeln!(html, " </tbody>")?;
753 writeln!(html, " </table>")?;
754 writeln!(html, "</div>")?;
755 }
756
757 writeln!(html, "<div class=\"footer\">")?;
759 writeln!(html, " <p>Generated by <a href=\"https://github.com/binarly-io/sbom-tools\">sbom-tools</a></p>")?;
760 writeln!(html, "</div>")?;
761
762 writeln!(html, "</div>")?;
763 writeln!(html, "</body>")?;
764 writeln!(html, "</html>")?;
765
766 Ok(html)
767 }
768
769 fn format(&self) -> ReportFormat {
770 ReportFormat::Html
771 }
772}
773
774fn format_sla_html(vuln: &VulnerabilityDetail) -> (String, &'static str) {
776 match vuln.sla_status() {
777 SlaStatus::Overdue(days) => (format!("{}d late", days), "sla-overdue"),
778 SlaStatus::DueSoon(days) => (format!("{}d left", days), "sla-due-soon"),
779 SlaStatus::OnTrack(days) => (format!("{}d left", days), "sla-on-track"),
780 SlaStatus::NoDueDate => {
781 let text = vuln
782 .days_since_published
783 .map(|d| format!("{}d old", d))
784 .unwrap_or_else(|| "-".to_string());
785 (text, "sla-unknown")
786 }
787 }
788}