From 17f788858d69aba26bcbf65edefbb3c8f9f49dc6 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar Date: Sun, 12 Jul 2026 21:28:52 +0000 Subject: [PATCH] feat(dashboard): onboarding wizard UI (#144) --- compliance-dashboard/src/app.rs | 2 + .../src/components/sidebar.rs | 5 + .../src/infrastructure/mod.rs | 1 + .../src/infrastructure/onboarding.rs | 107 +++++ compliance-dashboard/src/pages/mod.rs | 2 + compliance-dashboard/src/pages/onboarding.rs | 377 ++++++++++++++++++ 6 files changed, 494 insertions(+) create mode 100644 compliance-dashboard/src/infrastructure/onboarding.rs create mode 100644 compliance-dashboard/src/pages/onboarding.rs diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index 8b7cdd3..a14d517 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -12,6 +12,8 @@ pub enum Route { OverviewPage {}, #[route("/repositories")] RepositoriesPage {}, + #[route("/onboard")] + OnboardingPage {}, #[route("/findings")] FindingsPage {}, #[route("/findings/:id")] diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index cceb196..3fd4138 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -28,6 +28,11 @@ pub fn Sidebar() -> Element { route: Route::RepositoriesPage {}, icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } }, }, + NavItem { + label: "Onboard", + route: Route::OnboardingPage {}, + icon: rsx! { Icon { icon: BsPlusCircle, width: 18, height: 18 } }, + }, NavItem { label: "Findings", route: Route::FindingsPage {}, diff --git a/compliance-dashboard/src/infrastructure/mod.rs b/compliance-dashboard/src/infrastructure/mod.rs index dd8066b..9b55913 100644 --- a/compliance-dashboard/src/infrastructure/mod.rs +++ b/compliance-dashboard/src/infrastructure/mod.rs @@ -10,6 +10,7 @@ pub mod issues; pub mod mcp; pub mod mcp_tokens; pub mod notifications; +pub mod onboarding; pub mod pentest; #[allow(clippy::too_many_arguments)] pub mod repositories; diff --git a/compliance-dashboard/src/infrastructure/onboarding.rs b/compliance-dashboard/src/infrastructure/onboarding.rs new file mode 100644 index 0000000..09c2a05 --- /dev/null +++ b/compliance-dashboard/src/infrastructure/onboarding.rs @@ -0,0 +1,107 @@ +//! Server functions for the onboarding wizard — proxy to the agent's +//! `/api/v1/targets` endpoints. + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +/// One artifact the wizard collects for a target. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ArtifactInputDto { + pub kind: String, + pub source_ref: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plc_format: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TargetsResponse { + pub data: Vec, + pub total: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TargetResponse { + pub data: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ApplicableScansData { + #[serde(default)] + pub scans: Vec, + #[serde(default)] + pub pentest_supported: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ApplicableScansResponse { + pub data: ApplicableScansData, +} + +/// List onboarded targets. +#[server] +pub async fn fetch_targets() -> Result { + let resp = super::agent_client::agent_get("/api/v1/targets") + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Create a target with the collected artifacts. +#[server] +pub async fn create_target( + name: String, + target_type: String, + description: Option, + artifacts: Vec, +) -> Result { + let body = serde_json::json!({ + "name": name, + "target_type": target_type, + "description": description, + "artifacts": artifacts, + }); + let resp = super::agent_client::agent_request(reqwest::Method::POST, "/api/v1/targets") + .await? + .json(&body) + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Run kind-based classification on a target. +#[server] +pub async fn detect_target(id: String) -> Result { + let resp = super::agent_client::agent_request( + reqwest::Method::POST, + &format!("/api/v1/targets/{id}/detect"), + ) + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Fetch the scan-applicability matrix for a target. +#[server] +pub async fn fetch_applicable_scans(id: String) -> Result { + let resp = super::agent_client::agent_get(&format!("/api/v1/targets/{id}/applicable-scans")) + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index 1df2d9e..4613d46 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -12,6 +12,7 @@ pub mod impact_analysis; pub mod issues; pub mod mcp_servers; pub mod mcp_tokens; +pub mod onboarding; pub mod overview; pub mod pentest_dashboard; pub mod pentest_session; @@ -32,6 +33,7 @@ pub use impact_analysis::ImpactAnalysisPage; pub use issues::IssuesPage; pub use mcp_servers::McpServersPage; pub use mcp_tokens::McpTokensPage; +pub use onboarding::OnboardingPage; pub use overview::OverviewPage; pub use pentest_dashboard::PentestDashboardPage; pub use pentest_session::PentestSessionPage; diff --git a/compliance-dashboard/src/pages/onboarding.rs b/compliance-dashboard/src/pages/onboarding.rs new file mode 100644 index 0000000..ce18faa --- /dev/null +++ b/compliance-dashboard/src/pages/onboarding.rs @@ -0,0 +1,377 @@ +use dioxus::prelude::*; + +use crate::components::page_header::PageHeader; +use crate::infrastructure::onboarding::{ + create_target, detect_target, fetch_applicable_scans, ArtifactInputDto, +}; + +/// (value, label, one-line description) for the 9 target families. +const TARGET_TYPES: &[(&str, &str, &str)] = &[ + ("web_app", "Web Application", "Front end + server"), + ("backend_service", "Backend / API", "REST, GraphQL, gRPC"), + ("desktop_app", "Desktop App", "Windows / macOS / Linux"), + ("android_app", "Android App", "APK / AAB"), + ("ios_app", "iOS App", "IPA"), + ( + "firmware_bare_metal", + "Firmware — bare metal", + "No operating system", + ), + ("firmware_rtos", "Firmware — RTOS", "Zephyr, FreeRTOS, ..."), + ( + "embedded_linux_yocto", + "Embedded Linux / Yocto", + "BSP + image", + ), + ("plc_sps", "PLC / SPS", "IEC 61131-3"), +]; + +/// (value, label) for the artifact kinds a user can attach. +const ARTIFACT_KINDS: &[(&str, &str)] = &[ + ("git_repo", "Git repository"), + ("source_archive", "Source archive (zip)"), + ("firmware_image", "Firmware image"), + ("mobile_package", "Mobile package (APK/IPA)"), + ("container_image", "Container image"), + ("live_url", "Live URL"), + ("plc_project", "PLC project"), + ("plaintext_description", "Description (text)"), +]; + +const STEP_LABELS: &[&str] = &["Target type", "Artifacts", "Review", "Done"]; + +/// One row in the applicable-scans list on the success step. +#[component] +fn ScanRow(scan: serde_json::Value) -> Element { + let name = scan + .get("scan") + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(); + let rationale = scan + .get("rationale") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let blocked = scan + .get("blocked_reason") + .and_then(|v| v.as_str()) + .map(String::from); + let default_on = scan + .get("default_on") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let badge_class = if blocked.is_some() { + "badge badge-info" + } else if default_on { + "badge badge-success" + } else { + "badge" + }; + rsx! { + div { style: "display: flex; gap: 8px; align-items: center; padding: 6px 0;", + span { class: "{badge_class}", "{name}" } + span { style: "opacity: 0.8;", "{rationale}" } + if let Some(b) = blocked { + span { style: "opacity: 0.6; font-style: italic;", "— {b}" } + } + } + } +} + +fn kind_label(kind: &str) -> &str { + ARTIFACT_KINDS + .iter() + .find(|(v, _)| *v == kind) + .map(|(_, l)| *l) + .unwrap_or(kind) +} + +fn type_label(value: &str) -> &str { + TARGET_TYPES + .iter() + .find(|(v, _, _)| *v == value) + .map(|(_, l, _)| *l) + .unwrap_or(value) +} + +#[component] +pub fn OnboardingPage() -> Element { + let mut step = use_signal(|| 0usize); + let mut name = use_signal(String::new); + let mut target_type = use_signal(String::new); + let mut description = use_signal(String::new); + let mut artifacts = use_signal(Vec::::new); + + // "Add artifact" mini-form. + let mut new_kind = use_signal(|| "git_repo".to_string()); + let mut new_source = use_signal(String::new); + let mut new_branch = use_signal(|| "main".to_string()); + + // Create + result state. + let mut creating = use_signal(|| false); + let mut error = use_signal(|| Option::::None); + let mut scans = use_signal(Vec::::new); + let mut suggested = use_signal(|| Option::::None); + + let step_now = step(); + let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty(); + let has_artifacts = !artifacts().is_empty(); + + rsx! { + PageHeader { + title: "Onboard a target", + description: "Add a target, attach its artifacts, and see which scans apply.", + } + + // Stepper. + div { class: "wizard-steps", + for (i, label) in STEP_LABELS.iter().enumerate() { + div { + class: if i == step_now { "wizard-step wizard-step-active" } else { "wizard-step" }, + span { class: "wizard-step-dot", "{i + 1}" } + span { class: "wizard-step-label", "{label}" } + } + } + } + + if let Some(err) = error() { + div { class: "card", style: "border-color: var(--danger, #d33); margin-bottom: 12px;", + div { class: "card-header", "Error" } + div { style: "padding: 12px;", "{err}" } + } + } + + div { class: "card", + // ---- Step 0: target type + name ---- + if step_now == 0 { + div { class: "card-header", "What kind of software is this?" } + div { style: "padding: 16px;", + div { class: "form-group", + label { "Name" } + input { + r#type: "text", + placeholder: "acme-web", + value: "{name}", + oninput: move |e| name.set(e.value()), + } + } + div { + style: "display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; margin-top: 12px;", + for (value, tlabel, tdesc) in TARGET_TYPES.iter().copied() { + div { + class: "card", + style: if target_type() == value { + "padding: 12px; cursor: pointer; border: 2px solid var(--accent, #3b82f6);" + } else { + "padding: 12px; cursor: pointer;" + }, + onclick: move |_| target_type.set(value.to_string()), + div { style: "font-weight: 600;", "{tlabel}" } + div { style: "font-size: 0.85em; opacity: 0.7;", "{tdesc}" } + } + } + } + } + } + + // ---- Step 1: artifacts ---- + if step_now == 1 { + div { class: "card-header", "Attach artifacts" } + div { style: "padding: 16px;", + div { style: "display: flex; gap: 8px; flex-wrap: wrap; align-items: flex-end;", + div { class: "form-group", style: "margin: 0;", + label { "Kind" } + select { + value: "{new_kind}", + oninput: move |e| new_kind.set(e.value()), + for (value, klabel) in ARTIFACT_KINDS.iter().copied() { + option { value: "{value}", "{klabel}" } + } + } + } + div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", + label { "Reference (URL / path / text)" } + input { + r#type: "text", + placeholder: "https://git.example.com/acme.git", + value: "{new_source}", + oninput: move |e| new_source.set(e.value()), + } + } + if new_kind() == "git_repo" { + div { class: "form-group", style: "margin: 0;", + label { "Branch" } + input { + r#type: "text", + value: "{new_branch}", + oninput: move |e| new_branch.set(e.value()), + } + } + } + button { + class: "btn btn-secondary", + onclick: move |_| { + let kind = new_kind(); + if !new_source().trim().is_empty() { + let branch = if kind == "git_repo" { Some(new_branch()) } else { None }; + artifacts.write().push(ArtifactInputDto { + kind, + source_ref: new_source(), + branch, + plc_format: None, + }); + new_source.set(String::new()); + } + }, + "+ Add" + } + } + + div { style: "margin-top: 16px;", + if has_artifacts { + for (i, a) in artifacts().iter().enumerate() { + div { + style: "display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; border: 1px solid var(--border, #333); border-radius: 6px; margin-bottom: 6px;", + span { + span { style: "opacity: 0.7;", "{kind_label(&a.kind)}: " } + "{a.source_ref}" + } + button { + class: "btn btn-ghost-danger btn-sm", + onclick: move |_| { artifacts.write().remove(i); }, + "Remove" + } + } + } + } else { + div { style: "opacity: 0.6;", "No artifacts yet. Add at least one." } + } + } + } + } + + // ---- Step 2: review + create ---- + if step_now == 2 { + div { class: "card-header", "Review" } + div { style: "padding: 16px;", + div { class: "wizard-summary", + div { strong { "Name: " } "{name()}" } + div { strong { "Type: " } "{type_label(&target_type())}" } + div { strong { "Artifacts:" } } + ul { + for a in artifacts() { + li { "{kind_label(&a.kind)}: {a.source_ref}" } + } + } + } + div { style: "margin-top: 12px; opacity: 0.7; font-size: 0.9em;", + "The applicable scans (SAST / DAST / firmware / PLC) are shown after the target is created." + } + } + } + + // ---- Step 3: created ---- + if step_now == 3 { + div { class: "card-header", "Target onboarded" } + div { style: "padding: 16px;", + p { + strong { "{name()}" } + " was created." + if let Some(s) = suggested() { + span { " Suggested type from detection: " strong { "{type_label(&s)}" } "." } + } + } + h4 { style: "margin-top: 16px;", "Applicable scans" } + if scans().is_empty() { + div { style: "opacity: 0.6;", "No scans available (no code / URL / firmware artifact present)." } + } else { + for s in scans() { + ScanRow { scan: s } + } + } + div { style: "margin-top: 16px;", + button { + class: "btn btn-secondary", + onclick: move |_| { + step.set(0); + name.set(String::new()); + target_type.set(String::new()); + description.set(String::new()); + artifacts.write().clear(); + scans.write().clear(); + suggested.set(None); + error.set(None); + }, + "Onboard another" + } + } + } + } + } + + // ---- Footer navigation ---- + if step_now < 3 { + div { style: "display: flex; justify-content: space-between; margin-top: 16px;", + button { + class: "btn btn-back", + disabled: step_now == 0, + onclick: move |_| { if step() > 0 { step.set(step() - 1); } }, + "Back" + } + if step_now < 2 { + button { + class: "btn btn-primary", + disabled: (step_now == 0 && !can_advance_type) || (step_now == 1 && !has_artifacts), + onclick: move |_| step.set(step() + 1), + "Next" + } + } else { + button { + class: "btn btn-primary", + disabled: creating(), + onclick: move |_| { + let n = name(); + let tt = target_type(); + let desc = description(); + let arts = artifacts(); + let d = if desc.trim().is_empty() { None } else { Some(desc) }; + creating.set(true); + error.set(None); + spawn(async move { + match create_target(n, tt, d, arts).await { + Ok(resp) => { + let id = resp + .data + .get("_id") + .and_then(|o| o.get("$oid")) + .and_then(|s| s.as_str()) + .map(String::from); + if let Some(id) = id { + if let Ok(sc) = fetch_applicable_scans(id.clone()).await { + scans.set(sc.data.scans); + } + if let Ok(det) = detect_target(id).await { + suggested.set( + det.data + .get("classification") + .and_then(|c| c.get("suggested")) + .and_then(|s| s.as_str()) + .map(String::from), + ); + } + } + step.set(3); + } + Err(e) => error.set(Some(e.to_string())), + } + creating.set(false); + }); + }, + if creating() { "Creating..." } else { "Create target" } + } + } + } + } + } +}