From a22cf1595f40d2e73aae7732f6dbbc2bea2c50a2 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Thu, 5 Mar 2026 00:17:14 +0100 Subject: [PATCH] Add SBOM enhancements, delete repo feature, and embedding build spinner - Fix SBOM display bug by removing incorrect BSON serde helpers on DateTime fields - Add filtered/searchable SBOM list with repo, package manager, search, vuln, and license filters - Add SBOM export (CycloneDX 1.5 / SPDX 2.3), license compliance tab, and cross-repo diff - Add vulnerability drill-down with inline CVE details and advisory links - Add DELETE /api/v1/repositories/{id} with cascade delete of all related data - Add delete repository button with confirmation modal warning in dashboard - Add spinner and progress bar for embedding builds with auto-polling status - Install syft in agent Dockerfile for SBOM generation Co-Authored-By: Claude Opus 4.6 --- Dockerfile.agent | 5 +- compliance-agent/src/api/handlers/graph.rs | 13 +- compliance-agent/src/api/handlers/mod.rs | 421 ++++++++++- compliance-agent/src/api/routes.rs | 9 +- compliance-dashboard/assets/graph-viz.js | 37 +- compliance-dashboard/assets/main.css | 490 +++++++++++++ .../src/infrastructure/repositories.rs | 26 + .../src/infrastructure/sbom.rs | 187 ++++- compliance-dashboard/src/pages/chat.rs | 68 +- .../src/pages/repositories.rs | 56 +- compliance-dashboard/src/pages/sbom.rs | 669 ++++++++++++++++-- 11 files changed, 1900 insertions(+), 81 deletions(-) diff --git a/Dockerfile.agent b/Dockerfile.agent index d8f54b6..f42e05a 100644 --- a/Dockerfile.agent +++ b/Dockerfile.agent @@ -5,7 +5,10 @@ COPY . . RUN cargo build --release -p compliance-agent FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates libssl3 git && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ca-certificates libssl3 git curl && rm -rf /var/lib/apt/lists/* + +# Install syft for SBOM generation +RUN curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent diff --git a/compliance-agent/src/api/handlers/graph.rs b/compliance-agent/src/api/handlers/graph.rs index d84bd4b..ea12acd 100644 --- a/compliance-agent/src/api/handlers/graph.rs +++ b/compliance-agent/src/api/handlers/graph.rs @@ -52,7 +52,7 @@ pub async fn get_graph( // so there is only one set of nodes/edges per repo. let filter = doc! { "repo_id": &repo_id }; - let nodes: Vec = match db.graph_nodes().find(filter.clone()).await { + let all_nodes: Vec = match db.graph_nodes().find(filter.clone()).await { Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; @@ -60,6 +60,17 @@ pub async fn get_graph( Ok(cursor) => collect_cursor_async(cursor).await, Err(_) => Vec::new(), }; + + // Remove disconnected nodes (no edges) to keep the graph clean + let connected: std::collections::HashSet<&str> = edges + .iter() + .flat_map(|e| [e.source.as_str(), e.target.as_str()]) + .collect(); + let nodes = all_nodes + .into_iter() + .filter(|n| connected.contains(n.qualified_name.as_str())) + .collect(); + (nodes, edges) } else { (Vec::new(), Vec::new()) diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index b9beccc..188684b 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -6,7 +6,8 @@ use std::sync::Arc; #[allow(unused_imports)] use axum::extract::{Extension, Path, Query}; -use axum::http::StatusCode; +use axum::http::{header, StatusCode}; +use axum::response::IntoResponse; use axum::Json; use mongodb::bson::doc; use serde::{Deserialize, Serialize}; @@ -90,6 +91,72 @@ pub struct UpdateStatusRequest { pub status: String, } +#[derive(Deserialize)] +pub struct SbomFilter { + #[serde(default)] + pub repo_id: Option, + #[serde(default)] + pub package_manager: Option, + #[serde(default)] + pub q: Option, + #[serde(default)] + pub has_vulns: Option, + #[serde(default)] + pub license: Option, + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_limit")] + pub limit: i64, +} + +#[derive(Deserialize)] +pub struct SbomExportParams { + pub repo_id: String, + #[serde(default = "default_export_format")] + pub format: String, +} + +fn default_export_format() -> String { + "cyclonedx".to_string() +} + +#[derive(Deserialize)] +pub struct SbomDiffParams { + pub repo_a: String, + pub repo_b: String, +} + +#[derive(Serialize)] +pub struct LicenseSummary { + pub license: String, + pub count: u64, + pub is_copyleft: bool, + pub packages: Vec, +} + +#[derive(Serialize)] +pub struct SbomDiffResult { + pub only_in_a: Vec, + pub only_in_b: Vec, + pub version_changed: Vec, + pub common_count: u64, +} + +#[derive(Serialize)] +pub struct SbomDiffEntry { + pub name: String, + pub version: String, + pub package_manager: String, +} + +#[derive(Serialize)] +pub struct SbomVersionDiff { + pub name: String, + pub package_manager: String, + pub version_a: String, + pub version_b: String, +} + type AgentExt = Extension>; type ApiResult = Result>, StatusCode>; @@ -236,6 +303,56 @@ pub async fn trigger_scan( Ok(Json(serde_json::json!({ "status": "scan_triggered" }))) } +pub async fn delete_repository( + Extension(agent): AgentExt, + Path(id): Path, +) -> Result, StatusCode> { + let oid = + mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + let db = &agent.db; + + // Delete the repository + let result = db + .repositories() + .delete_one(doc! { "_id": oid }) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.deleted_count == 0 { + return Err(StatusCode::NOT_FOUND); + } + + // Cascade delete all related data + let _ = db.findings().delete_many(doc! { "repo_id": &id }).await; + let _ = db.sbom_entries().delete_many(doc! { "repo_id": &id }).await; + let _ = db.scan_runs().delete_many(doc! { "repo_id": &id }).await; + let _ = db.cve_alerts().delete_many(doc! { "repo_id": &id }).await; + let _ = db + .tracker_issues() + .delete_many(doc! { "repo_id": &id }) + .await; + let _ = db.graph_nodes().delete_many(doc! { "repo_id": &id }).await; + let _ = db.graph_edges().delete_many(doc! { "repo_id": &id }).await; + let _ = db + .graph_builds() + .delete_many(doc! { "repo_id": &id }) + .await; + let _ = db + .impact_analyses() + .delete_many(doc! { "repo_id": &id }) + .await; + let _ = db + .code_embeddings() + .delete_many(doc! { "repo_id": &id }) + .await; + let _ = db + .embedding_builds() + .delete_many(doc! { "repo_id": &id }) + .await; + + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + pub async fn list_findings( Extension(agent): AgentExt, Query(filter): Query, @@ -323,21 +440,46 @@ pub async fn update_finding_status( pub async fn list_sbom( Extension(agent): AgentExt, - Query(params): Query, + Query(filter): Query, ) -> ApiResult> { let db = &agent.db; - let skip = (params.page.saturating_sub(1)) * params.limit as u64; + let mut query = doc! {}; + + if let Some(repo_id) = &filter.repo_id { + query.insert("repo_id", repo_id); + } + if let Some(pm) = &filter.package_manager { + query.insert("package_manager", pm); + } + if let Some(q) = &filter.q { + if !q.is_empty() { + query.insert("name", doc! { "$regex": q, "$options": "i" }); + } + } + if let Some(has_vulns) = filter.has_vulns { + if has_vulns { + query.insert("known_vulnerabilities", doc! { "$exists": true, "$ne": [] }); + } else { + query.insert("known_vulnerabilities", doc! { "$size": 0 }); + } + } + if let Some(license) = &filter.license { + query.insert("license", license); + } + + let skip = (filter.page.saturating_sub(1)) * filter.limit as u64; let total = db .sbom_entries() - .count_documents(doc! {}) + .count_documents(query.clone()) .await .unwrap_or(0); let entries = match db .sbom_entries() - .find(doc! {}) + .find(query) + .sort(doc! { "name": 1 }) .skip(skip) - .limit(params.limit) + .limit(filter.limit) .await { Ok(cursor) => collect_cursor_async(cursor).await, @@ -347,7 +489,272 @@ pub async fn list_sbom( Ok(Json(ApiResponse { data: entries, total: Some(total), - page: Some(params.page), + page: Some(filter.page), + })) +} + +pub async fn export_sbom( + Extension(agent): AgentExt, + Query(params): Query, +) -> Result { + let db = &agent.db; + let entries: Vec = match db + .sbom_entries() + .find(doc! { "repo_id": ¶ms.repo_id }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + let body = if params.format == "spdx" { + // SPDX 2.3 format + let packages: Vec = entries + .iter() + .enumerate() + .map(|(i, e)| { + serde_json::json!({ + "SPDXID": format!("SPDXRef-Package-{i}"), + "name": e.name, + "versionInfo": e.version, + "downloadLocation": "NOASSERTION", + "licenseConcluded": e.license.as_deref().unwrap_or("NOASSERTION"), + "externalRefs": e.purl.as_ref().map(|p| vec![serde_json::json!({ + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": p, + })]).unwrap_or_default(), + }) + }) + .collect(); + + serde_json::json!({ + "spdxVersion": "SPDX-2.3", + "dataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "name": format!("sbom-{}", params.repo_id), + "documentNamespace": format!("https://compliance-scanner/sbom/{}", params.repo_id), + "packages": packages, + }) + } else { + // CycloneDX 1.5 format + let components: Vec = entries + .iter() + .map(|e| { + let mut comp = serde_json::json!({ + "type": "library", + "name": e.name, + "version": e.version, + "group": e.package_manager, + }); + if let Some(purl) = &e.purl { + comp["purl"] = serde_json::Value::String(purl.clone()); + } + if let Some(license) = &e.license { + comp["licenses"] = serde_json::json!([{ "license": { "id": license } }]); + } + if !e.known_vulnerabilities.is_empty() { + comp["vulnerabilities"] = serde_json::json!( + e.known_vulnerabilities.iter().map(|v| serde_json::json!({ + "id": v.id, + "source": { "name": v.source }, + "ratings": v.severity.as_ref().map(|s| vec![serde_json::json!({"severity": s})]).unwrap_or_default(), + })).collect::>() + ); + } + comp + }) + .collect(); + + serde_json::json!({ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": format!("repo-{}", params.repo_id), + } + }, + "components": components, + }) + }; + + let json_str = + serde_json::to_string_pretty(&body).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let filename = if params.format == "spdx" { + format!("sbom-{}-spdx.json", params.repo_id) + } else { + format!("sbom-{}-cyclonedx.json", params.repo_id) + }; + + let disposition = format!("attachment; filename=\"{filename}\""); + Ok(( + [ + ( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/json"), + ), + ( + header::CONTENT_DISPOSITION, + header::HeaderValue::from_str(&disposition) + .unwrap_or_else(|_| header::HeaderValue::from_static("attachment")), + ), + ], + json_str, + )) +} + +const COPYLEFT_LICENSES: &[&str] = &[ + "GPL-2.0", + "GPL-2.0-only", + "GPL-2.0-or-later", + "GPL-3.0", + "GPL-3.0-only", + "GPL-3.0-or-later", + "AGPL-3.0", + "AGPL-3.0-only", + "AGPL-3.0-or-later", + "LGPL-2.1", + "LGPL-2.1-only", + "LGPL-2.1-or-later", + "LGPL-3.0", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "MPL-2.0", +]; + +pub async fn license_summary( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult> { + let db = &agent.db; + let mut query = doc! {}; + if let Some(repo_id) = ¶ms.repo_id { + query.insert("repo_id", repo_id); + } + + let entries: Vec = match db.sbom_entries().find(query).await { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + let mut license_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for entry in &entries { + let lic = entry.license.as_deref().unwrap_or("Unknown").to_string(); + license_map.entry(lic).or_default().push(entry.name.clone()); + } + + let mut summaries: Vec = license_map + .into_iter() + .map(|(license, packages)| { + let is_copyleft = COPYLEFT_LICENSES + .iter() + .any(|c| license.to_uppercase().contains(&c.to_uppercase())); + LicenseSummary { + license, + count: packages.len() as u64, + is_copyleft, + packages, + } + }) + .collect(); + summaries.sort_by(|a, b| b.count.cmp(&a.count)); + + Ok(Json(ApiResponse { + data: summaries, + total: None, + page: None, + })) +} + +pub async fn sbom_diff( + Extension(agent): AgentExt, + Query(params): Query, +) -> ApiResult { + let db = &agent.db; + + let entries_a: Vec = match db + .sbom_entries() + .find(doc! { "repo_id": ¶ms.repo_a }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + let entries_b: Vec = match db + .sbom_entries() + .find(doc! { "repo_id": ¶ms.repo_b }) + .await + { + Ok(cursor) => collect_cursor_async(cursor).await, + Err(_) => Vec::new(), + }; + + // Build maps by (name, package_manager) -> version + let map_a: std::collections::HashMap<(String, String), String> = entries_a + .iter() + .map(|e| { + ( + (e.name.clone(), e.package_manager.clone()), + e.version.clone(), + ) + }) + .collect(); + let map_b: std::collections::HashMap<(String, String), String> = entries_b + .iter() + .map(|e| { + ( + (e.name.clone(), e.package_manager.clone()), + e.version.clone(), + ) + }) + .collect(); + + let mut only_in_a = Vec::new(); + let mut version_changed = Vec::new(); + let mut common_count: u64 = 0; + + for (key, ver_a) in &map_a { + match map_b.get(key) { + None => only_in_a.push(SbomDiffEntry { + name: key.0.clone(), + version: ver_a.clone(), + package_manager: key.1.clone(), + }), + Some(ver_b) if ver_a != ver_b => { + version_changed.push(SbomVersionDiff { + name: key.0.clone(), + package_manager: key.1.clone(), + version_a: ver_a.clone(), + version_b: ver_b.clone(), + }); + } + Some(_) => common_count += 1, + } + } + + let only_in_b: Vec = map_b + .iter() + .filter(|(key, _)| !map_a.contains_key(key)) + .map(|(key, ver)| SbomDiffEntry { + name: key.0.clone(), + version: ver.clone(), + package_manager: key.1.clone(), + }) + .collect(); + + Ok(Json(ApiResponse { + data: SbomDiffResult { + only_in_a, + only_in_b, + version_changed, + common_count, + }, + total: None, + page: None, })) } diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index 8f0e72a..78fdc3d 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -1,4 +1,4 @@ -use axum::routing::{get, patch, post}; +use axum::routing::{delete, get, patch, post}; use axum::Router; use crate::api::handlers; @@ -13,6 +13,10 @@ pub fn build_router() -> Router { "/api/v1/repositories/{id}/scan", post(handlers::trigger_scan), ) + .route( + "/api/v1/repositories/{id}", + delete(handlers::delete_repository), + ) .route("/api/v1/findings", get(handlers::list_findings)) .route("/api/v1/findings/{id}", get(handlers::get_finding)) .route( @@ -20,6 +24,9 @@ pub fn build_router() -> Router { patch(handlers::update_finding_status), ) .route("/api/v1/sbom", get(handlers::list_sbom)) + .route("/api/v1/sbom/export", get(handlers::export_sbom)) + .route("/api/v1/sbom/licenses", get(handlers::license_summary)) + .route("/api/v1/sbom/diff", get(handlers::sbom_diff)) .route("/api/v1/issues", get(handlers::list_issues)) .route("/api/v1/scan-runs", get(handlers::list_scan_runs)) // Graph API endpoints diff --git a/compliance-dashboard/assets/graph-viz.js b/compliance-dashboard/assets/graph-viz.js index 982b95c..ef9337d 100644 --- a/compliance-dashboard/assets/graph-viz.js +++ b/compliance-dashboard/assets/graph-viz.js @@ -169,20 +169,20 @@ enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { - gravitationalConstant: -60, - centralGravity: 0.012, - springLength: 80, - springConstant: 0.06, - damping: 0.4, - avoidOverlap: 0.5, + gravitationalConstant: -80, + centralGravity: 0.005, + springLength: 120, + springConstant: 0.04, + damping: 0.5, + avoidOverlap: 0.6, }, stabilization: { enabled: true, - iterations: 1000, + iterations: 1500, updateInterval: 25, }, - maxVelocity: 40, - minVelocity: 0.1, + maxVelocity: 50, + minVelocity: 0.75, }, interaction: { hover: true, @@ -252,7 +252,24 @@ overlay.style.display = "none"; }, 900); } - network.setOptions({ physics: { enabled: false } }); + // Keep physics running so nodes float and respond to dragging, + // but reduce forces for a calm, settled feel + network.setOptions({ + physics: { + enabled: true, + solver: "forceAtlas2Based", + forceAtlas2Based: { + gravitationalConstant: -40, + centralGravity: 0.003, + springLength: 120, + springConstant: 0.03, + damping: 0.7, + avoidOverlap: 0.6, + }, + maxVelocity: 20, + minVelocity: 0.75, + }, + }); }); console.log( diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 1cba4c1..93b80fb 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -603,6 +603,76 @@ tbody tr:last-child td { background: var(--accent-muted); } +.btn-ghost-danger:hover { + color: var(--danger); + border-color: var(--danger); + background: var(--danger-bg); +} + +.btn-danger { + background: var(--danger); + color: #fff; + border: 1px solid var(--danger); +} + +.btn-danger:hover { + background: #e0334f; + box-shadow: 0 0 12px rgba(255, 59, 92, 0.3); +} + +/* ── Modal ── */ + +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-dialog { + background: var(--bg-card-solid); + border: 1px solid var(--border-bright); + border-radius: var(--radius-lg); + padding: 24px 28px; + max-width: 460px; + width: 90%; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5); +} + +.modal-dialog h3 { + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; +} + +.modal-dialog p { + font-size: 14px; + color: var(--text-secondary); + line-height: 1.5; + margin-bottom: 8px; +} + +.modal-warning { + color: var(--danger) !important; + font-size: 13px !important; + background: var(--danger-bg); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin-top: 4px; +} + +.modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 20px; +} + .btn-secondary { background: transparent; color: var(--accent); @@ -1726,6 +1796,49 @@ tbody tr:last-child td { color: var(--text-secondary); } +.chat-embedding-building { + border-color: var(--border-accent); + background: rgba(0, 200, 255, 0.04); +} + +.chat-embedding-status { + display: flex; + align-items: center; + gap: 10px; + flex: 1; +} + +.chat-spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-bright); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.chat-progress-bar { + width: 120px; + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.chat-progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.5s var(--ease-out); + min-width: 2%; +} + .chat-embedding-banner .btn-sm { padding: 6px 14px; font-size: 12px; @@ -1947,3 +2060,380 @@ tbody tr:last-child td { opacity: 0.5; cursor: not-allowed; } + +/* ── SBOM Enhancements ── */ + +.sbom-tab-bar { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.sbom-tab { + padding: 10px 20px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-family: var(--font-display); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s var(--ease-out); +} + +.sbom-tab:hover { + color: var(--text-primary); +} + +.sbom-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.sbom-filter-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 16px; + align-items: center; +} + +.sbom-filter-select { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 8px 12px; + outline: none; + transition: border-color 0.2s var(--ease-out); + min-width: 140px; +} + +.sbom-filter-select:focus { + border-color: var(--accent); +} + +.sbom-filter-input { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: var(--font-body); + font-size: 13px; + padding: 8px 14px; + outline: none; + min-width: 200px; + transition: border-color 0.2s var(--ease-out); +} + +.sbom-filter-input:focus { + border-color: var(--accent); +} + +.sbom-filter-input::placeholder { + color: var(--text-tertiary); +} + +.sbom-result-count { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 12px; +} + +/* Export */ +.sbom-export-wrapper { + position: relative; + margin-left: auto; +} + +.sbom-export-btn { + font-size: 13px; +} + +.sbom-export-dropdown { + position: absolute; + top: 100%; + right: 0; + z-index: 50; + background: var(--bg-elevated); + border: 1px solid var(--border-bright); + border-radius: var(--radius); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + margin-top: 4px; +} + +.sbom-export-hint { + font-size: 11px; + color: var(--text-tertiary); +} + +.sbom-export-result { + margin-bottom: 16px; +} + +.sbom-export-result-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +/* Vulnerability drill-down */ +.sbom-vuln-toggle { + cursor: pointer; + user-select: none; +} + +.sbom-vuln-detail-row td { + padding: 0 !important; + background: var(--bg-secondary); +} + +.sbom-vuln-detail { + padding: 12px 16px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.sbom-vuln-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + min-width: 240px; + flex: 1; + max-width: 400px; +} + +.sbom-vuln-card-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; +} + +.sbom-vuln-id { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.sbom-vuln-source { + font-size: 11px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sbom-vuln-link { + font-size: 12px; + color: var(--accent); + text-decoration: none; + transition: color 0.15s; +} + +.sbom-vuln-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +/* License compliance */ +.sbom-license-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-weight: 500; + white-space: nowrap; +} + +.license-permissive { + background: var(--success-bg); + color: var(--success); + border: 1px solid rgba(0, 230, 118, 0.2); +} + +.license-weak-copyleft { + background: var(--warning-bg); + color: var(--warning); + border: 1px solid rgba(255, 176, 32, 0.2); +} + +.license-copyleft { + background: var(--danger-bg); + color: var(--danger); + border: 1px solid rgba(255, 59, 92, 0.2); +} + +.license-copyleft-warning { + background: var(--danger-bg); + border: 1px solid rgba(255, 59, 92, 0.3); + border-radius: var(--radius); + padding: 16px 20px; + margin-bottom: 16px; +} + +.license-copyleft-warning strong { + color: var(--danger); + font-size: 15px; + display: block; + margin-bottom: 6px; +} + +.license-copyleft-warning p { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 10px; +} + +.license-copyleft-item { + padding: 6px 0; + font-size: 13px; + color: var(--text-secondary); +} + +.license-pkg-list { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); +} + +.license-bar-chart { + display: flex; + flex-direction: column; + gap: 8px; +} + +.license-bar-row { + display: flex; + align-items: center; + gap: 12px; +} + +.license-bar-label { + font-size: 13px; + color: var(--text-secondary); + min-width: 120px; + text-align: right; + flex-shrink: 0; +} + +.license-bar-track { + flex: 1; + height: 20px; + background: var(--bg-secondary); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.license-bar { + height: 100%; + border-radius: var(--radius-sm); + transition: width 0.3s var(--ease-out); +} + +.license-bar.license-permissive { + background: var(--success); + border: none; +} + +.license-bar.license-copyleft { + background: var(--danger); + border: none; +} + +.license-bar-count { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-tertiary); + min-width: 32px; +} + +/* SBOM Diff */ +.sbom-diff-controls { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.sbom-diff-select-group { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 200px; +} + +.sbom-diff-select-group label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sbom-diff-summary { + display: flex; + gap: 12px; + margin: 16px 0; + flex-wrap: wrap; +} + +.sbom-diff-stat { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 20px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + min-width: 100px; + text-align: center; + font-size: 12px; + color: var(--text-secondary); +} + +.sbom-diff-stat-num { + font-family: var(--font-display); + font-size: 24px; + font-weight: 700; + color: var(--text-primary); +} + +.sbom-diff-added .sbom-diff-stat-num { + color: var(--success); +} + +.sbom-diff-removed .sbom-diff-stat-num { + color: var(--danger); +} + +.sbom-diff-changed .sbom-diff-stat-num { + color: var(--warning); +} + +.sbom-diff-row-added { + border-left: 3px solid var(--success); +} + +.sbom-diff-row-removed { + border-left: 3px solid var(--danger); +} + +.sbom-diff-row-changed { + border-left: 3px solid var(--warning); +} diff --git a/compliance-dashboard/src/infrastructure/repositories.rs b/compliance-dashboard/src/infrastructure/repositories.rs index eb2a18b..942a865 100644 --- a/compliance-dashboard/src/infrastructure/repositories.rs +++ b/compliance-dashboard/src/infrastructure/repositories.rs @@ -61,6 +61,32 @@ pub async fn add_repository( Ok(()) } +#[server] +pub async fn delete_repository(repo_id: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + let url = format!( + "{}/api/v1/repositories/{repo_id}", + state.agent_api_url + ); + + let client = reqwest::Client::new(); + let resp = client + .delete(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(ServerFnError::new(format!( + "Failed to delete repository: {body}" + ))); + } + + Ok(()) +} + #[server] pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> { let state: super::server_state::ServerState = diff --git a/compliance-dashboard/src/infrastructure/sbom.rs b/compliance-dashboard/src/infrastructure/sbom.rs index a456f0d..c5fb0a2 100644 --- a/compliance-dashboard/src/infrastructure/sbom.rs +++ b/compliance-dashboard/src/infrastructure/sbom.rs @@ -1,27 +1,202 @@ use dioxus::prelude::*; use serde::{Deserialize, Serialize}; -use compliance_core::models::SbomEntry; +// ── Local types (no bson dependency, WASM-safe) ── + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VulnRefData { + pub id: String, + pub source: String, + pub severity: Option, + pub url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomEntryData { + #[serde(rename = "_id", default)] + pub id: Option, + pub repo_id: String, + pub name: String, + pub version: String, + pub package_manager: String, + pub license: Option, + pub purl: Option, + #[serde(default)] + pub known_vulnerabilities: Vec, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, +} #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct SbomListResponse { - pub data: Vec, + pub data: Vec, pub total: Option, pub page: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LicenseSummaryData { + pub license: String, + pub count: u64, + pub is_copyleft: bool, + pub packages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LicenseSummaryResponse { + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffEntryData { + pub name: String, + pub version: String, + pub package_manager: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomVersionDiffData { + pub name: String, + pub package_manager: String, + pub version_a: String, + pub version_b: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffResultData { + pub only_in_a: Vec, + pub only_in_b: Vec, + pub version_changed: Vec, + pub common_count: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SbomDiffResponse { + pub data: SbomDiffResultData, +} + +// ── Server functions ── + #[server] -pub async fn fetch_sbom(page: u64) -> Result { +pub async fn fetch_sbom_filtered( + repo_id: Option, + package_manager: Option, + q: Option, + has_vulns: Option, + license: Option, + page: u64, +) -> Result { let state: super::server_state::ServerState = dioxus_fullstack::FullstackContext::extract().await?; - let url = format!("{}/api/v1/sbom?page={page}&limit=50", state.agent_api_url); + + let mut params = vec![format!("page={page}"), "limit=50".to_string()]; + if let Some(r) = &repo_id { + if !r.is_empty() { + params.push(format!("repo_id={r}")); + } + } + if let Some(pm) = &package_manager { + if !pm.is_empty() { + params.push(format!("package_manager={pm}")); + } + } + if let Some(q) = &q { + if !q.is_empty() { + params.push(format!("q={}", q.replace(' ', "%20"))); + } + } + if let Some(hv) = has_vulns { + params.push(format!("has_vulns={hv}")); + } + if let Some(l) = &license { + if !l.is_empty() { + params.push(format!("license={}", l.replace(' ', "%20"))); + } + } + + let url = format!("{}/api/v1/sbom?{}", state.agent_api_url, params.join("&")); let resp = reqwest::get(&url) .await .map_err(|e| ServerFnError::new(e.to_string()))?; - let body: SbomListResponse = resp - .json() + let text = resp + .text() .await .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: SbomListResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; + Ok(body) +} + +#[server] +pub async fn fetch_sbom_export(repo_id: String, format: String) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!( + "{}/api/v1/sbom/export?repo_id={}&format={}", + state.agent_api_url, repo_id, format + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(text) +} + +#[server] +pub async fn fetch_license_summary( + repo_id: Option, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let mut url = format!("{}/api/v1/sbom/licenses", state.agent_api_url); + if let Some(r) = &repo_id { + if !r.is_empty() { + url = format!("{url}?repo_id={r}"); + } + } + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: LicenseSummaryResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; + Ok(body) +} + +#[server] +pub async fn fetch_sbom_diff( + repo_a: String, + repo_b: String, +) -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!( + "{}/api/v1/sbom/diff?repo_a={}&repo_b={}", + state.agent_api_url, repo_a, repo_b + ); + + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let text = resp + .text() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: SbomDiffResponse = serde_json::from_str(&text) + .map_err(|e| ServerFnError::new(format!("Parse error: {e} — body: {text}")))?; Ok(body) } diff --git a/compliance-dashboard/src/pages/chat.rs b/compliance-dashboard/src/pages/chat.rs index 882dffb..e7cd02a 100644 --- a/compliance-dashboard/src/pages/chat.rs +++ b/compliance-dashboard/src/pages/chat.rs @@ -39,6 +39,36 @@ pub fn ChatPage(repo_id: String) -> Element { } }; + let is_running = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => resp + .data + .as_ref() + .map(|d| d.status == "running") + .unwrap_or(false), + _ => false, + } + }; + + let embed_progress = { + let status = embedding_status.read(); + match &*status { + Some(Some(resp)) => resp + .data + .as_ref() + .map(|d| { + if d.total_chunks > 0 { + (d.embedded_chunks as f64 / d.total_chunks as f64 * 100.0) as u32 + } else { + 0 + } + }) + .unwrap_or(0), + _ => 0, + } + }; + let embedding_status_text = { let status = embedding_status.read(); match &*status { @@ -49,8 +79,8 @@ pub fn ChatPage(repo_id: String) -> Element { d.embedded_chunks, d.total_chunks ), "running" => format!( - "Building embeddings: {}/{}...", - d.embedded_chunks, d.total_chunks + "Building embeddings: {}/{} chunks ({}%)", + d.embedded_chunks, d.total_chunks, embed_progress ), "failed" => format!( "Embedding build failed: {}", @@ -65,6 +95,19 @@ pub fn ChatPage(repo_id: String) -> Element { } }; + // Auto-poll embedding status every 3s while building/running + use_effect(move || { + if is_running || *building.read() { + spawn(async move { + #[cfg(feature = "web")] + gloo_timers::future::TimeoutFuture::new(3_000).await; + #[cfg(not(feature = "web"))] + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + embedding_status.restart(); + }); + } + }); + let repo_id_for_build = repo_id.clone(); let on_build = move |_| { let rid = repo_id_for_build.clone(); @@ -139,13 +182,26 @@ pub fn ChatPage(repo_id: String) -> Element { PageHeader { title: "AI Chat" } // Embedding status banner - div { class: "chat-embedding-banner", - span { "{embedding_status_text}" } + div { class: if is_running || *building.read() { "chat-embedding-banner chat-embedding-building" } else { "chat-embedding-banner" }, + div { class: "chat-embedding-status", + if is_running || *building.read() { + span { class: "chat-spinner" } + } + span { "{embedding_status_text}" } + } + if is_running || *building.read() { + div { class: "chat-progress-bar", + div { + class: "chat-progress-fill", + style: "width: {embed_progress}%;", + } + } + } button { class: "btn btn-sm", - disabled: *building.read(), + disabled: *building.read() || is_running, onclick: on_build, - if *building.read() { "Building..." } else { "Build Embeddings" } + if *building.read() || is_running { "Building..." } else { "Build Embeddings" } } } diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 91a6e4e..b1ff63b 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -13,6 +13,7 @@ pub fn RepositoriesPage() -> Element { let mut git_url = use_signal(String::new); let mut branch = use_signal(|| "main".to_string()); let mut toasts = use_context::(); + let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut repos = use_resource(move || { let p = page(); @@ -91,6 +92,48 @@ pub fn RepositoriesPage() -> Element { } } + // ── Delete confirmation dialog ── + if let Some((del_id, del_name)) = confirm_delete() { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Delete Repository" } + p { + "Are you sure you want to delete " + strong { "{del_name}" } + "?" + } + p { class: "modal-warning", + "This will permanently remove all associated findings, SBOM entries, scan runs, graph data, embeddings, and CVE alerts." + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + let id = del_id.clone(); + let name = del_name.clone(); + confirm_delete.set(None); + spawn(async move { + match crate::infrastructure::repositories::delete_repository(id).await { + Ok(_) => { + toasts.push(ToastType::Success, format!("{name} deleted")); + repos.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + }, + "Delete" + } + } + } + } + } + match &*repos.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); @@ -112,7 +155,9 @@ pub fn RepositoriesPage() -> Element { for repo in &resp.data { { let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); - let repo_id_clone = repo_id.clone(); + let repo_id_scan = repo_id.clone(); + let repo_id_del = repo_id.clone(); + let repo_name_del = repo.name.clone(); rsx! { tr { td { "{repo.name}" } @@ -149,7 +194,7 @@ pub fn RepositoriesPage() -> Element { button { class: "btn btn-ghost", onclick: move |_| { - let id = repo_id_clone.clone(); + let id = repo_id_scan.clone(); spawn(async move { match crate::infrastructure::repositories::trigger_repo_scan(id).await { Ok(_) => toasts.push(ToastType::Success, "Scan triggered"), @@ -159,6 +204,13 @@ pub fn RepositoriesPage() -> Element { }, "Scan" } + button { + class: "btn btn-ghost btn-ghost-danger", + onclick: move |_| { + confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone()))); + }, + "Delete" + } } } } diff --git a/compliance-dashboard/src/pages/sbom.rs b/compliance-dashboard/src/pages/sbom.rs index 0cfcb66..1779334 100644 --- a/compliance-dashboard/src/pages/sbom.rs +++ b/compliance-dashboard/src/pages/sbom.rs @@ -2,60 +2,335 @@ 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()); + let mut export_result = use_signal(|| Option::::None); + + // ── 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() + }); + + // ── SBOM list (filtered) ── let sbom = use_resource(move || { let p = page(); - async move { crate::infrastructure::sbom::fetch_sbom(p).await.ok() } + 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 across all repositories", + description: "Software Bill of Materials — dependency inventory, license compliance, and vulnerability analysis", } - match &*sbom.read() { - Some(Some(resp)) => { - let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1); - rsx! { - div { class: "card", - div { class: "table-wrapper", - table { - thead { - tr { - th { "Package" } - th { "Version" } - th { "Manager" } - th { "License" } - th { "Vulnerabilities" } + // ── 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}" } } } } - tbody { - for entry in &resp.data { + }, + _ => rsx! {}, + } + } + } + select { + class: "sbom-filter-select", + onchange: move |e| { pm_filter.set(e.value()); page.set(1); }, + option { value: "", "All Managers" } + option { value: "npm", "npm" } + option { value: "cargo", "Cargo" } + option { value: "pip", "pip" } + option { value: "go", "Go" } + option { value: "maven", "Maven" } + option { value: "nuget", "NuGet" } + option { value: "composer", "Composer" } + option { value: "gem", "RubyGems" } + } + 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" } + option { value: "MIT", "MIT" } + option { value: "Apache-2.0", "Apache 2.0" } + option { value: "BSD-3-Clause", "BSD 3-Clause" } + option { value: "ISC", "ISC" } + option { value: "GPL-3.0", "GPL 3.0" } + option { value: "GPL-2.0", "GPL 2.0" } + option { value: "LGPL-2.1", "LGPL 2.1" } + option { value: "MPL-2.0", "MPL 2.0" } + } + + // ── 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, fmt).await { + Ok(json) => export_result.set(Some(json)), + Err(e) => tracing::error!("Export failed: {e}"), + } + }); + }, + "Download" + } + if repo_filter().is_empty() { + span { class: "sbom-export-hint", "Select a repo first" } + } + } + } + } + } + + // ── Export result display ── + if let Some(json) = export_result() { + div { class: "card sbom-export-result", + div { class: "sbom-export-result-header", + strong { "Exported SBOM" } + button { + class: "btn btn-secondary", + onclick: move |_| export_result.set(None), + "Close" + } + } + pre { + style: "max-height: 400px; overflow: auto; font-size: 12px;", + "{json}" + } + } + } + + // ── 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 { - td { - style: "font-weight: 500;", - "{entry.name}" - } - td { - style: "font-family: monospace; font-size: 13px;", - "{entry.version}" - } - td { "{entry.package_manager}" } - td { "{entry.license.as_deref().unwrap_or(\"-\")}" } - td { - if entry.known_vulnerabilities.is_empty() { - span { - style: "color: var(--success);", - "None" + 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" + } + } + } } - } else { - span { class: "badge badge-high", - "{entry.known_vulnerabilities.len()} vuln(s)" + // ── 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 →" + } + } + } + } + } + } + } } } } @@ -63,21 +338,321 @@ pub fn SbomPage() -> Element { } } } + Pagination { + current_page: page(), + total_pages: total_pages, + on_page_change: move |p| page.set(p), + } } - 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! {}, + } + } } } } - }, - Some(None) => rsx! { - div { class: "card", p { "Failed to load SBOM." } } - }, - None => rsx! { - div { class: "loading", "Loading SBOM..." } - }, + } + + 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 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" + } +}