Files
compliance-scanner-agent/compliance-dashboard/src/pages/findings.rs
T
sharang 0ec5fd8295
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m7s
CI / Deploy Dashboard (push) Successful in 2m57s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
fix(dashboard): Findings/SBOM filter by targets + accurate target findings_count (#155)
2026-07-13 07:59:45 +00:00

321 lines
16 KiB
Rust

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::<String>::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<String> = 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..." }
},
}
}
}