feat(onboarding): enable opt-in scans from the wizard success step
CI / Detect Changes (pull_request) Has been cancelled
CI / Deploy Agent (pull_request) Has been cancelled
CI / Deploy Dashboard (pull_request) Has been cancelled
CI / Deploy Docs (pull_request) Has been cancelled
CI / Deploy MCP (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled

The ICS probe (ScanType::IcsProbe) and other default-off scans are
applicable to a target but excluded from a run unless the target's
scan_config.enabled_scans lists them — build_scan_plan only includes a
scan when default_on || enabled_scans.contains(scan). Until now the
wizard had no way to enable them, so they could never be triggered.

Add a checkbox per applicable-but-default-off scan on the success step,
and a new enable_target_scans server fn that PATCHes
/api/v1/targets/{id} with { scan_config: { enabled_scans } }. Run scan
now persists the ticked scans (serde names, e.g. "icsprobe") before
triggering, so the first run includes them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-07-16 22:33:33 +02:00
co-authored by Claude Opus 4.8
parent 097f86243e
commit 74ced0d740
2 changed files with 93 additions and 1 deletions
@@ -196,6 +196,28 @@ pub async fn update_target(
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Enable specific opt-in scans on a target by setting `scan_config.enabled_scans`.
/// `scans` are serde scan-type names (lowercase, no underscores — e.g. `icsprobe`).
#[server]
pub async fn enable_target_scans(
id: String,
scans: Vec<String>,
) -> Result<TargetResponse, ServerFnError> {
let body = serde_json::json!({ "scan_config": { "enabled_scans": scans } });
let resp = super::agent_client::agent_request(
reqwest::Method::PATCH,
&format!("/api/v1/targets/{id}"),
)
.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<TargetResponse, ServerFnError> {
+71 -1
View File
@@ -2,7 +2,7 @@ use dioxus::prelude::*;
use crate::components::page_header::PageHeader;
use crate::infrastructure::onboarding::{
create_target, detect_target, fetch_applicable_scans, trigger_target_scan,
create_target, detect_target, enable_target_scans, fetch_applicable_scans, trigger_target_scan,
upload_target_artifact, validate_artifact_ref, validate_target_name, ArtifactInputDto,
};
@@ -137,11 +137,35 @@ pub fn OnboardingPage() -> Element {
let mut suggested = use_signal(|| Option::<String>::None);
let mut created_id = use_signal(|| Option::<String>::None);
let mut scan_msg = use_signal(|| Option::<String>::None);
// Opt-in scans (default-off but unblocked) the user ticks to enable before
// running — stored as serde scan-type names (lowercase, no underscores).
let mut enabled_extra = use_signal(Vec::<String>::new);
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();
// Opt-in scans: applicable + unblocked, but default-off (e.g. the ICS probe).
// The user ticks these to enable them before the first run. Each entry is
// (display name for the label, serde scan-type name for the enable call —
// lowercase, no underscores, matching ScanType's rename_all = "lowercase").
let optin_scans: Vec<(String, String)> = scans()
.iter()
.filter_map(|s| {
let unblocked = s.get("blocked_reason").and_then(|v| v.as_str()).is_none();
let default_on = s
.get("default_on")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if unblocked && !default_on {
let display = s.get("scan").and_then(|v| v.as_str())?.to_string();
let serde_name = display.replace('_', "");
Some((display, serde_name))
} else {
None
}
})
.collect();
// Live validation of the artifact reference being typed (empty = no error yet).
let new_source_error = if new_source().is_empty() {
None
@@ -456,6 +480,39 @@ pub fn OnboardingPage() -> Element {
ScanRow { scan: s }
}
}
if !optin_scans.is_empty() {
div { style: "margin-top: 12px; padding: 10px; border: 1px dashed var(--border, #ccc); border-radius: 6px;",
div { style: "font-weight: 600; margin-bottom: 6px;", "Enable opt-in scans" }
div { style: "opacity: 0.7; font-size: 0.85em; margin-bottom: 8px;",
"These are applicable but off by default (they touch a live device). Tick to enable before running."
}
for pair in optin_scans.clone() {
{
let (display, serde_name) = pair;
let cb_name = serde_name.clone();
rsx! {
label {
style: "display: flex; gap: 6px; align-items: center; margin-top: 4px;",
input {
r#type: "checkbox",
checked: enabled_extra().contains(&serde_name),
onchange: move |_| {
let mut v = enabled_extra();
if let Some(p) = v.iter().position(|x| x == &cb_name) {
v.remove(p);
} else {
v.push(cb_name.clone());
}
enabled_extra.set(v);
},
}
"Enable {display}"
}
}
}
}
}
}
if let Some(msg) = scan_msg() {
div { style: "margin-top: 8px; color: var(--success, #2a2);", "{msg}" }
}
@@ -464,8 +521,21 @@ pub fn OnboardingPage() -> Element {
class: "btn btn-primary",
onclick: move |_| {
if let Some(id) = created_id() {
let extra = enabled_extra();
scan_msg.set(Some("Scan triggered...".to_string()));
spawn(async move {
// Persist any ticked opt-in scans first, so the
// agent's build_scan_plan includes them this run.
if !extra.is_empty() {
if let Err(e) =
enable_target_scans(id.clone(), extra).await
{
scan_msg.set(Some(format!(
"Failed to enable opt-in scans: {e}"
)));
return;
}
}
match trigger_target_scan(id).await {
Ok(_) => scan_msg.set(Some(
"Scan started — findings will appear as it runs.".to_string(),