use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; use crate::components::page_header::PageHeader; use crate::components::pagination::Pagination; use crate::components::severity_badge::SeverityBadge; #[component] pub fn FindingsPage() -> Element { let mut page = use_signal(|| 1u64); let mut severity_filter = use_signal(String::new); let mut type_filter = use_signal(String::new); let mut status_filter = use_signal(String::new); let mut repo_filter = use_signal(String::new); let mut search_query = use_signal(String::new); let mut sort_by = use_signal(|| "created_at".to_string()); let mut sort_order = use_signal(|| "desc".to_string()); let mut selected_ids = use_signal(Vec::::new); let repos = use_resource(|| async { crate::infrastructure::onboarding::fetch_targets() .await .ok() }); let mut findings = use_resource(move || { let query = crate::infrastructure::findings::FindingsQuery { page: page(), severity: severity_filter(), scan_type: type_filter(), status: status_filter(), repo_id: repo_filter(), q: search_query(), sort_by: sort_by(), sort_order: sort_order(), }; async move { crate::infrastructure::findings::fetch_findings(query) .await .ok() } }); let toggle_sort = move |field: &'static str| { move |_: MouseEvent| { if sort_by() == field { sort_order.set(if sort_order() == "asc" { "desc".to_string() } else { "asc".to_string() }); } else { sort_by.set(field.to_string()); sort_order.set("desc".to_string()); } page.set(1); } }; let sort_indicator = move |field: &str| -> String { if sort_by() == field { if sort_order() == "asc" { " \u{25B2}".to_string() } else { " \u{25BC}".to_string() } } else { String::new() } }; rsx! { PageHeader { title: "Findings", description: "Security and compliance findings across all repositories", } div { class: "filter-bar", input { r#type: "text", placeholder: "Search findings...", style: "min-width: 200px;", oninput: move |e| { search_query.set(e.value()); page.set(1); }, } select { onchange: move |e| { repo_filter.set(e.value()); page.set(1); }, option { value: "", "All Targets" } { match &*repos.read() { Some(Some(resp)) => rsx! { for t in &resp.data { { let id = t.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string(); let name = t.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string(); rsx! { option { value: "{id}", "{name}" } } } } }, _ => rsx! {}, } } } select { onchange: move |e| { severity_filter.set(e.value()); page.set(1); }, option { value: "", "All Severities" } option { value: "critical", "Critical" } option { value: "high", "High" } option { value: "medium", "Medium" } option { value: "low", "Low" } option { value: "info", "Info" } } select { onchange: move |e| { type_filter.set(e.value()); page.set(1); }, option { value: "", "All Types" } option { value: "sast", "SAST" } option { value: "sbom", "SBOM" } option { value: "cve", "CVE" } option { value: "gdpr", "GDPR" } option { value: "oauth", "OAuth" } option { value: "secret_detection", "Secrets" } option { value: "lint", "Lint" } } select { onchange: move |e| { status_filter.set(e.value()); page.set(1); }, option { value: "", "All Statuses" } option { value: "open", "Open" } option { value: "triaged", "Triaged" } option { value: "resolved", "Resolved" } option { value: "false_positive", "False Positive" } option { value: "ignored", "Ignored" } } } // Bulk action bar if !selected_ids().is_empty() { div { class: "card", style: "display: flex; align-items: center; gap: 12px; padding: 12px 16px; margin-bottom: 16px; background: rgba(56, 189, 248, 0.08); border-color: rgba(56, 189, 248, 0.2);", span { style: "font-size: 14px; color: var(--text-secondary);", "{selected_ids().len()} selected" } for status in ["triaged", "resolved", "false_positive", "ignored"] { { let status_str = status.to_string(); let label = match status { "false_positive" => "False Positive", other => { // Capitalize first letter let mut s = other.to_string(); if let Some(c) = s.get_mut(0..1) { c.make_ascii_uppercase(); } // Leak to get a &str that lives long enough - this is fine for static-ish UI strings &*Box::leak(s.into_boxed_str()) } }; rsx! { button { class: "btn btn-sm btn-ghost", title: "Mark {label}", onclick: move |_| { let ids = selected_ids(); let s = status_str.clone(); spawn(async move { let _ = crate::infrastructure::findings::bulk_update_finding_status(ids, s).await; findings.restart(); }); selected_ids.set(Vec::new()); }, match status { "triaged" => rsx! { Icon { icon: BsEye, width: 14, height: 14 } }, "resolved" => rsx! { Icon { icon: BsCheckCircle, width: 14, height: 14 } }, "false_positive" => rsx! { Icon { icon: BsXCircle, width: 14, height: 14 } }, "ignored" => rsx! { Icon { icon: BsDashCircle, width: 14, height: 14 } }, _ => rsx! {}, } " {label}" } } } } button { class: "btn btn-sm btn-ghost", onclick: move |_| { selected_ids.set(Vec::new()); }, "Clear" } } } match &*findings.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); let all_ids: Vec = resp.data.iter().filter_map(|f| f.id.as_ref().map(|id| id.to_hex())).collect(); rsx! { div { class: "card", div { class: "table-wrapper", table { thead { tr { th { style: "width: 40px;", input { r#type: "checkbox", checked: !all_ids.is_empty() && selected_ids().len() == all_ids.len(), onchange: move |_| { if selected_ids().len() == all_ids.len() { selected_ids.set(Vec::new()); } else { selected_ids.set(all_ids.clone()); } }, } } th { style: "cursor: pointer; user-select: none;", onclick: toggle_sort("severity"), "Severity{sort_indicator(\"severity\")}" } th { style: "cursor: pointer; user-select: none;", onclick: toggle_sort("title"), "Title{sort_indicator(\"title\")}" } th { style: "cursor: pointer; user-select: none;", onclick: toggle_sort("scan_type"), "Type{sort_indicator(\"scan_type\")}" } th { "Scanner" } th { "File" } th { style: "cursor: pointer; user-select: none;", onclick: toggle_sort("status"), "Status{sort_indicator(\"status\")}" } } } tbody { for finding in &resp.data { { let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); let id_for_check = id.clone(); let is_selected = selected_ids().contains(&id); rsx! { tr { td { input { r#type: "checkbox", checked: is_selected, onchange: move |_| { let mut ids = selected_ids(); if ids.contains(&id_for_check) { ids.retain(|i| i != &id_for_check); } else { ids.push(id_for_check.clone()); } selected_ids.set(ids); }, } } td { SeverityBadge { severity: finding.severity.to_string() } } td { Link { to: Route::FindingDetailPage { id }, style: "color: var(--accent); text-decoration: none;", "{finding.title}" } } td { "{finding.scan_type}" } td { Icon { icon: BsCpu, width: 14, height: 14 } " {finding.scanner}" } td { style: "font-family: monospace; font-size: 12px;", Icon { icon: BsFileEarmarkCode, width: 14, height: 14 } " {finding.file_path.as_deref().unwrap_or(\"-\")}" } td { span { class: "badge badge-info", { use compliance_core::models::FindingStatus; match &finding.status { FindingStatus::Open => rsx! { Icon { icon: BsCircle, width: 12, height: 12 } }, FindingStatus::Triaged => rsx! { Icon { icon: BsEye, width: 12, height: 12 } }, FindingStatus::Resolved => rsx! { Icon { icon: BsCheckCircle, width: 12, height: 12 } }, FindingStatus::FalsePositive => rsx! { Icon { icon: BsXCircle, width: 12, height: 12 } }, FindingStatus::Ignored => rsx! { Icon { icon: BsDashCircle, width: 12, height: 12 } }, } } " {finding.status}" } } } } } } } } } 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 findings." } } }, None => rsx! { div { class: "loading", "Loading findings..." } }, } } }