feat: rag-embedding-ai-chat #1
@@ -5,7 +5,10 @@ COPY . .
|
|||||||
RUN cargo build --release -p compliance-agent
|
RUN cargo build --release -p compliance-agent
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
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
|
COPY --from=builder /app/target/release/compliance-agent /usr/local/bin/compliance-agent
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ pub async fn get_graph(
|
|||||||
// so there is only one set of nodes/edges per repo.
|
// so there is only one set of nodes/edges per repo.
|
||||||
let filter = doc! { "repo_id": &repo_id };
|
let filter = doc! { "repo_id": &repo_id };
|
||||||
|
|
||||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
let all_nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
Err(_) => Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,17 @@ pub async fn get_graph(
|
|||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
Err(_) => Vec::new(),
|
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)
|
(nodes, edges)
|
||||||
} else {
|
} else {
|
||||||
(Vec::new(), Vec::new())
|
(Vec::new(), Vec::new())
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use axum::extract::{Extension, Path, Query};
|
use axum::extract::{Extension, Path, Query};
|
||||||
use axum::http::StatusCode;
|
use axum::http::{header, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use mongodb::bson::doc;
|
use mongodb::bson::doc;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -90,6 +91,72 @@ pub struct UpdateStatusRequest {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SbomFilter {
|
||||||
|
#[serde(default)]
|
||||||
|
pub repo_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub package_manager: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub q: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_vulns: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub license: Option<String>,
|
||||||
|
#[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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SbomDiffResult {
|
||||||
|
pub only_in_a: Vec<SbomDiffEntry>,
|
||||||
|
pub only_in_b: Vec<SbomDiffEntry>,
|
||||||
|
pub version_changed: Vec<SbomVersionDiff>,
|
||||||
|
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<Arc<ComplianceAgent>>;
|
type AgentExt = Extension<Arc<ComplianceAgent>>;
|
||||||
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
type ApiResult<T> = Result<Json<ApiResponse<T>>, StatusCode>;
|
||||||
|
|
||||||
@@ -236,6 +303,56 @@ pub async fn trigger_scan(
|
|||||||
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
Ok(Json(serde_json::json!({ "status": "scan_triggered" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_repository(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Json<serde_json::Value>, 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(
|
pub async fn list_findings(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(filter): Query<FindingsFilter>,
|
Query(filter): Query<FindingsFilter>,
|
||||||
@@ -323,21 +440,46 @@ pub async fn update_finding_status(
|
|||||||
|
|
||||||
pub async fn list_sbom(
|
pub async fn list_sbom(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
Query(params): Query<PaginationParams>,
|
Query(filter): Query<SbomFilter>,
|
||||||
) -> ApiResult<Vec<SbomEntry>> {
|
) -> ApiResult<Vec<SbomEntry>> {
|
||||||
let db = &agent.db;
|
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
|
let total = db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.count_documents(doc! {})
|
.count_documents(query.clone())
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let entries = match db
|
let entries = match db
|
||||||
.sbom_entries()
|
.sbom_entries()
|
||||||
.find(doc! {})
|
.find(query)
|
||||||
|
.sort(doc! { "name": 1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(params.limit)
|
.limit(filter.limit)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
@@ -347,7 +489,272 @@ pub async fn list_sbom(
|
|||||||
Ok(Json(ApiResponse {
|
Ok(Json(ApiResponse {
|
||||||
data: entries,
|
data: entries,
|
||||||
total: Some(total),
|
total: Some(total),
|
||||||
page: Some(params.page),
|
page: Some(filter.page),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn export_sbom(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Query(params): Query<SbomExportParams>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
let db = &agent.db;
|
||||||
|
let entries: Vec<SbomEntry> = 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<serde_json::Value> = 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<serde_json::Value> = 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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<SbomFilter>,
|
||||||
|
) -> ApiResult<Vec<LicenseSummary>> {
|
||||||
|
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<SbomEntry> = match db.sbom_entries().find(query).await {
|
||||||
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
|
Err(_) => Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut license_map: std::collections::HashMap<String, Vec<String>> =
|
||||||
|
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<LicenseSummary> = 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<SbomDiffParams>,
|
||||||
|
) -> ApiResult<SbomDiffResult> {
|
||||||
|
let db = &agent.db;
|
||||||
|
|
||||||
|
let entries_a: Vec<SbomEntry> = 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<SbomEntry> = 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<SbomDiffEntry> = 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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use axum::routing::{get, patch, post};
|
use axum::routing::{delete, get, patch, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
|
||||||
use crate::api::handlers;
|
use crate::api::handlers;
|
||||||
@@ -13,6 +13,10 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/repositories/{id}/scan",
|
"/api/v1/repositories/{id}/scan",
|
||||||
post(handlers::trigger_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", get(handlers::list_findings))
|
||||||
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
.route("/api/v1/findings/{id}", get(handlers::get_finding))
|
||||||
.route(
|
.route(
|
||||||
@@ -20,6 +24,9 @@ pub fn build_router() -> Router {
|
|||||||
patch(handlers::update_finding_status),
|
patch(handlers::update_finding_status),
|
||||||
)
|
)
|
||||||
.route("/api/v1/sbom", get(handlers::list_sbom))
|
.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/issues", get(handlers::list_issues))
|
||||||
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
.route("/api/v1/scan-runs", get(handlers::list_scan_runs))
|
||||||
// Graph API endpoints
|
// Graph API endpoints
|
||||||
|
|||||||
@@ -169,20 +169,20 @@
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
solver: "forceAtlas2Based",
|
solver: "forceAtlas2Based",
|
||||||
forceAtlas2Based: {
|
forceAtlas2Based: {
|
||||||
gravitationalConstant: -60,
|
gravitationalConstant: -80,
|
||||||
centralGravity: 0.012,
|
centralGravity: 0.005,
|
||||||
springLength: 80,
|
springLength: 120,
|
||||||
springConstant: 0.06,
|
springConstant: 0.04,
|
||||||
damping: 0.4,
|
damping: 0.5,
|
||||||
avoidOverlap: 0.5,
|
avoidOverlap: 0.6,
|
||||||
},
|
},
|
||||||
stabilization: {
|
stabilization: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
iterations: 1000,
|
iterations: 1500,
|
||||||
updateInterval: 25,
|
updateInterval: 25,
|
||||||
},
|
},
|
||||||
maxVelocity: 40,
|
maxVelocity: 50,
|
||||||
minVelocity: 0.1,
|
minVelocity: 0.75,
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
hover: true,
|
hover: true,
|
||||||
@@ -252,7 +252,24 @@
|
|||||||
overlay.style.display = "none";
|
overlay.style.display = "none";
|
||||||
}, 900);
|
}, 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(
|
console.log(
|
||||||
|
|||||||
@@ -603,6 +603,76 @@ tbody tr:last-child td {
|
|||||||
background: var(--accent-muted);
|
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 {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -1726,6 +1796,49 @@ tbody tr:last-child td {
|
|||||||
color: var(--text-secondary);
|
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 {
|
.chat-embedding-banner .btn-sm {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1947,3 +2060,380 @@ tbody tr:last-child td {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ pub async fn add_repository(
|
|||||||
Ok(())
|
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]
|
#[server]
|
||||||
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
pub async fn trigger_repo_scan(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
|
|||||||
@@ -1,27 +1,202 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
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<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomEntryData {
|
||||||
|
#[serde(rename = "_id", default)]
|
||||||
|
pub id: Option<serde_json::Value>,
|
||||||
|
pub repo_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
pub package_manager: String,
|
||||||
|
pub license: Option<String>,
|
||||||
|
pub purl: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub known_vulnerabilities: Vec<VulnRefData>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<serde_json::Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct SbomListResponse {
|
pub struct SbomListResponse {
|
||||||
pub data: Vec<SbomEntry>,
|
pub data: Vec<SbomEntryData>,
|
||||||
pub total: Option<u64>,
|
pub total: Option<u64>,
|
||||||
pub page: Option<u64>,
|
pub page: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryData {
|
||||||
|
pub license: String,
|
||||||
|
pub count: u64,
|
||||||
|
pub is_copyleft: bool,
|
||||||
|
pub packages: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LicenseSummaryResponse {
|
||||||
|
pub data: Vec<LicenseSummaryData>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<SbomDiffEntryData>,
|
||||||
|
pub only_in_b: Vec<SbomDiffEntryData>,
|
||||||
|
pub version_changed: Vec<SbomVersionDiffData>,
|
||||||
|
pub common_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct SbomDiffResponse {
|
||||||
|
pub data: SbomDiffResultData,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server functions ──
|
||||||
|
|
||||||
#[server]
|
#[server]
|
||||||
pub async fn fetch_sbom(page: u64) -> Result<SbomListResponse, ServerFnError> {
|
pub async fn fetch_sbom_filtered(
|
||||||
|
repo_id: Option<String>,
|
||||||
|
package_manager: Option<String>,
|
||||||
|
q: Option<String>,
|
||||||
|
has_vulns: Option<bool>,
|
||||||
|
license: Option<String>,
|
||||||
|
page: u64,
|
||||||
|
) -> Result<SbomListResponse, ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
dioxus_fullstack::FullstackContext::extract().await?;
|
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)
|
let resp = reqwest::get(&url)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
let body: SbomListResponse = resp
|
let text = resp
|
||||||
.json()
|
.text()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
.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<String, ServerFnError> {
|
||||||
|
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<String>,
|
||||||
|
) -> Result<LicenseSummaryResponse, ServerFnError> {
|
||||||
|
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<SbomDiffResponse, ServerFnError> {
|
||||||
|
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)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 embedding_status_text = {
|
||||||
let status = embedding_status.read();
|
let status = embedding_status.read();
|
||||||
match &*status {
|
match &*status {
|
||||||
@@ -49,8 +79,8 @@ pub fn ChatPage(repo_id: String) -> Element {
|
|||||||
d.embedded_chunks, d.total_chunks
|
d.embedded_chunks, d.total_chunks
|
||||||
),
|
),
|
||||||
"running" => format!(
|
"running" => format!(
|
||||||
"Building embeddings: {}/{}...",
|
"Building embeddings: {}/{} chunks ({}%)",
|
||||||
d.embedded_chunks, d.total_chunks
|
d.embedded_chunks, d.total_chunks, embed_progress
|
||||||
),
|
),
|
||||||
"failed" => format!(
|
"failed" => format!(
|
||||||
"Embedding build failed: {}",
|
"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 repo_id_for_build = repo_id.clone();
|
||||||
let on_build = move |_| {
|
let on_build = move |_| {
|
||||||
let rid = repo_id_for_build.clone();
|
let rid = repo_id_for_build.clone();
|
||||||
@@ -139,13 +182,26 @@ pub fn ChatPage(repo_id: String) -> Element {
|
|||||||
PageHeader { title: "AI Chat" }
|
PageHeader { title: "AI Chat" }
|
||||||
|
|
||||||
// Embedding status banner
|
// Embedding status banner
|
||||||
div { class: "chat-embedding-banner",
|
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}" }
|
span { "{embedding_status_text}" }
|
||||||
|
}
|
||||||
|
if is_running || *building.read() {
|
||||||
|
div { class: "chat-progress-bar",
|
||||||
|
div {
|
||||||
|
class: "chat-progress-fill",
|
||||||
|
style: "width: {embed_progress}%;",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
button {
|
button {
|
||||||
class: "btn btn-sm",
|
class: "btn btn-sm",
|
||||||
disabled: *building.read(),
|
disabled: *building.read() || is_running,
|
||||||
onclick: on_build,
|
onclick: on_build,
|
||||||
if *building.read() { "Building..." } else { "Build Embeddings" }
|
if *building.read() || is_running { "Building..." } else { "Build Embeddings" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
let mut git_url = use_signal(String::new);
|
let mut git_url = use_signal(String::new);
|
||||||
let mut branch = use_signal(|| "main".to_string());
|
let mut branch = use_signal(|| "main".to_string());
|
||||||
let mut toasts = use_context::<Toasts>();
|
let mut toasts = use_context::<Toasts>();
|
||||||
|
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
|
||||||
|
|
||||||
let mut repos = use_resource(move || {
|
let mut repos = use_resource(move || {
|
||||||
let p = page();
|
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() {
|
match &*repos.read() {
|
||||||
Some(Some(resp)) => {
|
Some(Some(resp)) => {
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1);
|
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 {
|
for repo in &resp.data {
|
||||||
{
|
{
|
||||||
let repo_id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
|
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! {
|
rsx! {
|
||||||
tr {
|
tr {
|
||||||
td { "{repo.name}" }
|
td { "{repo.name}" }
|
||||||
@@ -149,7 +194,7 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
button {
|
button {
|
||||||
class: "btn btn-ghost",
|
class: "btn btn-ghost",
|
||||||
onclick: move |_| {
|
onclick: move |_| {
|
||||||
let id = repo_id_clone.clone();
|
let id = repo_id_scan.clone();
|
||||||
spawn(async move {
|
spawn(async move {
|
||||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||||
@@ -159,6 +204,13 @@ pub fn RepositoriesPage() -> Element {
|
|||||||
},
|
},
|
||||||
"Scan"
|
"Scan"
|
||||||
}
|
}
|
||||||
|
button {
|
||||||
|
class: "btn btn-ghost btn-ghost-danger",
|
||||||
|
onclick: move |_| {
|
||||||
|
confirm_delete.set(Some((repo_id_del.clone(), repo_name_del.clone())));
|
||||||
|
},
|
||||||
|
"Delete"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,247 @@ use dioxus::prelude::*;
|
|||||||
|
|
||||||
use crate::components::page_header::PageHeader;
|
use crate::components::page_header::PageHeader;
|
||||||
use crate::components::pagination::Pagination;
|
use crate::components::pagination::Pagination;
|
||||||
|
use crate::infrastructure::sbom::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SbomPage() -> Element {
|
pub fn SbomPage() -> Element {
|
||||||
|
// ── Filter signals ──
|
||||||
let mut page = use_signal(|| 1u64);
|
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());
|
||||||
|
let mut export_result = use_signal(|| Option::<String>::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 sbom = use_resource(move || {
|
||||||
let p = page();
|
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! {
|
rsx! {
|
||||||
PageHeader {
|
PageHeader {
|
||||||
title: "SBOM",
|
title: "SBOM",
|
||||||
description: "Software Bill of Materials - dependency inventory across all repositories",
|
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" }
|
||||||
|
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() {
|
match &*sbom.read() {
|
||||||
Some(Some(resp)) => {
|
Some(Some(resp)) => {
|
||||||
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
let total_pages = resp.total.unwrap_or(0).div_ceil(50).max(1);
|
||||||
rsx! {
|
rsx! {
|
||||||
|
if let Some(total) = resp.total {
|
||||||
|
div { class: "sbom-result-count",
|
||||||
|
"{total} package(s) found"
|
||||||
|
}
|
||||||
|
}
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "table-wrapper",
|
div { class: "table-wrapper",
|
||||||
table {
|
table {
|
||||||
@@ -36,26 +257,79 @@ pub fn SbomPage() -> Element {
|
|||||||
}
|
}
|
||||||
tbody {
|
tbody {
|
||||||
for entry in &resp.data {
|
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 {
|
tr {
|
||||||
td {
|
td {
|
||||||
style: "font-weight: 500;",
|
style: "font-weight: 500;",
|
||||||
"{entry.name}"
|
"{entry.name}"
|
||||||
}
|
}
|
||||||
td {
|
td {
|
||||||
style: "font-family: monospace; font-size: 13px;",
|
style: "font-family: var(--font-mono, monospace); font-size: 13px;",
|
||||||
"{entry.version}"
|
"{entry.version}"
|
||||||
}
|
}
|
||||||
td { "{entry.package_manager}" }
|
td { "{entry.package_manager}" }
|
||||||
td { "{entry.license.as_deref().unwrap_or(\"-\")}" }
|
|
||||||
td {
|
td {
|
||||||
if entry.known_vulnerabilities.is_empty() {
|
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 {
|
span {
|
||||||
style: "color: var(--success);",
|
style: "color: var(--success);",
|
||||||
"None"
|
"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 →"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,4 +354,305 @@ pub fn SbomPage() -> Element {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════ 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 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user