use dioxus::prelude::*; use crate::components::page_header::PageHeader; use crate::infrastructure::onboarding::{ create_target, detect_target, fetch_applicable_scans, trigger_target_scan, upload_target_artifact, validate_artifact_ref, validate_target_name, 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"]; /// Artifact kinds provided as an uploaded file (rather than a URL/text ref). fn is_file_kind(kind: &str) -> bool { matches!( kind, "plc_project" | "firmware_image" | "source_archive" | "mobile_package" ) } /// A file artifact staged in the wizard, uploaded after the target is created. #[derive(Clone, PartialEq)] struct PendingFile { kind: String, plc_format: Option, filename: String, bytes: Vec, } /// 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()); // File-upload artifacts (PLC project, firmware image, ...). let mut new_plc_format = use_signal(|| "plcopen_xml".to_string()); let mut new_file = use_signal(|| Option::<(String, Vec)>::None); let mut pending_files = use_signal(Vec::::new); // 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 mut created_id = use_signal(|| Option::::None); let mut scan_msg = use_signal(|| Option::::None); let step_now = step(); let name_error = validate_target_name(&name()); let can_advance_type = name_error.is_none() && !target_type().trim().is_empty(); let has_artifacts = !artifacts().is_empty() || !pending_files().is_empty(); // Live validation of the artifact reference being typed (empty = no error yet). let new_source_error = if new_source().is_empty() { None } else { validate_artifact_ref(&new_kind(), &new_source()) }; 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()), } if !name().is_empty() { if let Some(err) = name_error.clone() { div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 4px;", "{err}" } } } } 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}" } } } } if is_file_kind(&new_kind()) { div { class: "form-group", style: "margin: 0; flex: 1; min-width: 240px;", label { "File" } input { r#type: "file", onchange: move |evt| { let Some(file) = evt.files().into_iter().next() else { return; }; let name = file.name(); spawn(async move { if let Ok(bytes) = file.read_bytes().await { new_file.set(Some((name, bytes.to_vec()))); } }); }, } } if new_kind() == "plc_project" { div { class: "form-group", style: "margin: 0;", label { "Format" } select { value: "{new_plc_format}", oninput: move |e| new_plc_format.set(e.value()), option { value: "plcopen_xml", "PLCopen XML" } option { value: "structured_text", "Structured Text" } } } } button { class: "btn btn-secondary", disabled: new_file().is_none(), onclick: move |_| { if let Some((fname, data)) = new_file() { let kind = new_kind(); let plc_format = if kind == "plc_project" { Some(new_plc_format()) } else { None }; pending_files.write().push(PendingFile { kind, plc_format, filename: fname, bytes: data, }); new_file.set(None); } }, "+ Add file" } } else { 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", disabled: new_source().trim().is_empty() || new_source_error.is_some(), onclick: move |_| { let kind = new_kind(); if !new_source().trim().is_empty() && validate_artifact_ref(&kind, &new_source()).is_none() { 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" } } } if is_file_kind(&new_kind()) { if let Some((fname, data)) = new_file() { div { style: "font-size: 0.85em; opacity: 0.7; margin-top: 6px;", "Selected: {fname} ({data.len()} bytes)" } } } else if let Some(err) = new_source_error.clone() { div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 6px;", "{err}" } } // Staged file artifacts (uploaded after the target is created). for (i, pf) in pending_files().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-top: 6px;", span { span { style: "opacity: 0.7;", "{kind_label(&pf.kind)} (file): " } "{pf.filename} ({pf.bytes.len()} bytes)" } button { class: "btn btn-ghost-danger btn-sm", onclick: move |_| { pending_files.write().remove(i); }, "Remove" } } } 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 } } } if let Some(msg) = scan_msg() { div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" } } div { style: "margin-top: 16px; display: flex; gap: 8px;", button { class: "btn btn-primary", onclick: move |_| { if let Some(id) = created_id() { scan_msg.set(Some("Scan triggered...".to_string())); spawn(async move { match trigger_target_scan(id).await { Ok(_) => scan_msg.set(Some( "Scan started — findings will appear as it runs.".to_string(), )), Err(e) => scan_msg.set(Some(format!("Failed to start scan: {e}"))), } }); } }, "Run scan" } 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(); pending_files.write().clear(); new_file.set(None); scans.write().clear(); suggested.set(None); created_id.set(None); scan_msg.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 files = pending_files(); 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 { created_id.set(Some(id.clone())); // Upload staged file artifacts now that the target exists. for pf in files { let _ = upload_target_artifact( id.clone(), pf.kind, pf.plc_format, pf.filename, pf.bytes, ) .await; } 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" } } } } } } }