Add DAST, graph modules, toast notifications, and dashboard enhancements
Add DAST scanning and code knowledge graph features across the stack: - compliance-dast and compliance-graph workspace crates - Agent API handlers and routes for DAST targets/scans and graph builds - Core models and traits for DAST and graph domains - Dashboard pages for DAST targets/findings/overview and graph explorer/impact - Toast notification system with auto-dismiss for async action feedback - Button click animations and disabled states for better UX Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
113
compliance-dashboard/src/pages/dast_finding_detail.rs
Normal file
113
compliance-dashboard/src/pages/dast_finding_detail.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
use crate::infrastructure::dast::fetch_dast_finding_detail;
|
||||
|
||||
#[component]
|
||||
pub fn DastFindingDetailPage(id: String) -> Element {
|
||||
let finding = use_resource(move || {
|
||||
let fid = id.clone();
|
||||
async move { fetch_dast_finding_detail(fid).await.ok() }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "DAST Finding Detail",
|
||||
description: "Full evidence and details for a dynamic security finding",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
match &*finding.read() {
|
||||
Some(Some(resp)) => {
|
||||
let f = resp.data.clone();
|
||||
let severity = f.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
|
||||
rsx! {
|
||||
div { class: "flex items-center gap-4 mb-4",
|
||||
SeverityBadge { severity: severity }
|
||||
h2 { "{f.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"Unknown Finding\")}" }
|
||||
}
|
||||
|
||||
div { class: "grid grid-cols-2 gap-4 mb-4",
|
||||
div {
|
||||
strong { "Vulnerability Type: " }
|
||||
span { class: "badge", "{f.get(\"vuln_type\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "CWE: " }
|
||||
span { "{f.get(\"cwe\").and_then(|v| v.as_str()).unwrap_or(\"N/A\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "Endpoint: " }
|
||||
code { "{f.get(\"endpoint\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "Method: " }
|
||||
span { "{f.get(\"method\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "Parameter: " }
|
||||
code { "{f.get(\"parameter\").and_then(|v| v.as_str()).unwrap_or(\"N/A\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "Exploitable: " }
|
||||
if f.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
span { class: "badge badge-danger", "Confirmed" }
|
||||
} else {
|
||||
span { class: "badge", "Unconfirmed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 { "Description" }
|
||||
p { "{f.get(\"description\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
|
||||
if let Some(remediation) = f.get("remediation").and_then(|v| v.as_str()) {
|
||||
h3 { class: "mt-4", "Remediation" }
|
||||
p { "{remediation}" }
|
||||
}
|
||||
|
||||
h3 { class: "mt-4", "Evidence" }
|
||||
if let Some(evidence_list) = f.get("evidence").and_then(|v| v.as_array()) {
|
||||
for (i, evidence) in evidence_list.iter().enumerate() {
|
||||
div { class: "card mb-3",
|
||||
h4 { "Evidence #{i + 1}" }
|
||||
div { class: "grid grid-cols-2 gap-2",
|
||||
div {
|
||||
strong { "Request: " }
|
||||
code { "{evidence.get(\"request_method\").and_then(|v| v.as_str()).unwrap_or(\"-\")} {evidence.get(\"request_url\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
}
|
||||
div {
|
||||
strong { "Response Status: " }
|
||||
span { "{evidence.get(\"response_status\").and_then(|v| v.as_u64()).unwrap_or(0)}" }
|
||||
}
|
||||
}
|
||||
if let Some(payload) = evidence.get("payload").and_then(|v| v.as_str()) {
|
||||
div { class: "mt-2",
|
||||
strong { "Payload: " }
|
||||
code { class: "block bg-gray-900 text-green-400 p-2 rounded mt-1",
|
||||
"{payload}"
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(snippet) = evidence.get("response_snippet").and_then(|v| v.as_str()) {
|
||||
div { class: "mt-2",
|
||||
strong { "Response Snippet: " }
|
||||
pre { class: "block bg-gray-900 text-gray-300 p-2 rounded mt-1 overflow-x-auto text-sm",
|
||||
"{snippet}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
p { "No evidence collected." }
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Finding not found." } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
79
compliance-dashboard/src/pages/dast_findings.rs
Normal file
79
compliance-dashboard/src/pages/dast_findings.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::severity_badge::SeverityBadge;
|
||||
use crate::infrastructure::dast::fetch_dast_findings;
|
||||
|
||||
#[component]
|
||||
pub fn DastFindingsPage() -> Element {
|
||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "DAST Findings",
|
||||
description: "Vulnerabilities discovered through dynamic application security testing",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
match &*findings.read() {
|
||||
Some(Some(data)) => {
|
||||
let finding_list = &data.data;
|
||||
if finding_list.is_empty() {
|
||||
rsx! { p { "No DAST findings yet. Run a scan to discover vulnerabilities." } }
|
||||
} else {
|
||||
rsx! {
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Severity" }
|
||||
th { "Type" }
|
||||
th { "Title" }
|
||||
th { "Endpoint" }
|
||||
th { "Method" }
|
||||
th { "Exploitable" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for finding in finding_list {
|
||||
{
|
||||
let id = finding.get("_id").and_then(|v| v.get("$oid")).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string();
|
||||
rsx! {
|
||||
tr {
|
||||
td { SeverityBadge { severity: severity } }
|
||||
td {
|
||||
span { class: "badge",
|
||||
"{finding.get(\"vuln_type\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
||||
}
|
||||
}
|
||||
td {
|
||||
Link {
|
||||
to: Route::DastFindingDetailPage { id: id },
|
||||
"{finding.get(\"title\").and_then(|v| v.as_str()).unwrap_or(\"-\")}"
|
||||
}
|
||||
}
|
||||
td { code { class: "text-sm", "{finding.get(\"endpoint\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" } }
|
||||
td { "{finding.get(\"method\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
td {
|
||||
if finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
span { class: "badge badge-danger", "Confirmed" }
|
||||
} else {
|
||||
span { class: "badge", "Unconfirmed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Failed to load findings." } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
107
compliance-dashboard/src/pages/dast_overview.rs
Normal file
107
compliance-dashboard/src/pages/dast_overview.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::dast::{fetch_dast_findings, fetch_dast_scan_runs};
|
||||
|
||||
#[component]
|
||||
pub fn DastOverviewPage() -> Element {
|
||||
let scan_runs = use_resource(|| async { fetch_dast_scan_runs().await.ok() });
|
||||
let findings = use_resource(|| async { fetch_dast_findings().await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "DAST Overview",
|
||||
description: "Dynamic Application Security Testing — scan running applications for vulnerabilities",
|
||||
}
|
||||
|
||||
div { class: "grid grid-cols-3 gap-4 mb-6",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
match &*scan_runs.read() {
|
||||
Some(Some(data)) => {
|
||||
let count = data.total.unwrap_or(0);
|
||||
rsx! { "{count}" }
|
||||
},
|
||||
_ => rsx! { "—" },
|
||||
}
|
||||
}
|
||||
div { class: "stat-label", "Total Scans" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
match &*findings.read() {
|
||||
Some(Some(data)) => {
|
||||
let count = data.total.unwrap_or(0);
|
||||
rsx! { "{count}" }
|
||||
},
|
||||
_ => rsx! { "—" },
|
||||
}
|
||||
}
|
||||
div { class: "stat-label", "DAST Findings" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "—" }
|
||||
div { class: "stat-label", "Active Targets" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "flex gap-4 mb-4",
|
||||
Link {
|
||||
to: Route::DastTargetsPage {},
|
||||
class: "btn btn-primary",
|
||||
"Manage Targets"
|
||||
}
|
||||
Link {
|
||||
to: Route::DastFindingsPage {},
|
||||
class: "btn btn-secondary",
|
||||
"View Findings"
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Recent Scan Runs" }
|
||||
match &*scan_runs.read() {
|
||||
Some(Some(data)) => {
|
||||
let runs = &data.data;
|
||||
if runs.is_empty() {
|
||||
rsx! { p { "No scan runs yet." } }
|
||||
} else {
|
||||
rsx! {
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Target" }
|
||||
th { "Status" }
|
||||
th { "Phase" }
|
||||
th { "Findings" }
|
||||
th { "Exploitable" }
|
||||
th { "Started" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for run in runs {
|
||||
tr {
|
||||
td { "{run.get(\"target_id\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
td {
|
||||
span { class: "badge",
|
||||
"{run.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"unknown\")}"
|
||||
}
|
||||
}
|
||||
td { "{run.get(\"current_phase\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
td { "{run.get(\"findings_count\").and_then(|v| v.as_u64()).unwrap_or(0)}" }
|
||||
td { "{run.get(\"exploitable_count\").and_then(|v| v.as_u64()).unwrap_or(0)}" }
|
||||
td { "{run.get(\"started_at\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Failed to load scan runs." } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
145
compliance-dashboard/src/pages/dast_targets.rs
Normal file
145
compliance-dashboard/src/pages/dast_targets.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
use crate::infrastructure::dast::{add_dast_target, fetch_dast_targets, trigger_dast_scan};
|
||||
|
||||
#[component]
|
||||
pub fn DastTargetsPage() -> Element {
|
||||
let mut targets = use_resource(|| async { fetch_dast_targets().await.ok() });
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
|
||||
let mut show_form = use_signal(|| false);
|
||||
let mut new_name = use_signal(String::new);
|
||||
let mut new_url = use_signal(String::new);
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "DAST Targets",
|
||||
description: "Configure target applications for dynamic security testing",
|
||||
}
|
||||
|
||||
div { class: "mb-4",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| show_form.set(!show_form()),
|
||||
if show_form() { "Cancel" } else { "Add Target" }
|
||||
}
|
||||
}
|
||||
|
||||
if show_form() {
|
||||
div { class: "card mb-4",
|
||||
h3 { "Add New Target" }
|
||||
div { class: "form-group",
|
||||
label { "Name" }
|
||||
input {
|
||||
class: "input",
|
||||
r#type: "text",
|
||||
placeholder: "My Web App",
|
||||
value: "{new_name}",
|
||||
oninput: move |e| new_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
div { class: "form-group",
|
||||
label { "Base URL" }
|
||||
input {
|
||||
class: "input",
|
||||
r#type: "text",
|
||||
placeholder: "https://example.com",
|
||||
value: "{new_url}",
|
||||
oninput: move |e| new_url.set(e.value()),
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
onclick: move |_| {
|
||||
let name = new_name();
|
||||
let url = new_url();
|
||||
spawn(async move {
|
||||
match add_dast_target(name, url).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Target created");
|
||||
targets.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
});
|
||||
show_form.set(false);
|
||||
new_name.set(String::new());
|
||||
new_url.set(String::new());
|
||||
},
|
||||
"Create Target"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Configured Targets" }
|
||||
match &*targets.read() {
|
||||
Some(Some(data)) => {
|
||||
let target_list = &data.data;
|
||||
if target_list.is_empty() {
|
||||
rsx! { p { "No DAST targets configured. Add one to get started." } }
|
||||
} else {
|
||||
rsx! {
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
th { "Name" }
|
||||
th { "URL" }
|
||||
th { "Type" }
|
||||
th { "Rate Limit" }
|
||||
th { "Destructive" }
|
||||
th { "Actions" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for target in target_list {
|
||||
{
|
||||
let target_id = target.get("_id").and_then(|v| v.get("$oid")).and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
rsx! {
|
||||
tr {
|
||||
td { "{target.get(\"name\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
td { code { "{target.get(\"base_url\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" } }
|
||||
td { "{target.get(\"target_type\").and_then(|v| v.as_str()).unwrap_or(\"-\")}" }
|
||||
td { "{target.get(\"rate_limit\").and_then(|v| v.as_u64()).unwrap_or(0)} req/s" }
|
||||
td {
|
||||
if target.get("allow_destructive").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
span { class: "badge badge-danger", "Yes" }
|
||||
} else {
|
||||
span { class: "badge badge-success", "No" }
|
||||
}
|
||||
}
|
||||
td {
|
||||
button {
|
||||
class: "btn btn-sm",
|
||||
onclick: {
|
||||
let tid = target_id.clone();
|
||||
move |_| {
|
||||
let tid = tid.clone();
|
||||
spawn(async move {
|
||||
match trigger_dast_scan(tid).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "DAST scan triggered"),
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
"Scan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Failed to load targets." } },
|
||||
None => rsx! { p { "Loading..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
compliance-dashboard/src/pages/graph_explorer.rs
Normal file
105
compliance-dashboard/src/pages/graph_explorer.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
use crate::infrastructure::graph::{fetch_graph, trigger_graph_build};
|
||||
|
||||
#[component]
|
||||
pub fn GraphExplorerPage(repo_id: String) -> Element {
|
||||
let repo_id_clone = repo_id.clone();
|
||||
let mut graph_data = use_resource(move || {
|
||||
let rid = repo_id_clone.clone();
|
||||
async move {
|
||||
if rid.is_empty() {
|
||||
return None;
|
||||
}
|
||||
fetch_graph(rid).await.ok()
|
||||
}
|
||||
});
|
||||
|
||||
let mut building = use_signal(|| false);
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Code Knowledge Graph",
|
||||
description: "Interactive visualization of code structure and relationships",
|
||||
}
|
||||
|
||||
if repo_id.is_empty() {
|
||||
div { class: "card",
|
||||
p { "Select a repository to view its code graph." }
|
||||
p { "You can trigger a graph build from the Repositories page." }
|
||||
}
|
||||
} else {
|
||||
div { style: "margin-bottom: 16px;",
|
||||
button {
|
||||
class: "btn btn-primary",
|
||||
disabled: building(),
|
||||
onclick: {
|
||||
let rid = repo_id.clone();
|
||||
move |_| {
|
||||
let rid = rid.clone();
|
||||
building.set(true);
|
||||
spawn(async move {
|
||||
match trigger_graph_build(rid).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"),
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
building.set(false);
|
||||
graph_data.restart();
|
||||
});
|
||||
}
|
||||
},
|
||||
if building() { "Building..." } else { "Build Graph" }
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Graph Explorer \u{2014} {repo_id}" }
|
||||
|
||||
match &*graph_data.read() {
|
||||
Some(Some(data)) => {
|
||||
let build = data.data.build.clone().unwrap_or_default();
|
||||
let node_count = build.get("node_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let edge_count = build.get("edge_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
let community_count = build.get("community_count").and_then(|n| n.as_u64()).unwrap_or(0);
|
||||
rsx! {
|
||||
div { class: "grid grid-cols-3 gap-4 mb-4",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{node_count}" }
|
||||
div { class: "stat-label", "Nodes" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{edge_count}" }
|
||||
div { class: "stat-label", "Edges" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value", "{community_count}" }
|
||||
div { class: "stat-label", "Communities" }
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
id: "graph-container",
|
||||
style: "width: 100%; height: 600px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-secondary);",
|
||||
}
|
||||
|
||||
script {
|
||||
r#"
|
||||
console.log('Graph explorer loaded');
|
||||
"#
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
p { "No graph data available. Build the graph first." }
|
||||
},
|
||||
None => rsx! {
|
||||
p { "Loading graph data..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
compliance-dashboard/src/pages/graph_index.rs
Normal file
53
compliance-dashboard/src/pages/graph_index.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::app::Route;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::repositories::fetch_repositories;
|
||||
|
||||
#[component]
|
||||
pub fn GraphIndexPage() -> Element {
|
||||
let repos = use_resource(|| async { fetch_repositories(1).await.ok() });
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Code Knowledge Graph",
|
||||
description: "Select a repository to explore its code graph",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
h3 { "Repositories" }
|
||||
match &*repos.read() {
|
||||
Some(Some(data)) => {
|
||||
let repo_list = &data.data;
|
||||
if repo_list.is_empty() {
|
||||
rsx! { p { "No repositories found. Add a repository first." } }
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "grid grid-cols-1 gap-3",
|
||||
for repo in repo_list {
|
||||
{
|
||||
let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default();
|
||||
let name = repo.name.clone();
|
||||
let url = repo.git_url.clone();
|
||||
rsx! {
|
||||
Link {
|
||||
to: Route::GraphExplorerPage { repo_id: repo_id },
|
||||
class: "card hover:bg-gray-800 transition-colors cursor-pointer",
|
||||
h4 { "{name}" }
|
||||
if !url.is_empty() {
|
||||
p { class: "text-sm text-muted", "{url}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! { p { "Failed to load repositories." } },
|
||||
None => rsx! { p { "Loading repositories..." } },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
compliance-dashboard/src/pages/impact_analysis.rs
Normal file
97
compliance-dashboard/src/pages/impact_analysis.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::graph::fetch_impact;
|
||||
|
||||
#[component]
|
||||
pub fn ImpactAnalysisPage(repo_id: String, finding_id: String) -> Element {
|
||||
let impact_data = use_resource(move || {
|
||||
let rid = repo_id.clone();
|
||||
let fid = finding_id.clone();
|
||||
async move { fetch_impact(rid, fid).await.ok() }
|
||||
});
|
||||
|
||||
rsx! {
|
||||
PageHeader {
|
||||
title: "Impact Analysis",
|
||||
description: "Blast radius and affected entry points for a security finding",
|
||||
}
|
||||
|
||||
div { class: "card",
|
||||
match &*impact_data.read() {
|
||||
Some(Some(resp)) => {
|
||||
let impact = resp.data.clone().unwrap_or_default();
|
||||
rsx! {
|
||||
div { class: "grid grid-cols-2 gap-4 mb-4",
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
"{impact.get(\"blast_radius\").and_then(|v| v.as_u64()).unwrap_or(0)}"
|
||||
}
|
||||
div { class: "stat-label", "Blast Radius (nodes affected)" }
|
||||
}
|
||||
div { class: "stat-card",
|
||||
div { class: "stat-value",
|
||||
"{impact.get(\"affected_entry_points\").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0)}"
|
||||
}
|
||||
div { class: "stat-label", "Entry Points Affected" }
|
||||
}
|
||||
}
|
||||
|
||||
h3 { "Affected Entry Points" }
|
||||
if let Some(entries) = impact.get("affected_entry_points").and_then(|v| v.as_array()) {
|
||||
if entries.is_empty() {
|
||||
p { class: "text-muted", "No entry points affected." }
|
||||
} else {
|
||||
ul { class: "list",
|
||||
for entry in entries {
|
||||
li { "{entry.as_str().unwrap_or(\"-\")}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 { class: "mt-4", "Call Chains" }
|
||||
if let Some(chains) = impact.get("call_chains").and_then(|v| v.as_array()) {
|
||||
if chains.is_empty() {
|
||||
p { class: "text-muted", "No call chains found." }
|
||||
} else {
|
||||
for (i, chain) in chains.iter().enumerate() {
|
||||
div { class: "card mb-2",
|
||||
strong { "Chain {i + 1}: " }
|
||||
if let Some(steps) = chain.as_array() {
|
||||
for (j, step) in steps.iter().enumerate() {
|
||||
span { "{step.as_str().unwrap_or(\"-\")}" }
|
||||
if j < steps.len() - 1 {
|
||||
span { class: "text-muted", " → " }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h3 { class: "mt-4", "Direct Callers" }
|
||||
if let Some(callers) = impact.get("direct_callers").and_then(|v| v.as_array()) {
|
||||
if callers.is_empty() {
|
||||
p { class: "text-muted", "No direct callers." }
|
||||
} else {
|
||||
ul { class: "list",
|
||||
for caller in callers {
|
||||
li { code { "{caller.as_str().unwrap_or(\"-\")}" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
Some(None) => rsx! {
|
||||
p { "No impact analysis data available for this finding." }
|
||||
},
|
||||
None => rsx! {
|
||||
p { "Loading impact analysis..." }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,27 @@
|
||||
pub mod dast_finding_detail;
|
||||
pub mod dast_findings;
|
||||
pub mod dast_overview;
|
||||
pub mod dast_targets;
|
||||
pub mod finding_detail;
|
||||
pub mod findings;
|
||||
pub mod graph_explorer;
|
||||
pub mod graph_index;
|
||||
pub mod impact_analysis;
|
||||
pub mod issues;
|
||||
pub mod overview;
|
||||
pub mod repositories;
|
||||
pub mod sbom;
|
||||
pub mod settings;
|
||||
|
||||
pub use dast_finding_detail::DastFindingDetailPage;
|
||||
pub use dast_findings::DastFindingsPage;
|
||||
pub use dast_overview::DastOverviewPage;
|
||||
pub use dast_targets::DastTargetsPage;
|
||||
pub use finding_detail::FindingDetailPage;
|
||||
pub use findings::FindingsPage;
|
||||
pub use graph_explorer::GraphExplorerPage;
|
||||
pub use graph_index::GraphIndexPage;
|
||||
pub use impact_analysis::ImpactAnalysisPage;
|
||||
pub use issues::IssuesPage;
|
||||
pub use overview::OverviewPage;
|
||||
pub use repositories::RepositoriesPage;
|
||||
|
||||
@@ -2,6 +2,7 @@ use dioxus::prelude::*;
|
||||
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::components::pagination::Pagination;
|
||||
use crate::components::toast::{ToastType, Toasts};
|
||||
|
||||
#[component]
|
||||
pub fn RepositoriesPage() -> Element {
|
||||
@@ -10,8 +11,9 @@ pub fn RepositoriesPage() -> Element {
|
||||
let mut name = use_signal(String::new);
|
||||
let mut git_url = use_signal(String::new);
|
||||
let mut branch = use_signal(|| "main".to_string());
|
||||
let mut toasts = use_context::<Toasts>();
|
||||
|
||||
let repos = use_resource(move || {
|
||||
let mut repos = use_resource(move || {
|
||||
let p = page();
|
||||
async move {
|
||||
crate::infrastructure::repositories::fetch_repositories(p)
|
||||
@@ -71,7 +73,13 @@ pub fn RepositoriesPage() -> Element {
|
||||
let u = git_url();
|
||||
let b = branch();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::repositories::add_repository(n, u, b).await;
|
||||
match crate::infrastructure::repositories::add_repository(n, u, b).await {
|
||||
Ok(_) => {
|
||||
toasts.push(ToastType::Success, "Repository added");
|
||||
repos.restart();
|
||||
}
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
});
|
||||
show_add_form.set(false);
|
||||
name.set(String::new());
|
||||
@@ -125,7 +133,10 @@ pub fn RepositoriesPage() -> Element {
|
||||
onclick: move |_| {
|
||||
let id = repo_id_clone.clone();
|
||||
spawn(async move {
|
||||
let _ = crate::infrastructure::repositories::trigger_repo_scan(id).await;
|
||||
match crate::infrastructure::repositories::trigger_repo_scan(id).await {
|
||||
Ok(_) => toasts.push(ToastType::Success, "Scan triggered"),
|
||||
Err(e) => toasts.push(ToastType::Error, e.to_string()),
|
||||
}
|
||||
});
|
||||
},
|
||||
"Scan"
|
||||
|
||||
Reference in New Issue
Block a user