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:
Sharang Parnerkar
2026-03-04 13:53:50 +01:00
parent 03ee69834d
commit cea8f59e10
69 changed files with 8745 additions and 54 deletions

View 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..." } },
}
}
}
}

View 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..." } },
}
}
}
}

View 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..." } },
}
}
}
}

View 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..." } },
}
}
}
}

View 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..." }
},
}
}
}
}
}

View 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..." } },
}
}
}
}

View 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..." }
},
}
}
}
}

View File

@@ -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;

View File

@@ -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"