use dioxus::prelude::*; 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 repos = use_resource(|| async { crate::infrastructure::repositories::fetch_repositories(1) .await .ok() }); let findings = use_resource(move || { let p = page(); let sev = severity_filter(); let typ = type_filter(); let stat = status_filter(); let repo = repo_filter(); async move { crate::infrastructure::findings::fetch_findings(p, sev, typ, stat, repo) .await .ok() } }); rsx! { PageHeader { title: "Findings", description: "Security and compliance findings across all repositories", } div { class: "filter-bar", 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}" } } } } }, _ => 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" } } 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" } } } match &*findings.read() { Some(Some(resp)) => { let total_pages = resp.total.unwrap_or(0).div_ceil(20).max(1); rsx! { div { class: "card", div { class: "table-wrapper", table { thead { tr { th { "Severity" } th { "Title" } th { "Type" } th { "Scanner" } th { "File" } th { "Status" } } } tbody { for finding in &resp.data { { let id = finding.id.as_ref().map(|id| id.to_hex()).unwrap_or_default(); rsx! { tr { 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 { "{finding.scanner}" } td { style: "font-family: monospace; font-size: 12px;", "{finding.file_path.as_deref().unwrap_or(\"-\")}" } td { span { class: "badge badge-info", "{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..." } }, } } }