feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix (#163)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 5m39s
CI / Deploy Dashboard (push) Successful in 4m7s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2m38s
This commit was merged in pull request #163.
This commit is contained in:
@@ -3,7 +3,7 @@ use dioxus::prelude::*;
|
||||
use crate::components::page_header::PageHeader;
|
||||
use crate::infrastructure::onboarding::{
|
||||
create_target, detect_target, fetch_applicable_scans, trigger_target_scan,
|
||||
validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
||||
upload_target_artifact, validate_artifact_ref, validate_target_name, ArtifactInputDto,
|
||||
};
|
||||
|
||||
/// (value, label, one-line description) for the 9 target families.
|
||||
@@ -41,6 +41,23 @@ const ARTIFACT_KINDS: &[(&str, &str)] = &[
|
||||
|
||||
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 {
|
||||
@@ -108,6 +125,10 @@ pub fn OnboardingPage() -> Element {
|
||||
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);
|
||||
@@ -120,7 +141,7 @@ pub fn OnboardingPage() -> Element {
|
||||
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();
|
||||
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
|
||||
@@ -205,49 +226,121 @@ pub fn OnboardingPage() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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() == "git_repo" {
|
||||
div { class: "form-group", style: "margin: 0;",
|
||||
label { "Branch" }
|
||||
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",
|
||||
value: "{new_branch}",
|
||||
oninput: move |e| new_branch.set(e.value()),
|
||||
placeholder: "https://git.example.com/acme.git",
|
||||
value: "{new_source}",
|
||||
oninput: move |e| new_source.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());
|
||||
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()),
|
||||
}
|
||||
}
|
||||
},
|
||||
"+ Add"
|
||||
}
|
||||
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 let Some(err) = new_source_error.clone() {
|
||||
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 {
|
||||
@@ -340,6 +433,8 @@ pub fn OnboardingPage() -> Element {
|
||||
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);
|
||||
@@ -378,6 +473,7 @@ pub fn OnboardingPage() -> Element {
|
||||
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);
|
||||
@@ -392,6 +488,17 @@ pub fn OnboardingPage() -> Element {
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user