feat(onboarding): PLC/blob file upload (multipart) + single-file ingest fix #163

Merged
sharang merged 2 commits from feat/artifact-file-upload into main 2026-07-16 09:41:13 +00:00
6 changed files with 291 additions and 39 deletions
Showing only changes of commit bd80ccef49 - Show all commits
+1 -1
View File
@@ -23,7 +23,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] }
mongodb = { version = "3", features = ["rustls-tls", "compat-3-0-0"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json", "rustls-tls", "multipart"], default-features = false }
thiserror = "2"
sha2 = "0.10"
hex = "0.4"
+2 -2
View File
@@ -34,7 +34,7 @@ hex = { workspace = true }
uuid = { workspace = true }
secrecy = { workspace = true }
regex = { workspace = true }
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] }
git2 = "0.20"
octocrab = "0.44"
@@ -65,5 +65,5 @@ tokio = { workspace = true }
mongodb = { workspace = true }
uuid = { workspace = true }
secrecy = { workspace = true }
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
tower-http = { version = "0.6", features = ["cors"] }
+111 -1
View File
@@ -5,7 +5,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Extension, Path, Query};
use axum::extract::{Extension, Multipart, Path, Query};
use axum::http::StatusCode;
use axum::Json;
use mongodb::bson::{doc, oid::ObjectId, to_bson};
@@ -377,6 +377,116 @@ pub async fn add_artifact(
get_target(Extension(agent), tenant, Path(id)).await
}
/// POST /api/v1/targets/{id}/artifacts/upload — attach an artifact by uploading
/// its file (PLC project, firmware image, source archive, mobile package). The
/// bytes are written to the artifact blob store and referenced by `stored_path`,
/// so ingest resolves them locally (no URL fetch).
///
/// Multipart fields: `file` (required), `kind` (required, snake_case
/// `ArtifactKind`), `plc_format` (optional, for PLC projects).
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn upload_artifact(
Extension(agent): AgentExt,
tenant: TenantCtx,
Path(id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<OnboardedTarget>>, StatusCode> {
let oid = parse_oid(&id)?;
let db = tenant_db(&agent, &tenant).await?;
if db
.onboarded_targets()
.find_one(doc! { "_id": oid })
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_none()
{
return Err(StatusCode::NOT_FOUND);
}
let mut kind: Option<ArtifactKind> = None;
let mut plc_format: Option<PlcFormat> = None;
let mut filename = String::from("upload.bin");
let mut bytes: Option<axum::body::Bytes> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| StatusCode::BAD_REQUEST)?
{
match field.name().unwrap_or("") {
"kind" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
kind = parse_enum(&v);
}
"plc_format" => {
let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?;
plc_format = parse_enum(&v);
}
"file" => {
if let Some(fname) = field.file_name() {
filename = fname.to_string();
}
bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?);
}
_ => {}
}
}
let (Some(kind), Some(bytes)) = (kind, bytes) else {
return Err(StatusCode::BAD_REQUEST);
};
// Store the uploaded bytes under the artifact blob store.
let safe_name: String = filename
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
c
} else {
'_'
}
})
.collect();
let dir = std::path::Path::new(&agent.config.artifact_store_base_path)
.join("uploads")
.join(&id);
std::fs::create_dir_all(&dir).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let dest = dir.join(format!("{}_{safe_name}", uuid::Uuid::new_v4()));
std::fs::write(&dest, bytes.as_ref()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Build the artifact for this kind, referencing the stored file.
let mut artifact = match kind {
ArtifactKind::PlcProject => Artifact::plc_project(
filename.clone(),
plc_format.unwrap_or(PlcFormat::PlcopenXml),
),
ArtifactKind::FirmwareImage => Artifact::firmware_image(filename.clone()),
ArtifactKind::SourceArchive => Artifact::source_archive(filename.clone()),
ArtifactKind::MobilePackage => Artifact::mobile_package(filename.clone()),
// Non-file kinds (git repo, live URL, container ref, text) use the JSON
// add-artifact endpoint, not upload.
_ => return Err(StatusCode::BAD_REQUEST),
};
artifact.stored_path = Some(dest.to_string_lossy().to_string());
artifact.size_bytes = Some(bytes.len() as u64);
let artifact_bson = to_bson(&artifact).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
db.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$push": { "artifacts": artifact_bson }, "$set": { "updated_at": mongodb::bson::DateTime::now() } },
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
get_target(Extension(agent), tenant, Path(id)).await
}
/// Deserialize a snake_case enum value from a plain string.
fn parse_enum<T: for<'de> Deserialize<'de>>(s: &str) -> Option<T> {
serde_json::from_value(serde_json::Value::String(s.to_string())).ok()
}
/// GET /api/v1/targets/{id}/applicable-scans — the scan-applicability matrix.
#[tracing::instrument(skip_all, fields(target_id = %id))]
pub async fn applicable_scans_for_target(
+4
View File
@@ -26,6 +26,10 @@ pub fn build_router() -> Router {
"/api/v1/targets/{id}/artifacts",
post(handlers::onboarding::add_artifact),
)
.route(
"/api/v1/targets/{id}/artifacts/upload",
post(handlers::onboarding::upload_artifact),
)
.route(
"/api/v1/targets/{id}/applicable-scans",
get(handlers::onboarding::applicable_scans_for_target),
@@ -130,6 +130,37 @@ pub async fn create_target(
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Upload a file artifact (PLC project, firmware image, source archive, mobile
/// package) to a target — proxied to the agent as multipart.
#[server]
pub async fn upload_target_artifact(
id: String,
kind: String,
plc_format: Option<String>,
filename: String,
bytes: Vec<u8>,
) -> Result<TargetResponse, ServerFnError> {
let mut form = reqwest::multipart::Form::new().text("kind", kind).part(
"file",
reqwest::multipart::Part::bytes(bytes).file_name(filename),
);
if let Some(pf) = plc_format {
form = form.text("plc_format", pf);
}
let resp = super::agent_client::agent_request(
reqwest::Method::POST,
&format!("/api/v1/targets/{id}/artifacts/upload"),
)
.await?
.multipart(form)
.send()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
resp.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Update a target's name / type / artifacts (dashboard editor).
#[server]
pub async fn update_target(
+142 -35
View File
@@ -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);
}