All checks were successful
CI / Format (push) Successful in 3s
CI / Tests (push) Successful in 5m2s
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy MCP (push) Has been skipped
CI / Clippy (push) Successful in 3m59s
CI / Security Audit (push) Successful in 1m44s
CI / Deploy Docs (push) Has been skipped
186 lines
8.2 KiB
Rust
186 lines
8.2 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::*;
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::components::code_snippet::CodeSnippet;
|
|
use crate::components::page_header::PageHeader;
|
|
use crate::components::severity_badge::SeverityBadge;
|
|
|
|
#[component]
|
|
pub fn FindingDetailPage(id: String) -> Element {
|
|
let finding_id = id.clone();
|
|
|
|
let mut finding = use_resource(move || {
|
|
let fid = finding_id.clone();
|
|
async move {
|
|
crate::infrastructure::findings::fetch_finding_detail(fid)
|
|
.await
|
|
.ok()
|
|
}
|
|
});
|
|
|
|
let snapshot = finding.read().clone();
|
|
|
|
match snapshot {
|
|
Some(Some(f)) => {
|
|
let finding_id_for_status = id.clone();
|
|
let finding_id_for_feedback = id.clone();
|
|
let existing_feedback = f.developer_feedback.clone().unwrap_or_default();
|
|
rsx! {
|
|
div { class: "back-nav",
|
|
button {
|
|
class: "btn btn-ghost btn-back",
|
|
onclick: move |_| { navigator().go_back(); },
|
|
Icon { icon: BsArrowLeft, width: 16, height: 16 }
|
|
"Back"
|
|
}
|
|
}
|
|
|
|
PageHeader {
|
|
title: f.title.clone(),
|
|
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
|
}
|
|
|
|
div { class: "flex gap-2 mb-4",
|
|
SeverityBadge { severity: f.severity.to_string() }
|
|
if let Some(cwe) = &f.cwe {
|
|
span { class: "badge badge-info", "{cwe}" }
|
|
}
|
|
if let Some(cve) = &f.cve {
|
|
span { class: "badge badge-high", "{cve}" }
|
|
}
|
|
if let Some(score) = f.cvss_score {
|
|
span { class: "badge badge-medium", "CVSS: {score}" }
|
|
}
|
|
if let Some(confidence) = f.confidence {
|
|
span { class: "badge badge-info", "Confidence: {confidence:.1}" }
|
|
}
|
|
}
|
|
|
|
div { class: "card",
|
|
div { class: "card-header", "Description" }
|
|
p { "{f.description}" }
|
|
}
|
|
|
|
if let Some(rationale) = &f.triage_rationale {
|
|
div { class: "card",
|
|
div { class: "card-header", "Triage Rationale" }
|
|
div {
|
|
style: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;",
|
|
if let Some(action) = &f.triage_action {
|
|
span { class: "badge badge-info", "{action}" }
|
|
}
|
|
}
|
|
p { style: "color: var(--text-secondary); font-size: 14px;", "{rationale}" }
|
|
}
|
|
}
|
|
|
|
if let Some(code) = &f.code_snippet {
|
|
div { class: "card",
|
|
div { class: "card-header", "Code Evidence" }
|
|
CodeSnippet {
|
|
code: code.clone(),
|
|
file_path: f.file_path.clone().unwrap_or_default(),
|
|
line_number: f.line_number.unwrap_or(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(remediation) = &f.remediation {
|
|
div { class: "card",
|
|
div { class: "card-header", "Remediation" }
|
|
p { "{remediation}" }
|
|
}
|
|
}
|
|
|
|
if let Some(fix) = &f.suggested_fix {
|
|
div { class: "card",
|
|
div { class: "card-header", "Suggested Fix" }
|
|
CodeSnippet { code: fix.clone() }
|
|
}
|
|
}
|
|
|
|
if let Some(url) = &f.tracker_issue_url {
|
|
div { class: "card",
|
|
div { class: "card-header", "Linked Issue" }
|
|
a {
|
|
href: "{url}",
|
|
target: "_blank",
|
|
style: "color: var(--accent);",
|
|
"{url}"
|
|
}
|
|
}
|
|
}
|
|
|
|
div { class: "card",
|
|
div { class: "card-header", "Update Status" }
|
|
div { class: "flex gap-2",
|
|
for status in ["open", "triaged", "resolved", "false_positive", "ignored"] {
|
|
{
|
|
let status_str = status.to_string();
|
|
let id_clone = finding_id_for_status.clone();
|
|
let label = match status {
|
|
"open" => "Open",
|
|
"triaged" => "Triaged",
|
|
"resolved" => "Resolved",
|
|
"false_positive" => "False Positive",
|
|
"ignored" => "Ignored",
|
|
_ => status,
|
|
};
|
|
rsx! {
|
|
button {
|
|
class: "btn btn-ghost",
|
|
title: "{label}",
|
|
onclick: move |_| {
|
|
let s = status_str.clone();
|
|
let id = id_clone.clone();
|
|
spawn(async move {
|
|
let _ = crate::infrastructure::findings::update_finding_status(id, s).await;
|
|
});
|
|
finding.restart();
|
|
},
|
|
match status {
|
|
"open" => rsx! { Icon { icon: BsCircle, width: 14, height: 14 } },
|
|
"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}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
div { class: "card",
|
|
div { class: "card-header", "Developer Feedback" }
|
|
p {
|
|
style: "font-size: 13px; color: var(--text-secondary); margin-bottom: 8px;",
|
|
"Share your assessment of this finding (e.g. false positive, actionable, needs context)"
|
|
}
|
|
textarea {
|
|
style: "width: 100%; min-height: 80px; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; color: var(--text-primary); font-size: 14px; resize: vertical;",
|
|
value: "{existing_feedback}",
|
|
oninput: move |e| {
|
|
let feedback = e.value();
|
|
let id = finding_id_for_feedback.clone();
|
|
spawn(async move {
|
|
let _ = crate::infrastructure::findings::update_finding_feedback(id, feedback).await;
|
|
});
|
|
},
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Some(None) => rsx! {
|
|
div { class: "card", p { "Finding not found." } }
|
|
},
|
|
None => rsx! {
|
|
div { class: "loading", "Loading finding..." }
|
|
},
|
|
}
|
|
}
|