use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; use crate::infrastructure::sbom::*; #[component] pub fn SbomPage() -> Element { // ── Filter signals ── let mut page = use_signal(|| 1u64); let mut repo_filter = use_signal(String::new); let mut pm_filter = use_signal(String::new); let mut search_q = use_signal(String::new); let mut vuln_toggle = use_signal(|| Option::::None); let mut license_filter = use_signal(String::new); // ── Active tab: "packages" | "licenses" | "diff" ── let mut active_tab = use_signal(|| "packages".to_string()); // ── Vuln drill-down: track expanded row by (name, version) ── let mut expanded_row = use_signal(|| Option::::None); // ── Export state ── let mut show_export = use_signal(|| false); let mut export_format = use_signal(|| "cyclonedx".to_string()); // ── Diff state ── let mut diff_repo_a = use_signal(String::new); let mut diff_repo_b = use_signal(String::new); // ── Repos for dropdowns ── let repos = use_resource(|| async { crate::infrastructure::repositories::fetch_repositories(1) .await .ok() }); // ── Dynamic filter options (package managers + licenses from DB) ── let sbom_filters = use_resource(|| async { fetch_sbom_filters().await.ok() }); // ── SBOM list (filtered) ── let sbom = use_resource(move || { let p = page(); let repo = repo_filter(); let pm = pm_filter(); let q = search_q(); let hv = vuln_toggle(); let lic = license_filter(); async move { fetch_sbom_filtered( if repo.is_empty() { None } else { Some(repo) }, if pm.is_empty() { None } else { Some(pm) }, if q.is_empty() { None } else { Some(q) }, hv, if lic.is_empty() { None } else { Some(lic) }, p, ) .await .ok() } }); // ── License summary ── let license_data = use_resource(move || { let repo = repo_filter(); async move { fetch_license_summary(if repo.is_empty() { None } else { Some(repo) }) .await .ok() } }); // ── Diff data ── let diff_data = use_resource(move || { let a = diff_repo_a(); let b = diff_repo_b(); async move { if a.is_empty() || b.is_empty() { return None; } fetch_sbom_diff(a, b).await.ok() } }); rsx! { PageHeader { title: "SBOM", description: "Software Bill of Materials — dependency inventory, license compliance, and vulnerability analysis", } // ── Tab bar ── div { class: "sbom-tab-bar", button { class: if active_tab() == "packages" { "sbom-tab active" } else { "sbom-tab" }, onclick: move |_| active_tab.set("packages".to_string()), "Packages" } button { class: if active_tab() == "licenses" { "sbom-tab active" } else { "sbom-tab" }, onclick: move |_| active_tab.set("licenses".to_string()), "License Compliance" } button { class: if active_tab() == "diff" { "sbom-tab active" } else { "sbom-tab" }, onclick: move |_| active_tab.set("diff".to_string()), "Compare" } } // ═══════════════ PACKAGES TAB ═══════════════ if active_tab() == "packages" { // ── Filter bar ── div { class: "sbom-filter-bar", select { class: "sbom-filter-select", onchange: move |e| { repo_filter.set(e.value()); page.set(1); }, option { value: "", "All Repositories" } { match &*repos.read() { Some(Some(resp)) => rsx! { for repo in &resp.data { { let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let name = repo.name.clone(); rsx! { option { value: "{id}", "{name}" } } } } }, _ => rsx! {}, } } } select { class: "sbom-filter-select", onchange: move |e| { pm_filter.set(e.value()); page.set(1); }, option { value: "", "All Managers" } { match &*sbom_filters.read() { Some(Some(f)) => rsx! { for pm in &f.package_managers { { let val = pm.clone(); let label = pm_display_name(&val); rsx! { option { value: "{val}", "{label}" } } } } }, _ => rsx! {}, } } } input { class: "sbom-filter-input", r#type: "text", placeholder: "Search packages...", oninput: move |e| { search_q.set(e.value()); page.set(1); }, } select { class: "sbom-filter-select", onchange: move |e| { let val = e.value(); vuln_toggle.set(match val.as_str() { "true" => Some(true), "false" => Some(false), _ => None, }); page.set(1); }, option { value: "", "All Packages" } option { value: "true", "With Vulnerabilities" } option { value: "false", "No Vulnerabilities" } } select { class: "sbom-filter-select", onchange: move |e| { license_filter.set(e.value()); page.set(1); }, option { value: "", "All Licenses" } { match &*sbom_filters.read() { Some(Some(f)) => rsx! { for lic in &f.licenses { { let val = lic.clone(); rsx! { option { value: "{val}", "{val}" } } } } }, _ => rsx! {}, } } } // ── Export button ── div { class: "sbom-export-wrapper", button { class: "btn btn-secondary sbom-export-btn", onclick: move |_| show_export.toggle(), "Export" } if show_export() { div { class: "sbom-export-dropdown", select { class: "sbom-filter-select", value: "{export_format}", onchange: move |e| export_format.set(e.value()), option { value: "cyclonedx", "CycloneDX 1.5" } option { value: "spdx", "SPDX 2.3" } } button { class: "btn btn-primary", disabled: repo_filter().is_empty(), onclick: move |_| { let repo = repo_filter(); let fmt = export_format(); spawn(async move { match fetch_sbom_export(repo.clone(), fmt.clone()).await { Ok(json) => { let filename = if fmt == "spdx" { format!("sbom-{repo}-spdx.json") } else { format!("sbom-{repo}-cyclonedx.json") }; trigger_download(&json, &filename); } Err(e) => tracing::error!("Export failed: {e}"), } }); }, "Download" } if repo_filter().is_empty() { span { class: "sbom-export-hint", "Select a repo first" } } } } } } // ── SBOM table ── match &*sbom.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1); rsx! { if let Some(total) = resp.total { div { class: "sbom-result-count", "{total} package(s) found" } } div { class: "card", div { class: "table-wrapper", table { thead { tr { th { "Package" } th { "Version" } th { "Manager" } th { "License" } th { "Vulnerabilities" } } } tbody { for entry in &resp.data { { let row_key = format!("{}@{}", entry.name, entry.version); let is_expanded = expanded_row() == Some(row_key.clone()); let has_vulns = !entry.known_vulnerabilities.is_empty(); let license_class = license_css_class(entry.license.as_deref()); let row_key_click = row_key.clone(); rsx! { tr { td { style: "font-weight: 500;", "{entry.name}" } td { style: "font-family: var(--font-mono, monospace); font-size: 13px;", "{entry.version}" } td { "{entry.package_manager}" } td { span { class: "sbom-license-badge {license_class}", "{entry.license.as_deref().unwrap_or(\"-\")}" } } td { if has_vulns { span { class: "badge badge-high sbom-vuln-toggle", onclick: move |_| { let key = row_key_click.clone(); if expanded_row() == Some(key.clone()) { expanded_row.set(None); } else { expanded_row.set(Some(key)); } }, "{entry.known_vulnerabilities.len()} vuln(s) ▾" } } else { span { style: "color: var(--success);", "None" } } } } // ── Vulnerability drill-down row ── if is_expanded && has_vulns { tr { class: "sbom-vuln-detail-row", td { colspan: "5", div { class: "sbom-vuln-detail", for vuln in &entry.known_vulnerabilities { div { class: "sbom-vuln-card", div { class: "sbom-vuln-card-header", span { class: "sbom-vuln-id", "{vuln.id}" } span { class: "sbom-vuln-source", "{vuln.source}" } if let Some(sev) = &vuln.severity { span { class: "badge badge-{sev}", "{sev}" } } } if let Some(url) = &vuln.url { a { href: "{url}", target: "_blank", class: "sbom-vuln-link", "View Advisory →" } } } } } } } } } } } } } } Pagination { current_page: page(), total_pages: total_pages, on_page_change: move |p| page.set(p), } } } }, Some(None) => rsx! { div { class: "card", p { "Failed to load SBOM." } } }, None => rsx! { div { class: "loading", "Loading SBOM..." } }, } } // ═══════════════ LICENSE COMPLIANCE TAB ═══════════════ if active_tab() == "licenses" { match &*license_data.read() { Some(Some(resp)) => { let total_pkgs: u64 = resp.data.iter().map(|l| l.count).sum(); let has_copyleft = resp.data.iter().any(|l| l.is_copyleft); let copyleft_items: Vec<_> = resp.data.iter().filter(|l| l.is_copyleft).collect(); rsx! { if has_copyleft { div { class: "license-copyleft-warning", strong { "⚠ Copyleft Licenses Detected" } p { "The following copyleft-licensed packages may impose distribution requirements on your software." } for item in ©left_items { div { class: "license-copyleft-item", span { class: "sbom-license-badge license-copyleft", "{item.license}" } span { " — {item.count} package(s): " } span { class: "license-pkg-list", "{item.packages.join(\", \")}" } } } } } div { class: "card", h3 { style: "margin-bottom: 16px;", "License Distribution" } if total_pkgs > 0 { div { class: "license-bar-chart", for item in &resp.data { { let pct = (item.count as f64 / total_pkgs as f64 * 100.0).max(2.0); let bar_class = if item.is_copyleft { "license-bar license-copyleft" } else { "license-bar license-permissive" }; rsx! { div { class: "license-bar-row", span { class: "license-bar-label", "{item.license}" } div { class: "license-bar-track", div { class: "{bar_class}", style: "width: {pct}%;", } } span { class: "license-bar-count", "{item.count}" } } } } } } } else { p { "No license data available." } } } div { class: "card", h3 { style: "margin-bottom: 16px;", "All Licenses" } div { class: "table-wrapper", table { thead { tr { th { "License" } th { "Type" } th { "Packages" } th { "Count" } } } tbody { for item in &resp.data { tr { td { span { class: "sbom-license-badge {license_type_class(item.is_copyleft)}", "{item.license}" } } td { if item.is_copyleft { span { class: "badge badge-high", "Copyleft" } } else { span { class: "badge badge-info", "Permissive" } } } td { style: "max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;", "{item.packages.join(\", \")}" } td { "{item.count}" } } } } } } } } }, Some(None) => rsx! { div { class: "card", p { "Failed to load license summary." } } }, None => rsx! { div { class: "loading", "Loading license data..." } }, } } // ═══════════════ DIFF TAB ═══════════════ if active_tab() == "diff" { div { class: "card", h3 { style: "margin-bottom: 16px;", "Compare SBOMs Between Repositories" } div { class: "sbom-diff-controls", div { class: "sbom-diff-select-group", label { "Repository A" } select { class: "sbom-filter-select", onchange: move |e| diff_repo_a.set(e.value()), option { value: "", "Select repository..." } { match &*repos.read() { Some(Some(resp)) => rsx! { for repo in &resp.data { { let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let name = repo.name.clone(); rsx! { option { value: "{id}", "{name}" } } } } }, _ => rsx! {}, } } } } div { class: "sbom-diff-select-group", label { "Repository B" } select { class: "sbom-filter-select", onchange: move |e| diff_repo_b.set(e.value()), option { value: "", "Select repository..." } { match &*repos.read() { Some(Some(resp)) => rsx! { for repo in &resp.data { { let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let name = repo.name.clone(); rsx! { option { value: "{id}", "{name}" } } } } }, _ => rsx! {}, } } } } } } if !diff_repo_a().is_empty() && !diff_repo_b().is_empty() { match &*diff_data.read() { Some(Some(resp)) => { let d = &resp.data; rsx! { div { class: "sbom-diff-summary", div { class: "sbom-diff-stat sbom-diff-added", span { class: "sbom-diff-stat-num", "{d.only_in_a.len()}" } span { "Only in A" } } div { class: "sbom-diff-stat sbom-diff-removed", span { class: "sbom-diff-stat-num", "{d.only_in_b.len()}" } span { "Only in B" } } div { class: "sbom-diff-stat sbom-diff-changed", span { class: "sbom-diff-stat-num", "{d.version_changed.len()}" } span { "Version Diffs" } } div { class: "sbom-diff-stat", span { class: "sbom-diff-stat-num", "{d.common_count}" } span { "Common" } } } if !d.only_in_a.is_empty() { div { class: "card", h4 { style: "margin-bottom: 12px; color: var(--success);", "Only in Repository A" } div { class: "table-wrapper", table { thead { tr { th { "Package" } th { "Version" } th { "Manager" } } } tbody { for e in &d.only_in_a { tr { class: "sbom-diff-row-added", td { "{e.name}" } td { "{e.version}" } td { "{e.package_manager}" } } } } } } } } if !d.only_in_b.is_empty() { div { class: "card", h4 { style: "margin-bottom: 12px; color: var(--danger);", "Only in Repository B" } div { class: "table-wrapper", table { thead { tr { th { "Package" } th { "Version" } th { "Manager" } } } tbody { for e in &d.only_in_b { tr { class: "sbom-diff-row-removed", td { "{e.name}" } td { "{e.version}" } td { "{e.package_manager}" } } } } } } } } if !d.version_changed.is_empty() { div { class: "card", h4 { style: "margin-bottom: 12px; color: var(--warning);", "Version Differences" } div { class: "table-wrapper", table { thead { tr { th { "Package" } th { "Manager" } th { "Version A" } th { "Version B" } } } tbody { for e in &d.version_changed { tr { class: "sbom-diff-row-changed", td { "{e.name}" } td { "{e.package_manager}" } td { "{e.version_a}" } td { "{e.version_b}" } } } } } } } } if d.only_in_a.is_empty() && d.only_in_b.is_empty() && d.version_changed.is_empty() { div { class: "card", p { "Both repositories have identical SBOM entries." } } } } }, Some(None) => rsx! { div { class: "card", p { "Failed to load diff." } } }, None => rsx! { div { class: "loading", "Computing diff..." } }, } } } } } fn pm_display_name(pm: &str) -> &str { match pm { "npm" => "npm", "cargo" => "Cargo", "pip" => "pip", "go" | "golang" => "Go", "maven" => "Maven", "nuget" => "NuGet", "composer" => "Composer", "gem" => "RubyGems", "github" => "GitHub Actions", other => other, } } fn license_css_class(license: Option<&str>) -> &'static str { match license { Some(l) => { let upper = l.to_uppercase(); if upper.contains("GPL") || upper.contains("AGPL") { "license-copyleft" } else if upper.contains("LGPL") || upper.contains("MPL") { "license-weak-copyleft" } else { "license-permissive" } } None => "", } } fn license_type_class(is_copyleft: bool) -> &'static str { if is_copyleft { "license-copyleft" } else { "license-permissive" } } #[cfg(feature = "web")] fn trigger_download(content: &str, filename: &str) { use wasm_bindgen::JsCast; let Some(window) = web_sys::window() else { return; }; let Some(document) = window.document() else { return; }; let blob_parts = js_sys::Array::new(); blob_parts.push(&wasm_bindgen::JsValue::from_str(content)); let opts = web_sys::BlobPropertyBag::new(); opts.set_type("application/json"); let Ok(blob) = web_sys::Blob::new_with_str_sequence_and_options(&blob_parts, &opts) else { return; }; let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { return; }; let Ok(el) = document.create_element("a") else { return; }; let Ok(a) = el.dyn_into::() else { return; }; a.set_href(&url); a.set_download(filename); a.click(); let _ = web_sys::Url::revoke_object_url(&url); } #[cfg(not(feature = "web"))] fn trigger_download(_content: &str, _filename: &str) { // Server-side: no-op (downloads only happen in the browser) }