feat: rag-embedding-ai-chat (#1)
Co-authored-by: Sharang Parnerkar <parnerkarsharang@gmail.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -26,6 +26,10 @@ pub enum Route {
|
||||
GraphExplorerPage { repo_id: String },
|
||||
#[route("/graph/:repo_id/impact/:finding_id")]
|
||||
ImpactAnalysisPage { repo_id: String, finding_id: String },
|
||||
#[route("/chat")]
|
||||
ChatIndexPage {},
|
||||
#[route("/chat/:repo_id")]
|
||||
ChatPage { repo_id: String },
|
||||
#[route("/dast")]
|
||||
DastOverviewPage {},
|
||||
#[route("/dast/targets")]
|
||||
|
||||
@@ -47,17 +47,19 @@ fn insert_path(
|
||||
let name = parts[0].to_string();
|
||||
let is_leaf = parts.len() == 1;
|
||||
|
||||
let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode {
|
||||
name: name.clone(),
|
||||
path: if is_leaf {
|
||||
full_path.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
is_dir: !is_leaf,
|
||||
node_count: 0,
|
||||
children: Vec::new(),
|
||||
});
|
||||
let entry = children
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| FileTreeNode {
|
||||
name: name.clone(),
|
||||
path: if is_leaf {
|
||||
full_path.to_string()
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
is_dir: !is_leaf,
|
||||
node_count: 0,
|
||||
children: Vec::new(),
|
||||
});
|
||||
|
||||
if is_leaf {
|
||||
entry.node_count = node_count;
|
||||
|
||||
@@ -46,6 +46,11 @@ pub fn Sidebar() -> Element {
|
||||
route: Route::GraphIndexPage {},
|
||||
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "AI Chat",
|
||||
route: Route::ChatIndexPage {},
|
||||
icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } },
|
||||
},
|
||||
NavItem {
|
||||
label: "DAST",
|
||||
route: Route::DastOverviewPage {},
|
||||
@@ -58,7 +63,11 @@ pub fn Sidebar() -> Element {
|
||||
},
|
||||
];
|
||||
|
||||
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
|
||||
let sidebar_class = if collapsed() {
|
||||
"sidebar collapsed"
|
||||
} else {
|
||||
"sidebar"
|
||||
};
|
||||
|
||||
rsx! {
|
||||
nav { class: "{sidebar_class}",
|
||||
@@ -76,6 +85,7 @@ pub fn Sidebar() -> Element {
|
||||
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
|
||||
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
|
||||
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
|
||||
(Route::ChatPage { .. }, Route::ChatIndexPage {}) => true,
|
||||
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
|
||||
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
|
||||
|
||||
@@ -20,6 +20,12 @@ pub struct Toasts {
|
||||
next_id: Signal<usize>,
|
||||
}
|
||||
|
||||
impl Default for Toasts {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Toasts {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -39,11 +45,11 @@ impl Toasts {
|
||||
|
||||
#[cfg(feature = "web")]
|
||||
{
|
||||
let mut items = self.items;
|
||||
spawn(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
||||
items.write().retain(|t| t.id != id);
|
||||
});
|
||||
let mut items = self.items;
|
||||
spawn(async move {
|
||||
gloo_timers::future::TimeoutFuture::new(4_000).await;
|
||||
items.write().retain(|t| t.id != id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
126
compliance-dashboard/src/infrastructure/chat.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ── Response types ──
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ChatApiResponse {
|
||||
pub data: ChatResponseData,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ChatResponseData {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub sources: Vec<SourceRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SourceRef {
|
||||
pub file_path: String,
|
||||
pub qualified_name: String,
|
||||
pub start_line: u32,
|
||||
pub end_line: u32,
|
||||
pub language: String,
|
||||
pub snippet: String,
|
||||
pub score: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EmbeddingStatusResponse {
|
||||
pub data: Option<EmbeddingBuildData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct EmbeddingBuildData {
|
||||
pub repo_id: String,
|
||||
pub status: String,
|
||||
pub total_chunks: u32,
|
||||
pub embedded_chunks: u32,
|
||||
pub embedding_model: String,
|
||||
pub error_message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub started_at: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub completed_at: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ── Chat message history type ──
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatHistoryMessage {
|
||||
pub role: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
// ── Server functions ──
|
||||
|
||||
#[server]
|
||||
pub async fn send_chat_message(
|
||||
repo_id: String,
|
||||
message: String,
|
||||
history: Vec<ChatHistoryMessage>,
|
||||
) -> Result<ChatApiResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let url = format!("{}/api/v1/chat/{repo_id}", state.agent_api_url);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&serde_json::json!({
|
||||
"message": message,
|
||||
"history": history,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Request failed: {e}")))?;
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to read response: {e}")))?;
|
||||
|
||||
let body: ChatApiResponse = serde_json::from_str(&text)
|
||||
.map_err(|e| ServerFnError::new(format!("Failed to parse response: {e} — body: {text}")))?;
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn trigger_embedding_build(repo_id: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v1/chat/{repo_id}/build-embeddings",
|
||||
state.agent_api_url
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn fetch_embedding_status(
|
||||
repo_id: String,
|
||||
) -> Result<EmbeddingStatusResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
|
||||
let url = format!("{}/api/v1/chat/{repo_id}/status", state.agent_api_url);
|
||||
let resp = reqwest::get(&url)
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
let body: EmbeddingStatusResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
Ok(body)
|
||||
}
|
||||
@@ -87,10 +87,7 @@ pub async fn fetch_dast_finding_detail(
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn add_dast_target(
|
||||
name: String,
|
||||
base_url: String,
|
||||
) -> Result<(), ServerFnError> {
|
||||
pub async fn add_dast_target(name: String, base_url: String) -> Result<(), ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
|
||||
|
||||
@@ -121,10 +121,7 @@ pub async fn fetch_file_content(
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn search_nodes(
|
||||
repo_id: String,
|
||||
query: String,
|
||||
) -> Result<SearchResponse, ServerFnError> {
|
||||
pub async fn search_nodes(repo_id: String, query: String) -> Result<SearchResponse, ServerFnError> {
|
||||
let state: super::server_state::ServerState =
|
||||
dioxus_fullstack::FullstackContext::extract().await?;
|
||||
let url = format!(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Server function modules (compiled for both web and server;
|
||||
// the #[server] macro generates client stubs for the web target)
|
||||
pub mod chat;
|
||||
pub mod dast;
|
||||
pub mod findings;
|
||||
pub mod graph;
|
||||
|
||||
@@ -61,6 +61,29 @@ 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 =
|
||||
|
||||
@@ -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<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)]
|
||||
pub struct SbomListResponse {
|
||||
pub data: Vec<SbomEntry>,
|
||||
pub data: Vec<SbomEntryData>,
|
||||
pub total: 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]
|
||||
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 =
|
||||
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<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)
|
||||
}
|
||||
|
||||
288
compliance-dashboard/src/pages/chat.rs
Normal file
288
compliance-dashboard/src/pages/chat.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::chat::{
|
||||
fetch_embedding_status, send_chat_message, trigger_embedding_build, ChatHistoryMessage,
|
||||
SourceRef,
|
||||
};
|
||||
|
||||
/// A UI-level chat message
|
||||
#[derive(Clone, Debug)]
|
||||
struct UiChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
sources: Vec<SourceRef>,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn ChatPage(repo_id: String) -> Element {
|
||||
let mut messages: Signal<Vec<UiChatMessage>> = use_signal(Vec::new);
|
||||
let mut input_text = use_signal(String::new);
|
||||
let mut loading = use_signal(|| false);
|
||||
let mut building = use_signal(|| false);
|
||||
|
||||
let repo_id_for_status = repo_id.clone();
|
||||
let mut embedding_status = use_resource(move || {
|
||||
let rid = repo_id_for_status.clone();
|
||||
async move { fetch_embedding_status(rid).await.ok() }
|
||||
});
|
||||
|
||||
let has_embeddings = {
|
||||
let status = embedding_status.read();
|
||||
match &*status {
|
||||
Some(Some(resp)) => resp
|
||||
.data
|
||||
.as_ref()
|
||||
.map(|d| d.status == "completed")
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
Some(Some(resp)) => match &resp.data {
|
||||
Some(d) => match d.status.as_str() {
|
||||
"completed" => format!(
|
||||
"Embeddings ready: {}/{} chunks",
|
||||
d.embedded_chunks, d.total_chunks
|
||||
),
|
||||
"running" => format!(
|
||||
"Building embeddings: {}/{} chunks ({}%)",
|
||||
d.embedded_chunks, d.total_chunks, embed_progress
|
||||
),
|
||||
"failed" => format!(
|
||||
"Embedding build failed: {}",
|
||||
d.error_message.as_deref().unwrap_or("unknown error")
|
||||
),
|
||||
s => format!("Status: {s}"),
|
||||
},
|
||||
None => "No embeddings built yet".to_string(),
|
||||
},
|
||||
Some(None) => "Failed to check embedding status".to_string(),
|
||||
None => "Checking embedding status...".to_string(),
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
building.set(true);
|
||||
spawn(async move {
|
||||
let _ = trigger_embedding_build(rid).await;
|
||||
building.set(false);
|
||||
embedding_status.restart();
|
||||
});
|
||||
};
|
||||
|
||||
let repo_id_for_send = repo_id.clone();
|
||||
let mut do_send = move || {
|
||||
let text = input_text.read().trim().to_string();
|
||||
if text.is_empty() || *loading.read() {
|
||||
return;
|
||||
}
|
||||
|
||||
let rid = repo_id_for_send.clone();
|
||||
let user_msg = text.clone();
|
||||
|
||||
// Add user message to UI
|
||||
messages.write().push(UiChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: user_msg.clone(),
|
||||
sources: Vec::new(),
|
||||
});
|
||||
input_text.set(String::new());
|
||||
loading.set(true);
|
||||
|
||||
spawn(async move {
|
||||
// Build history from existing messages
|
||||
let history: Vec<ChatHistoryMessage> = messages
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|m| m.role == "user" || m.role == "assistant")
|
||||
.rev()
|
||||
.skip(1) // skip the message we just added
|
||||
.take(10) // limit history
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|m| ChatHistoryMessage {
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
match send_chat_message(rid, user_msg, history).await {
|
||||
Ok(resp) => {
|
||||
messages.write().push(UiChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: resp.data.message,
|
||||
sources: resp.data.sources,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
messages.write().push(UiChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: format!("Error: {e}"),
|
||||
sources: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
loading.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
let mut do_send_click = do_send.clone();
|
||||
|
||||
rsx! {
|
||||
PageHeader { title: "AI Chat" }
|
||||
|
||||
// Embedding status 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}" }
|
||||
}
|
||||
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() || is_running,
|
||||
onclick: on_build,
|
||||
if *building.read() || is_running { "Building..." } else { "Build Embeddings" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "chat-container",
|
||||
// Message list
|
||||
div { class: "chat-messages",
|
||||
if messages.read().is_empty() && !*loading.read() {
|
||||
div { class: "chat-empty",
|
||||
h3 { "Ask anything about your codebase" }
|
||||
p { "Build embeddings first, then ask questions about functions, architecture, patterns, and more." }
|
||||
}
|
||||
}
|
||||
for (i, msg) in messages.read().iter().enumerate() {
|
||||
{
|
||||
let class = if msg.role == "user" {
|
||||
"chat-message chat-message-user"
|
||||
} else {
|
||||
"chat-message chat-message-assistant"
|
||||
};
|
||||
let content = msg.content.clone();
|
||||
let sources = msg.sources.clone();
|
||||
rsx! {
|
||||
div { class: class, key: "{i}",
|
||||
div { class: "chat-message-role",
|
||||
if msg.role == "user" { "You" } else { "Assistant" }
|
||||
}
|
||||
div { class: "chat-message-content", "{content}" }
|
||||
if !sources.is_empty() {
|
||||
div { class: "chat-sources",
|
||||
span { class: "chat-sources-label", "Sources:" }
|
||||
for src in sources {
|
||||
div { class: "chat-source-card",
|
||||
div { class: "chat-source-header",
|
||||
span { class: "chat-source-name",
|
||||
"{src.qualified_name}"
|
||||
}
|
||||
span { class: "chat-source-location",
|
||||
"{src.file_path}:{src.start_line}-{src.end_line}"
|
||||
}
|
||||
}
|
||||
pre { class: "chat-source-snippet",
|
||||
code { "{src.snippet}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if *loading.read() {
|
||||
div { class: "chat-message chat-message-assistant",
|
||||
div { class: "chat-message-role", "Assistant" }
|
||||
div { class: "chat-message-content chat-typing", "Thinking..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Input area
|
||||
div { class: "chat-input-area",
|
||||
textarea {
|
||||
class: "chat-input",
|
||||
placeholder: "Ask about your codebase...",
|
||||
value: "{input_text}",
|
||||
disabled: !has_embeddings,
|
||||
oninput: move |e| input_text.set(e.value()),
|
||||
onkeydown: move |e: Event<KeyboardData>| {
|
||||
if e.key() == Key::Enter && !e.modifiers().shift() {
|
||||
e.prevent_default();
|
||||
do_send();
|
||||
}
|
||||
},
|
||||
}
|
||||
button {
|
||||
class: "btn chat-send-btn",
|
||||
disabled: *loading.read() || !has_embeddings,
|
||||
onclick: move |_| do_send_click(),
|
||||
"Send"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
70
compliance-dashboard/src/pages/chat_index.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
|
||||
#[component]
|
||||
pub fn ChatIndexPage() -> Element {
|
||||
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "AI Chat",
|
||||
description: "Ask questions about your codebase using RAG-augmented AI",
|
||||
}
|
||||
|
||||
match &*repos.read() {
|
||||
Some(Some(data)) => {
|
||||
let repo_list = &data.data;
|
||||
if repo_list.is_empty() {
|
||||
rsx! {
|
||||
div { class: "card",
|
||||
p { "No repositories found. Add a repository first." }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "graph-index-grid",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let url = repo.git_url.clone();
|
||||
let branch = repo.default_branch.clone();
|
||||
rsx! {
|
||||
Link {
|
||||
to: Route::ChatPage { repo_id },
|
||||
class: "graph-repo-card",
|
||||
div { class: "graph-repo-card-header",
|
||||
div { class: "graph-repo-card-icon", "\u{1F4AC}" }
|
||||
h3 { class: "graph-repo-card-name", "{name}" }
|
||||
}
|
||||
if !url.is_empty() {
|
||||
p { class: "graph-repo-card-url", "{url}" }
|
||||
}
|
||||
div { class: "graph-repo-card-meta",
|
||||
span { class: "graph-repo-card-tag",
|
||||
"\u{E0A0} {branch}"
|
||||
}
|
||||
span { class: "graph-repo-card-tag",
|
||||
"AI Chat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
div { class: "card", p { "Failed to load repositories." } }
|
||||
},
|
||||
None => rsx! {
|
||||
div { class: "loading", "Loading repositories..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ pub fn DastFindingsPage() -> Element {
|
||||
}
|
||||
td {
|
||||
Link {
|
||||
to: Route::DastFindingDetailPage { id: id },
|
||||
to: Route::DastFindingDetailPage { id },
|
||||
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ pub fn FindingsPage() -> Element {
|
||||
let mut repo_filter = use_signal(String::new);
|
||||
|
||||
let repos = use_resource(|| async {
|
||||
crate::infrastructure::repositories::fetch_repositories(1).await.ok()
|
||||
crate::infrastructure::repositories::fetch_repositories(1)
|
||||
.await
|
||||
.ok()
|
||||
});
|
||||
|
||||
let findings = use_resource(move || {
|
||||
|
||||
@@ -27,13 +27,13 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
let mut inspector_open = use_signal(|| false);
|
||||
|
||||
// Search state
|
||||
let mut search_query = use_signal(|| String::new());
|
||||
let mut search_results = use_signal(|| Vec::<serde_json::Value>::new());
|
||||
let mut file_filter = use_signal(|| String::new());
|
||||
let mut search_query = use_signal(String::new);
|
||||
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
|
||||
let mut file_filter = use_signal(String::new);
|
||||
|
||||
// Store serialized graph JSON in signals so use_effect can react to them
|
||||
let mut nodes_json = use_signal(|| String::new());
|
||||
let mut edges_json = use_signal(|| String::new());
|
||||
let mut nodes_json = use_signal(String::new);
|
||||
let mut edges_json = use_signal(String::new);
|
||||
let mut graph_ready = use_signal(|| false);
|
||||
|
||||
// When resource resolves, serialize the data into signals
|
||||
@@ -404,7 +404,7 @@ pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
} else if node_count > 0 {
|
||||
// Data exists but nodes array was empty (shouldn't happen)
|
||||
div { class: "loading", "Loading graph visualization..." }
|
||||
} else if matches!(&*graph_data.read(), None) {
|
||||
} else if (*graph_data.read()).is_none() {
|
||||
div { class: "loading", "Loading graph data..." }
|
||||
} else {
|
||||
div { class: "graph-empty-state",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub mod chat;
|
||||
pub mod chat_index;
|
||||
pub mod dast_finding_detail;
|
||||
pub mod dast_findings;
|
||||
pub mod dast_overview;
|
||||
@@ -13,6 +15,8 @@ pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod settings;
|
||||
|
||||
pub use chat::ChatPage;
|
||||
pub use chat_index::ChatIndexPage;
|
||||
pub use dast_finding_detail::DastFindingDetailPage;
|
||||
pub use dast_findings::DastFindingsPage;
|
||||
pub use dast_overview::DastOverviewPage;
|
||||
|
||||
@@ -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::<Toasts>();
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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 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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user