Files
compliance-scanner-agent/compliance-dashboard/src/pages/sbom.rs
T
sharang 491665559f
CI / Format (push) Successful in 4s
CI / Security Audit (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Deploy Agent (push) Has been cancelled
CI / Deploy Dashboard (push) Has been cancelled
CI / Deploy Docs (push) Has been cancelled
CI / Deploy MCP (push) Has been cancelled
CI / Clippy (push) Has been cancelled
feat: per-repo issue tracker, Gitea support, PR review pipeline (#10)
2026-03-11 12:13:59 +00:00

717 lines
35 KiB
Rust

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::<bool>::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::<String>::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 &copyleft_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::<web_sys::HtmlAnchorElement>() 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)
}