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
321 lines
16 KiB
Rust
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..." }
|
|
},
|
|
}
|
|
}
|
|
}
|