Add a real file-upload path so PLC projects (and other blob artifacts:
firmware images, source archives, mobile packages) can be delivered to
the agent instead of referencing a local path.
- agent: POST /api/v1/targets/{id}/artifacts/upload (axum multipart);
stores bytes under {artifact_store_base_path}/uploads/{id}/... and
$push-es the artifact (with stored_path + size_bytes) onto the target.
- dashboard: upload_target_artifact server fn (reqwest multipart) + wizard
file input for blob artifact kinds, with a PLC format selector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
529 lines
24 KiB
Rust
529 lines
24 KiB
Rust
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<String>,
|
|
filename: String,
|
|
bytes: Vec<u8>,
|
|
}
|
|
|
|
/// 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::<ArtifactInputDto>::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<u8>)>::None);
|
|
let mut pending_files = use_signal(Vec::<PendingFile>::new);
|
|
|
|
// Create + result state.
|
|
let mut creating = use_signal(|| false);
|
|
let mut error = use_signal(|| Option::<String>::None);
|
|
let mut scans = use_signal(Vec::<serde_json::Value>::new);
|
|
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);
|
|
|
|
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" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|