Files
compliance-scanner-agent/compliance-dashboard/src/pages/onboarding.rs
T
Sharang ParnerkarandClaude Opus 4.8 bd80ccef49 feat(onboarding): multipart file upload for blob artifacts
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>
2026-07-16 11:28:35 +02:00

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" }
}
}
}
}
}
}