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

@@ -12,7 +12,7 @@ path = "../bin/main.rs"
workspace = true
[features]
web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys"]
web = ["dioxus/web", "dioxus/router", "dioxus/fullstack", "dep:web-sys", "dep:gloo-timers"]
server = [
"dioxus/server",
"dioxus/router",
@@ -43,6 +43,7 @@ thiserror = { workspace = true }
# Web-only
reqwest = { workspace = true, optional = true }
web-sys = { version = "0.3", optional = true }
gloo-timers = { version = "0.3", features = ["futures"], optional = true }
# Server-only
axum = { version = "0.8", optional = true }

View File

@@ -300,6 +300,87 @@ tr:hover {
color: var(--text-secondary);
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 50;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 280px;
max-width: 420px;
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
pointer-events: auto;
animation: toast-in 0.3s ease-out;
}
.toast-success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid var(--success);
color: #86efac;
}
.toast-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--danger);
color: #fca5a5;
}
.toast-info {
background: rgba(59, 130, 246, 0.15);
border: 1px solid var(--info);
color: #93c5fd;
}
.toast-dismiss {
background: none;
border: none;
color: inherit;
font-size: 18px;
cursor: pointer;
opacity: 0.7;
padding: 0 4px;
line-height: 1;
}
.toast-dismiss:hover {
opacity: 1;
}
@keyframes toast-in {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Button click animation + disabled */
.btn:active {
transform: scale(0.95);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);

View File

@@ -20,6 +20,20 @@ pub enum Route {
SbomPage {},
#[route("/issues")]
IssuesPage {},
#[route("/graph")]
GraphIndexPage {},
#[route("/graph/:repo_id")]
GraphExplorerPage { repo_id: String },
#[route("/graph/:repo_id/impact/:finding_id")]
ImpactAnalysisPage { repo_id: String, finding_id: String },
#[route("/dast")]
DastOverviewPage {},
#[route("/dast/targets")]
DastTargetsPage {},
#[route("/dast/findings")]
DastFindingsPage {},
#[route("/dast/findings/:id")]
DastFindingDetailPage { id: String },
#[route("/settings")]
SettingsPage {},
}

View File

@@ -2,15 +2,18 @@ use dioxus::prelude::*;
use crate::app::Route;
use crate::components::sidebar::Sidebar;
use crate::components::toast::{ToastContainer, Toasts};
#[component]
pub fn AppShell() -> Element {
use_context_provider(Toasts::new);
rsx! {
div { class: "app-shell",
Sidebar {}
main { class: "main-content",
Outlet::<Route> {}
}
ToastContainer {}
}
}
}

View File

@@ -5,3 +5,4 @@ pub mod pagination;
pub mod severity_badge;
pub mod sidebar;
pub mod stat_card;
pub mod toast;

View File

@@ -40,6 +40,16 @@ pub fn Sidebar() -> Element {
route: Route::IssuesPage {},
icon: rsx! { Icon { icon: BsListTask, width: 18, height: 18 } },
},
NavItem {
label: "Code Graph",
route: Route::GraphIndexPage {},
icon: rsx! { Icon { icon: BsDiagram3, width: 18, height: 18 } },
},
NavItem {
label: "DAST",
route: Route::DastOverviewPage {},
icon: rsx! { Icon { icon: BsBug, width: 18, height: 18 } },
},
NavItem {
label: "Settings",
route: Route::SettingsPage {},
@@ -58,6 +68,12 @@ pub fn Sidebar() -> Element {
{
let is_active = match (&current_route, &item.route) {
(Route::FindingDetailPage { .. }, Route::FindingsPage {}) => true,
(Route::GraphIndexPage {}, Route::GraphIndexPage {}) => true,
(Route::GraphExplorerPage { .. }, Route::GraphIndexPage {}) => true,
(Route::ImpactAnalysisPage { .. }, Route::GraphIndexPage {}) => true,
(Route::DastTargetsPage {}, Route::DastOverviewPage {}) => true,
(Route::DastFindingsPage {}, Route::DastOverviewPage {}) => true,
(Route::DastFindingDetailPage { .. }, Route::DastOverviewPage {}) => true,
(a, b) => a == b,
};
let class = if is_active { "nav-item active" } else { "nav-item" };

View File

@@ -0,0 +1,86 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub enum ToastType {
Success,
Error,
Info,
}
#[derive(Clone, PartialEq)]
pub struct ToastMessage {
pub id: usize,
pub message: String,
pub toast_type: ToastType,
}
#[derive(Clone, Copy)]
pub struct Toasts {
items: Signal<Vec<ToastMessage>>,
next_id: Signal<usize>,
}
impl Toasts {
pub fn new() -> Self {
Self {
items: Signal::new(vec![]),
next_id: Signal::new(0),
}
}
pub fn push(&mut self, toast_type: ToastType, message: impl Into<String>) {
let id = *self.next_id.read();
*self.next_id.write() = id + 1;
self.items.write().push(ToastMessage {
id,
message: message.into(),
toast_type,
});
#[cfg(feature = "web")]
{
let mut items = self.items;
spawn(async move {
gloo_timers::future::TimeoutFuture::new(4_000).await;
items.write().retain(|t| t.id != id);
});
}
}
pub fn remove(&mut self, id: usize) {
self.items.write().retain(|t| t.id != id);
}
}
#[component]
pub fn ToastContainer() -> Element {
let mut toasts = use_context::<Toasts>();
let items = toasts.items.read();
rsx! {
div { class: "toast-container",
for toast in items.iter() {
{
let toast_id = toast.id;
let type_class = match toast.toast_type {
ToastType::Success => "toast-success",
ToastType::Error => "toast-error",
ToastType::Info => "toast-info",
};
rsx! {
div {
key: "{toast_id}",
class: "toast {type_class}",
span { "{toast.message}" }
button {
class: "toast-dismiss",
onclick: move |_| toasts.remove(toast_id),
"\u{00d7}"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,125 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DastTargetsResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DastScanRunsResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DastFindingsResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DastFindingDetailResponse {
pub data: serde_json::Value,
}
#[server]
pub async fn fetch_dast_targets() -> Result<DastTargetsResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastTargetsResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn fetch_dast_scan_runs() -> Result<DastScanRunsResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/scan-runs", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastScanRunsResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn fetch_dast_findings() -> Result<DastFindingsResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/findings", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastFindingsResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn fetch_dast_finding_detail(
id: String,
) -> Result<DastFindingDetailResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/findings/{id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: DastFindingDetailResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn add_dast_target(
name: String,
base_url: String,
) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/dast/targets", state.agent_api_url);
let client = reqwest::Client::new();
client
.post(&url)
.json(&serde_json::json!({
"name": name,
"base_url": base_url,
}))
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
#[server]
pub async fn trigger_dast_scan(target_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/dast/targets/{target_id}/scan",
state.agent_api_url
);
let client = reqwest::Client::new();
client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}

View File

@@ -0,0 +1,96 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GraphDataResponse {
pub data: GraphData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GraphData {
pub build: Option<serde_json::Value>,
pub nodes: Vec<serde_json::Value>,
pub edges: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ImpactResponse {
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CommunitiesResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct NodesResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[server]
pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/graph/{repo_id}", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: GraphDataResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn fetch_impact(
repo_id: String,
finding_id: String,
) -> Result<ImpactResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/graph/{repo_id}/impact/{finding_id}",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: ImpactResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn fetch_communities(repo_id: String) -> Result<CommunitiesResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/graph/{repo_id}/communities", state.agent_api_url);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: CommunitiesResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!("{}/api/v1/graph/{repo_id}/build", state.agent_api_url);
let client = reqwest::Client::new();
client
.post(&url)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}

View File

@@ -1,6 +1,8 @@
// Server function modules (compiled for both web and server;
// the #[server] macro generates client stubs for the web target)
pub mod dast;
pub mod findings;
pub mod graph;
pub mod issues;
pub mod repositories;
pub mod sbom;

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"