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
717 lines
35 KiB
Rust
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 ©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::<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)
|
|
}
|