From 49d5cd4e0aaf11b253087a2d33722f2507911215 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Mon, 30 Mar 2026 10:39:39 +0000 Subject: [PATCH] feat: hourly CVE alerting with notification bell and API (#53) --- Dockerfile.dashboard | 2 +- compliance-agent/src/api/handlers/mod.rs | 1 + .../src/api/handlers/notifications.rs | 178 ++++++++++++++++++ compliance-agent/src/api/routes.rs | 21 +++ compliance-agent/src/config.rs | 2 +- compliance-agent/src/database.rs | 25 +++ compliance-agent/src/scheduler.rs | 150 ++++++++++++++- compliance-core/src/models/mod.rs | 2 + compliance-core/src/models/notification.rs | 103 ++++++++++ compliance-dashboard/assets/main.css | 30 +++ .../src/components/app_shell.rs | 2 + compliance-dashboard/src/components/mod.rs | 1 + .../src/components/notification_bell.rs | 155 +++++++++++++++ .../src/infrastructure/mod.rs | 1 + .../src/infrastructure/notifications.rs | 91 +++++++++ 15 files changed, 754 insertions(+), 10 deletions(-) create mode 100644 compliance-agent/src/api/handlers/notifications.rs create mode 100644 compliance-core/src/models/notification.rs create mode 100644 compliance-dashboard/src/components/notification_bell.rs create mode 100644 compliance-dashboard/src/infrastructure/notifications.rs diff --git a/Dockerfile.dashboard b/Dockerfile.dashboard index b535505..7cdd7a6 100644 --- a/Dockerfile.dashboard +++ b/Dockerfile.dashboard @@ -1,6 +1,6 @@ FROM rust:1.94-bookworm AS builder -RUN cargo install dioxus-cli --version 0.7.4 +RUN cargo install dioxus-cli --version 0.7.3 --locked ARG DOCS_URL=/docs diff --git a/compliance-agent/src/api/handlers/mod.rs b/compliance-agent/src/api/handlers/mod.rs index 7902b26..ea1d74d 100644 --- a/compliance-agent/src/api/handlers/mod.rs +++ b/compliance-agent/src/api/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod graph; pub mod health; pub mod help_chat; pub mod issues; +pub mod notifications; pub mod pentest_handlers; pub use pentest_handlers as pentest; pub mod repos; diff --git a/compliance-agent/src/api/handlers/notifications.rs b/compliance-agent/src/api/handlers/notifications.rs new file mode 100644 index 0000000..2f82b8c --- /dev/null +++ b/compliance-agent/src/api/handlers/notifications.rs @@ -0,0 +1,178 @@ +use axum::extract::Extension; +use axum::http::StatusCode; +use axum::Json; +use mongodb::bson::doc; +use serde::Deserialize; + +use compliance_core::models::notification::CveNotification; + +use super::dto::{AgentExt, ApiResponse}; + +/// GET /api/v1/notifications — List CVE notifications (newest first) +#[tracing::instrument(skip_all)] +pub async fn list_notifications( + Extension(agent): AgentExt, + axum::extract::Query(params): axum::extract::Query, +) -> Result>>, StatusCode> { + let mut filter = doc! {}; + + // Filter by status (default: show new + read, exclude dismissed) + match params.status.as_deref() { + Some("all") => {} + Some(s) => { + filter.insert("status", s); + } + None => { + filter.insert("status", doc! { "$in": ["new", "read"] }); + } + } + + // Filter by severity + if let Some(ref sev) = params.severity { + filter.insert("severity", sev.as_str()); + } + + // Filter by repo + if let Some(ref repo_id) = params.repo_id { + filter.insert("repo_id", repo_id.as_str()); + } + + let page = params.page.unwrap_or(1).max(1); + let limit = params.limit.unwrap_or(50).min(200); + let skip = (page - 1) * limit as u64; + + let total = agent + .db + .cve_notifications() + .count_documents(filter.clone()) + .await + .unwrap_or(0); + + let notifications: Vec = match agent + .db + .cve_notifications() + .find(filter) + .sort(doc! { "created_at": -1 }) + .skip(skip) + .limit(limit) + .await + { + Ok(cursor) => { + use futures_util::StreamExt; + let mut items = Vec::new(); + let mut cursor = cursor; + while let Some(Ok(n)) = cursor.next().await { + items.push(n); + } + items + } + Err(e) => { + tracing::error!("Failed to list notifications: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + Ok(Json(ApiResponse { + data: notifications, + total: Some(total), + page: Some(page), + })) +} + +/// GET /api/v1/notifications/count — Count of unread notifications +#[tracing::instrument(skip_all)] +pub async fn notification_count( + Extension(agent): AgentExt, +) -> Result, StatusCode> { + let count = agent + .db + .cve_notifications() + .count_documents(doc! { "status": "new" }) + .await + .unwrap_or(0); + + Ok(Json(serde_json::json!({ "count": count }))) +} + +/// PATCH /api/v1/notifications/:id/read — Mark a notification as read +#[tracing::instrument(skip_all, fields(id = %id))] +pub async fn mark_read( + Extension(agent): AgentExt, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + + let result = agent + .db + .cve_notifications() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { + "status": "read", + "read_at": mongodb::bson::DateTime::now(), + }}, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.matched_count == 0 { + return Err(StatusCode::NOT_FOUND); + } + Ok(Json(serde_json::json!({ "status": "read" }))) +} + +/// PATCH /api/v1/notifications/:id/dismiss — Dismiss a notification +#[tracing::instrument(skip_all, fields(id = %id))] +pub async fn dismiss_notification( + Extension(agent): AgentExt, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let oid = mongodb::bson::oid::ObjectId::parse_str(&id).map_err(|_| StatusCode::BAD_REQUEST)?; + + let result = agent + .db + .cve_notifications() + .update_one( + doc! { "_id": oid }, + doc! { "$set": { "status": "dismissed" } }, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if result.matched_count == 0 { + return Err(StatusCode::NOT_FOUND); + } + Ok(Json(serde_json::json!({ "status": "dismissed" }))) +} + +/// POST /api/v1/notifications/read-all — Mark all new notifications as read +#[tracing::instrument(skip_all)] +pub async fn mark_all_read( + Extension(agent): AgentExt, +) -> Result, StatusCode> { + let result = agent + .db + .cve_notifications() + .update_many( + doc! { "status": "new" }, + doc! { "$set": { + "status": "read", + "read_at": mongodb::bson::DateTime::now(), + }}, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json( + serde_json::json!({ "updated": result.modified_count }), + )) +} + +#[derive(Debug, Deserialize)] +pub struct NotificationFilter { + pub status: Option, + pub severity: Option, + pub repo_id: Option, + pub page: Option, + pub limit: Option, +} diff --git a/compliance-agent/src/api/routes.rs b/compliance-agent/src/api/routes.rs index e2f0ec0..c715df4 100644 --- a/compliance-agent/src/api/routes.rs +++ b/compliance-agent/src/api/routes.rs @@ -101,6 +101,27 @@ pub fn build_router() -> Router { ) // Help chat (documentation-grounded Q&A) .route("/api/v1/help/chat", post(handlers::help_chat::help_chat)) + // CVE notification endpoints + .route( + "/api/v1/notifications", + get(handlers::notifications::list_notifications), + ) + .route( + "/api/v1/notifications/count", + get(handlers::notifications::notification_count), + ) + .route( + "/api/v1/notifications/read-all", + post(handlers::notifications::mark_all_read), + ) + .route( + "/api/v1/notifications/{id}/read", + patch(handlers::notifications::mark_read), + ) + .route( + "/api/v1/notifications/{id}/dismiss", + patch(handlers::notifications::dismiss_notification), + ) // Pentest API endpoints .route( "/api/v1/pentest/lookup-repo", diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index 3361ad8..b58ebab 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -42,7 +42,7 @@ pub fn load_config() -> Result { .unwrap_or(3001), scan_schedule: env_var_opt("SCAN_SCHEDULE").unwrap_or_else(|| "0 0 */6 * * *".to_string()), cve_monitor_schedule: env_var_opt("CVE_MONITOR_SCHEDULE") - .unwrap_or_else(|| "0 0 0 * * *".to_string()), + .unwrap_or_else(|| "0 0 * * * *".to_string()), git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH") .unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()), ssh_key_path: env_var_opt("SSH_KEY_PATH") diff --git a/compliance-agent/src/database.rs b/compliance-agent/src/database.rs index 6b0c0d9..80d3b04 100644 --- a/compliance-agent/src/database.rs +++ b/compliance-agent/src/database.rs @@ -78,6 +78,25 @@ impl Database { ) .await?; + // cve_notifications: unique cve_id + repo_id + package, status filter + self.cve_notifications() + .create_index( + IndexModel::builder() + .keys( + doc! { "cve_id": 1, "repo_id": 1, "package_name": 1, "package_version": 1 }, + ) + .options(IndexOptions::builder().unique(true).build()) + .build(), + ) + .await?; + self.cve_notifications() + .create_index( + IndexModel::builder() + .keys(doc! { "status": 1, "created_at": -1 }) + .build(), + ) + .await?; + // tracker_issues: unique finding_id self.tracker_issues() .create_index( @@ -222,6 +241,12 @@ impl Database { self.inner.collection("cve_alerts") } + pub fn cve_notifications( + &self, + ) -> Collection { + self.inner.collection("cve_notifications") + } + pub fn tracker_issues(&self) -> Collection { self.inner.collection("tracker_issues") } diff --git a/compliance-agent/src/scheduler.rs b/compliance-agent/src/scheduler.rs index 66de09c..2ce907e 100644 --- a/compliance-agent/src/scheduler.rs +++ b/compliance-agent/src/scheduler.rs @@ -82,24 +82,158 @@ async fn scan_all_repos(agent: &ComplianceAgent) { } async fn monitor_cves(agent: &ComplianceAgent) { + use compliance_core::models::notification::{parse_severity, CveNotification}; + use compliance_core::models::SbomEntry; use futures_util::StreamExt; - // Re-scan all SBOM entries for new CVEs + // Fetch all SBOM entries grouped by repo let cursor = match agent.db.sbom_entries().find(doc! {}).await { Ok(c) => c, Err(e) => { - tracing::error!("Failed to list SBOM entries for CVE monitoring: {e}"); + tracing::error!("CVE monitor: failed to list SBOM entries: {e}"); return; } }; - - let entries: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await; - + let entries: Vec = cursor.filter_map(|r| async { r.ok() }).collect().await; if entries.is_empty() { + tracing::debug!("CVE monitor: no SBOM entries, skipping"); return; } - tracing::info!("CVE monitor: checking {} dependencies", entries.len()); - // The actual CVE checking is handled by the CveScanner in the pipeline - // This is a simplified version that just logs the activity + tracing::info!( + "CVE monitor: checking {} dependencies for new CVEs", + entries.len() + ); + + // Build a repo_id → repo_name lookup + let repo_ids: std::collections::HashSet = + entries.iter().map(|e| e.repo_id.clone()).collect(); + let mut repo_names: std::collections::HashMap = + std::collections::HashMap::new(); + for rid in &repo_ids { + if let Ok(oid) = mongodb::bson::oid::ObjectId::parse_str(rid) { + if let Ok(Some(repo)) = agent.db.repositories().find_one(doc! { "_id": oid }).await { + repo_names.insert(rid.clone(), repo.name.clone()); + } + } + } + + // Use the existing CveScanner to query OSV.dev + let nvd_key = agent.config.nvd_api_key.as_ref().map(|k| { + use secrecy::ExposeSecret; + k.expose_secret().to_string() + }); + let scanner = crate::pipeline::cve::CveScanner::new( + agent.http.clone(), + agent.config.searxng_url.clone(), + nvd_key, + ); + + // Group entries by repo for scanning + let mut entries_by_repo: std::collections::HashMap> = + std::collections::HashMap::new(); + for entry in entries { + entries_by_repo + .entry(entry.repo_id.clone()) + .or_default() + .push(entry); + } + + let mut new_notifications = 0u32; + + for (repo_id, mut repo_entries) in entries_by_repo { + let repo_name = repo_names + .get(&repo_id) + .cloned() + .unwrap_or_else(|| repo_id.clone()); + + // Scan dependencies for CVEs + let alerts = match scanner.scan_dependencies(&repo_id, &mut repo_entries).await { + Ok(a) => a, + Err(e) => { + tracing::warn!("CVE monitor: scan failed for {repo_name}: {e}"); + continue; + } + }; + + // Upsert CVE alerts (existing logic) + for alert in &alerts { + let filter = doc! { "cve_id": &alert.cve_id, "repo_id": &alert.repo_id }; + let update = doc! { "$setOnInsert": mongodb::bson::to_bson(alert).unwrap_or_default() }; + let _ = agent + .db + .cve_alerts() + .update_one(filter, update) + .upsert(true) + .await; + } + + // Update SBOM entries with discovered vulnerabilities + for entry in &repo_entries { + if entry.known_vulnerabilities.is_empty() { + continue; + } + if let Some(entry_id) = &entry.id { + let _ = agent + .db + .sbom_entries() + .update_one( + doc! { "_id": entry_id }, + doc! { "$set": { + "known_vulnerabilities": mongodb::bson::to_bson(&entry.known_vulnerabilities).unwrap_or_default(), + "updated_at": mongodb::bson::DateTime::now(), + }}, + ) + .await; + } + } + + // Create notifications for NEW CVEs (dedup against existing notifications) + for alert in &alerts { + let filter = doc! { + "cve_id": &alert.cve_id, + "repo_id": &alert.repo_id, + "package_name": &alert.affected_package, + "package_version": &alert.affected_version, + }; + // Only insert if not already exists (upsert with $setOnInsert) + let severity = parse_severity(alert.severity.as_deref(), alert.cvss_score); + let mut notification = CveNotification::new( + alert.cve_id.clone(), + repo_id.clone(), + repo_name.clone(), + alert.affected_package.clone(), + alert.affected_version.clone(), + severity, + ); + notification.cvss_score = alert.cvss_score; + notification.summary = alert.summary.clone(); + notification.url = Some(format!("https://osv.dev/vulnerability/{}", alert.cve_id)); + + let update = doc! { + "$setOnInsert": mongodb::bson::to_bson(¬ification).unwrap_or_default() + }; + match agent + .db + .cve_notifications() + .update_one(filter, update) + .upsert(true) + .await + { + Ok(result) if result.upserted_id.is_some() => { + new_notifications += 1; + } + Err(e) => { + tracing::warn!("CVE monitor: failed to create notification: {e}"); + } + _ => {} // Already exists + } + } + } + + if new_notifications > 0 { + tracing::info!("CVE monitor: created {new_notifications} new notification(s)"); + } else { + tracing::info!("CVE monitor: no new CVEs found"); + } } diff --git a/compliance-core/src/models/mod.rs b/compliance-core/src/models/mod.rs index a7ecf3e..41c2f63 100644 --- a/compliance-core/src/models/mod.rs +++ b/compliance-core/src/models/mod.rs @@ -7,6 +7,7 @@ pub mod finding; pub mod graph; pub mod issue; pub mod mcp; +pub mod notification; pub mod pentest; pub mod repository; pub mod sbom; @@ -27,6 +28,7 @@ pub use graph::{ }; pub use issue::{IssueStatus, TrackerIssue, TrackerType}; pub use mcp::{McpServerConfig, McpServerStatus, McpTransport}; +pub use notification::{CveNotification, NotificationSeverity, NotificationStatus}; pub use pentest::{ AttackChainNode, AttackNodeStatus, AuthMode, CodeContextHint, Environment, IdentityProvider, PentestAuthConfig, PentestConfig, PentestEvent, PentestMessage, PentestSession, PentestStats, diff --git a/compliance-core/src/models/notification.rs b/compliance-core/src/models/notification.rs new file mode 100644 index 0000000..8f185d7 --- /dev/null +++ b/compliance-core/src/models/notification.rs @@ -0,0 +1,103 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Status of a CVE notification +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum NotificationStatus { + /// Newly created, not yet seen by the user + New, + /// User has seen it (e.g., opened the notification panel) + Read, + /// User has explicitly acknowledged/dismissed it + Dismissed, +} + +/// Severity level for notification filtering +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum NotificationSeverity { + Low, + Medium, + High, + Critical, +} + +/// A notification about a newly discovered CVE affecting a tracked dependency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CveNotification { + #[serde(rename = "_id", skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The CVE/GHSA identifier + pub cve_id: String, + /// Repository where the vulnerable dependency is used + pub repo_id: String, + /// Repository name (denormalized for display) + pub repo_name: String, + /// Affected package name + pub package_name: String, + /// Affected version + pub package_version: String, + /// Human-readable severity + pub severity: NotificationSeverity, + /// CVSS score if available + pub cvss_score: Option, + /// Short summary of the vulnerability + pub summary: Option, + /// Link to vulnerability details + pub url: Option, + /// Notification lifecycle status + pub status: NotificationStatus, + /// When the CVE was first detected for this dependency + #[serde(with = "super::serde_helpers::bson_datetime")] + pub created_at: DateTime, + /// When the user last interacted with this notification + pub read_at: Option>, +} + +impl CveNotification { + pub fn new( + cve_id: String, + repo_id: String, + repo_name: String, + package_name: String, + package_version: String, + severity: NotificationSeverity, + ) -> Self { + Self { + id: None, + cve_id, + repo_id, + repo_name, + package_name, + package_version, + severity, + cvss_score: None, + summary: None, + url: None, + status: NotificationStatus::New, + created_at: Utc::now(), + read_at: None, + } + } +} + +/// Map an OSV/NVD severity string to our notification severity +pub fn parse_severity(s: Option<&str>, cvss: Option) -> NotificationSeverity { + // Prefer CVSS score if available + if let Some(score) = cvss { + return match score { + s if s >= 9.0 => NotificationSeverity::Critical, + s if s >= 7.0 => NotificationSeverity::High, + s if s >= 4.0 => NotificationSeverity::Medium, + _ => NotificationSeverity::Low, + }; + } + // Fall back to string severity + match s.map(|s| s.to_uppercase()).as_deref() { + Some("CRITICAL") => NotificationSeverity::Critical, + Some("HIGH") => NotificationSeverity::High, + Some("MODERATE" | "MEDIUM") => NotificationSeverity::Medium, + _ => NotificationSeverity::Low, + } +} diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index 4dff597..c4aec6a 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -3847,3 +3847,33 @@ tbody tr:last-child td { .help-chat-send:not(:disabled):hover { background: var(--accent-hover); } + +/* ═══════════════════════════════════════════════════════════════ + NOTIFICATION BELL — CVE alert dropdown + ═══════════════════════════════════════════════════════════════ */ +.notification-bell-wrapper { position: fixed; top: 16px; right: 28px; z-index: 48; } +.notification-bell-btn { position: relative; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; color: var(--text-secondary); cursor: pointer; display: flex; align-items: center; transition: color 0.15s, border-color 0.15s; } +.notification-bell-btn:hover { color: var(--text-primary); border-color: var(--border-bright); } +.notification-badge { position: absolute; top: -4px; right: -4px; background: var(--danger); color: #fff; font-size: 10px; font-weight: 700; min-width: 18px; height: 18px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 4px; font-family: 'Outfit', sans-serif; } +.notification-panel { position: absolute; top: 44px; right: 0; width: 380px; max-height: 480px; background: var(--bg-secondary); border: 1px solid var(--border-bright); border-radius: 12px; overflow: hidden; box-shadow: 0 12px 48px rgba(0,0,0,0.5); display: flex; flex-direction: column; } +.notification-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); font-family: 'Outfit', sans-serif; font-weight: 600; font-size: 14px; color: var(--text-primary); } +.notification-close-btn { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 2px; } +.notification-panel-body { overflow-y: auto; flex: 1; padding: 8px; } +.notification-loading, .notification-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 32px 16px; color: var(--text-secondary); font-size: 13px; gap: 8px; } +.notification-item { padding: 10px 12px; border-radius: 8px; margin-bottom: 4px; background: var(--bg-card); border: 1px solid var(--border); transition: border-color 0.15s; } +.notification-item:hover { border-color: var(--border-bright); } +.notification-item-header { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.notification-sev { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.5px; font-family: 'Outfit', sans-serif; } +.notification-sev.sev-critical { background: var(--danger-bg); color: var(--danger); } +.notification-sev.sev-high { background: rgba(255,140,0,0.12); color: #ff8c00; } +.notification-sev.sev-medium { background: var(--warning-bg); color: var(--warning); } +.notification-sev.sev-low { background: rgba(0,200,255,0.08); color: var(--accent); } +.notification-cve-id { font-size: 12px; font-weight: 600; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; } +.notification-cve-id a { color: var(--accent); text-decoration: none; } +.notification-cve-id a:hover { text-decoration: underline; } +.notification-cvss { font-size: 10px; color: var(--text-secondary); margin-left: auto; font-family: 'JetBrains Mono', monospace; } +.notification-dismiss-btn { background: none; border: none; color: var(--text-tertiary); cursor: pointer; padding: 2px; margin-left: 4px; } +.notification-dismiss-btn:hover { color: var(--danger); } +.notification-item-pkg { font-size: 12px; color: var(--text-primary); font-family: 'JetBrains Mono', monospace; } +.notification-item-repo { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; } +.notification-item-summary { font-size: 11px; color: var(--text-secondary); line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } diff --git a/compliance-dashboard/src/components/app_shell.rs b/compliance-dashboard/src/components/app_shell.rs index c5c3c43..8c4ae2f 100644 --- a/compliance-dashboard/src/components/app_shell.rs +++ b/compliance-dashboard/src/components/app_shell.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use crate::app::Route; use crate::components::help_chat::HelpChat; +use crate::components::notification_bell::NotificationBell; use crate::components::sidebar::Sidebar; use crate::components::toast::{ToastContainer, Toasts}; use crate::infrastructure::auth_check::check_auth; @@ -21,6 +22,7 @@ pub fn AppShell() -> Element { main { class: "main-content", Outlet:: {} } + NotificationBell {} ToastContainer {} HelpChat {} } diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs index 594e07e..a5e7bce 100644 --- a/compliance-dashboard/src/components/mod.rs +++ b/compliance-dashboard/src/components/mod.rs @@ -4,6 +4,7 @@ pub mod code_inspector; pub mod code_snippet; pub mod file_tree; pub mod help_chat; +pub mod notification_bell; pub mod page_header; pub mod pagination; pub mod pentest_wizard; diff --git a/compliance-dashboard/src/components/notification_bell.rs b/compliance-dashboard/src/components/notification_bell.rs new file mode 100644 index 0000000..cb9a668 --- /dev/null +++ b/compliance-dashboard/src/components/notification_bell.rs @@ -0,0 +1,155 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +use dioxus_free_icons::Icon; + +use crate::infrastructure::notifications::{ + dismiss_notification, fetch_notification_count, fetch_notifications, + mark_all_notifications_read, +}; + +#[component] +pub fn NotificationBell() -> Element { + let mut is_open = use_signal(|| false); + let mut count = use_signal(|| 0u64); + let mut notifications = use_signal(Vec::new); + let mut is_loading = use_signal(|| false); + + // Poll notification count every 30 seconds + use_resource(move || async move { + loop { + if let Ok(c) = fetch_notification_count().await { + count.set(c); + } + #[cfg(feature = "web")] + { + gloo_timers::future::TimeoutFuture::new(30_000).await; + } + #[cfg(not(feature = "web"))] + { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + } + } + }); + + // Load notifications when panel opens + let load_notifications = move |_| { + is_open.set(!is_open()); + if !is_open() { + return; + } + is_loading.set(true); + spawn(async move { + if let Ok(resp) = fetch_notifications().await { + notifications.set(resp.data); + } + // Mark all as read when panel opens + let _ = mark_all_notifications_read().await; + count.set(0); + is_loading.set(false); + }); + }; + + let on_dismiss = move |id: String| { + spawn(async move { + let _ = dismiss_notification(id.clone()).await; + notifications.write().retain(|n| { + n.id.as_ref() + .and_then(|v| v.get("$oid")) + .and_then(|v| v.as_str()) + != Some(&id) + }); + }); + }; + + rsx! { + div { class: "notification-bell-wrapper", + // Bell button + button { + class: "notification-bell-btn", + onclick: load_notifications, + title: "CVE Alerts", + Icon { icon: BsBell, width: 18, height: 18 } + if count() > 0 { + span { class: "notification-badge", "{count()}" } + } + } + + // Dropdown panel + if is_open() { + div { class: "notification-panel", + div { class: "notification-panel-header", + span { "CVE Alerts" } + button { + class: "notification-close-btn", + onclick: move |_| is_open.set(false), + Icon { icon: BsX, width: 16, height: 16 } + } + } + div { class: "notification-panel-body", + if is_loading() { + div { class: "notification-loading", "Loading..." } + } else if notifications().is_empty() { + div { class: "notification-empty", + Icon { icon: BsShieldCheck, width: 32, height: 32 } + p { "No CVE alerts" } + } + } else { + for notif in notifications().iter() { + { + let id = notif.id.as_ref() + .and_then(|v| v.get("$oid")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let sev_class = match notif.severity.as_str() { + "critical" => "sev-critical", + "high" => "sev-high", + "medium" => "sev-medium", + _ => "sev-low", + }; + let dismiss_id = id.clone(); + rsx! { + div { class: "notification-item", + div { class: "notification-item-header", + span { class: "notification-sev {sev_class}", + "{notif.severity.to_uppercase()}" + } + span { class: "notification-cve-id", + if let Some(ref url) = notif.url { + a { href: "{url}", target: "_blank", "{notif.cve_id}" } + } else { + "{notif.cve_id}" + } + } + if let Some(score) = notif.cvss_score { + span { class: "notification-cvss", "CVSS {score:.1}" } + } + button { + class: "notification-dismiss-btn", + title: "Dismiss", + onclick: move |_| on_dismiss(dismiss_id.clone()), + Icon { icon: BsXCircle, width: 14, height: 14 } + } + } + div { class: "notification-item-pkg", + "{notif.package_name} {notif.package_version}" + } + div { class: "notification-item-repo", + "{notif.repo_name}" + } + if let Some(ref summary) = notif.summary { + div { class: "notification-item-summary", + "{summary}" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index 84ceb89..cfd2057 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -8,6 +8,7 @@ pub mod graph; pub mod help_chat; pub mod issues; pub mod mcp; +pub mod notifications; pub mod pentest; #[allow(clippy::too_many_arguments)] pub mod repositories; diff --git a/compliance-dashboard/src/infrastructure/notifications.rs b/compliance-dashboard/src/infrastructure/notifications.rs new file mode 100644 index 0000000..9e280f2 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/notifications.rs @@ -0,0 +1,91 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotificationListResponse { + pub data: Vec, + #[serde(default)] + pub total: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CveNotificationData { + #[serde(rename = "_id")] + pub id: Option, + pub cve_id: String, + pub repo_name: String, + pub package_name: String, + pub package_version: String, + pub severity: String, + pub cvss_score: Option, + pub summary: Option, + pub url: Option, + pub status: String, + #[serde(default)] + pub created_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NotificationCountResponse { + pub count: u64, +} + +#[server] +pub async fn fetch_notification_count() -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/notifications/count", state.agent_api_url); + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: NotificationCountResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body.count) +} + +#[server] +pub async fn fetch_notifications() -> Result { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/notifications?limit=20", state.agent_api_url); + let resp = reqwest::get(&url) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let body: NotificationListResponse = resp + .json() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(body) +} + +#[server] +pub async fn mark_all_notifications_read() -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/notifications/read-all", state.agent_api_url); + reqwest::Client::new() + .post(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(()) +} + +#[server] +pub async fn dismiss_notification(id: String) -> Result<(), ServerFnError> { + let state: super::server_state::ServerState = + dioxus_fullstack::FullstackContext::extract().await?; + + let url = format!("{}/api/v1/notifications/{id}/dismiss", state.agent_api_url); + reqwest::Client::new() + .patch(&url) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + Ok(()) +}