feat(onboarding): input validation + editable targets (#160)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Successful in 4m6s
CI / Deploy Dashboard (push) Successful in 3m11s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped

This commit was merged in pull request #160.
This commit is contained in:
2026-07-13 17:57:48 +00:00
parent 613847d4d3
commit 87f240b26f
6 changed files with 374 additions and 6 deletions
@@ -39,6 +39,59 @@ pub struct ApplicableScansResponse {
pub data: ApplicableScansData,
}
/// Validate a target name. The name is used as the clone directory downstream,
/// so it must be a single safe segment (no slashes) and free of stray spaces.
pub fn validate_target_name(name: &str) -> Option<String> {
let n = name.trim();
if n.is_empty() {
return Some("Enter a name".to_string());
}
if name != n {
return Some("Remove the leading/trailing spaces".to_string());
}
if n.contains('/') || n.contains('\\') {
return Some("No slashes — the name becomes a folder (e.g. stm32f411-blinky)".to_string());
}
None
}
/// Client-side validation of an artifact reference for its kind. Returns an
/// error message when the value is obviously wrong for its category, so the
/// wizard / editor can flag it up front instead of the scan discovering it.
pub fn validate_artifact_ref(kind: &str, source_ref: &str) -> Option<String> {
let s = source_ref;
if s.trim().is_empty() {
return Some("Cannot be empty".to_string());
}
if s != s.trim() {
return Some("Remove the leading/trailing spaces".to_string());
}
let no_space = !s.contains(char::is_whitespace);
match kind {
"git_repo" => {
let looks_git = s.starts_with("https://")
|| s.starts_with("http://")
|| s.starts_with("ssh://")
|| s.starts_with("git://")
|| (s.contains('@') && s.contains(':'));
(!(looks_git && no_space))
.then(|| "Enter a git URL — https://…, ssh://…, or git@host:path".to_string())
}
"live_url" => {
let ok = (s.starts_with("https://") || s.starts_with("http://")) && no_space;
(!ok).then(|| "Enter an http(s) URL, e.g. https://app.example.com".to_string())
}
"container_image" => {
(!no_space).then(|| "Enter an image ref, e.g. registry/name:tag".to_string())
}
"source_archive" | "firmware_image" | "mobile_package" | "plc_project" => {
(!no_space).then(|| "Enter a path or URL (no spaces)".to_string())
}
// plaintext_description (and anything unknown): accept free-form text.
_ => None,
}
}
/// List onboarded targets.
#[server]
pub async fn fetch_targets() -> Result<TargetsResponse, ServerFnError> {
@@ -77,6 +130,41 @@ pub async fn create_target(
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Update a target's name / type / artifacts (dashboard editor).
#[server]
pub async fn update_target(
id: String,
name: Option<String>,
target_type: Option<String>,
artifacts: Option<Vec<ArtifactInputDto>>,
) -> Result<TargetResponse, ServerFnError> {
let mut body = serde_json::Map::new();
if let Some(n) = name {
body.insert("name".to_string(), serde_json::json!(n));
}
if let Some(t) = target_type {
body.insert("target_type".to_string(), serde_json::json!(t));
}
if let Some(a) = artifacts {
body.insert(
"artifacts".to_string(),
serde_json::to_value(a).map_err(|e| ServerFnError::new(e.to_string()))?,
);
}
let resp = super::agent_client::agent_request(
reqwest::Method::PATCH,
&format!("/api/v1/targets/{id}"),
)
.await?
.json(&serde_json::Value::Object(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> {
+22 -3
View File
@@ -2,7 +2,8 @@ use dioxus::prelude::*;
use crate::components::page_header::PageHeader;
use crate::infrastructure::onboarding::{
create_target, detect_target, fetch_applicable_scans, trigger_target_scan, ArtifactInputDto,
create_target, detect_target, fetch_applicable_scans, trigger_target_scan,
validate_artifact_ref, validate_target_name, ArtifactInputDto,
};
/// (value, label, one-line description) for the 9 target families.
@@ -117,8 +118,15 @@ pub fn OnboardingPage() -> Element {
let mut scan_msg = use_signal(|| Option::<String>::None);
let step_now = step();
let can_advance_type = !name().trim().is_empty() && !target_type().trim().is_empty();
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();
// 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 {
@@ -157,6 +165,11 @@ pub fn OnboardingPage() -> Element {
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;",
@@ -213,9 +226,12 @@ pub fn OnboardingPage() -> Element {
}
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() {
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,
@@ -229,6 +245,9 @@ pub fn OnboardingPage() -> Element {
"+ Add"
}
}
if let Some(err) = new_source_error.clone() {
div { style: "color: var(--danger, #d33); font-size: 0.85em; margin-top: 6px;", "{err}" }
}
div { style: "margin-top: 16px;",
if has_artifacts {
+206 -1
View File
@@ -12,9 +12,35 @@ use dioxus_free_icons::Icon;
use crate::components::page_header::PageHeader;
use crate::components::toast::{ToastType, Toasts};
use crate::infrastructure::onboarding::{
delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan,
delete_target, fetch_applicable_scans, fetch_targets, trigger_target_scan, update_target,
validate_artifact_ref, validate_target_name, ArtifactInputDto,
};
/// The nine target families (value, label) for the edit form's type selector.
const TARGET_TYPES: &[(&str, &str)] = &[
("web_app", "Web Application"),
("backend_service", "Backend / API"),
("desktop_app", "Desktop App"),
("android_app", "Android App"),
("ios_app", "iOS App"),
("firmware_bare_metal", "Firmware — bare metal"),
("firmware_rtos", "Firmware — RTOS"),
("embedded_linux_yocto", "Embedded Linux / Yocto"),
("plc_sps", "PLC / SPS"),
];
/// The artifact kinds (value, label) for the edit form.
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)"),
];
/// Prettify a snake_case target-type value into a human label.
fn pretty_type(v: &str) -> String {
match v {
@@ -106,6 +132,17 @@ pub fn TargetsPage() -> Element {
let mut expanded_ids = use_signal(Vec::<String>::new);
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None);
// Edit-target modal state.
let mut edit_id = use_signal(|| Option::<String>::None);
let mut edit_name = use_signal(String::new);
let mut edit_type = use_signal(String::new);
let mut edit_arts = use_signal(Vec::<ArtifactInputDto>::new);
let mut edit_saving = use_signal(|| false);
// In-modal "add artifact" mini-form.
let mut e_kind = use_signal(|| "git_repo".to_string());
let mut e_source = use_signal(String::new);
let mut e_branch = use_signal(|| "main".to_string());
let mut targets = use_resource(move || async move { fetch_targets().await.ok() });
rsx! {
@@ -163,6 +200,145 @@ pub fn TargetsPage() -> Element {
}
}
// ── Edit target ──
if let Some(eid) = edit_id() {
{
let name_err = validate_target_name(&edit_name());
let e_source_err = if e_source().is_empty() {
None
} else {
validate_artifact_ref(&e_kind(), &e_source())
};
rsx! {
div { class: "modal-overlay",
div { class: "modal-dialog",
h3 { "Edit target" }
div { class: "form-group",
label { "Name" }
input {
r#type: "text",
value: "{edit_name}",
oninput: move |e| edit_name.set(e.value()),
}
if !edit_name().is_empty() {
if let Some(err) = name_err.clone() {
div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" }
}
}
}
div { class: "form-group",
label { "Type" }
select {
value: "{edit_type}",
oninput: move |e| edit_type.set(e.value()),
for (v, l) in TARGET_TYPES.iter().copied() {
option { value: "{v}", "{l}" }
}
}
}
label { style: "font-weight: 600;", "Artifacts" }
for (i, a) in edit_arts().iter().enumerate() {
div { style: "display: flex; justify-content: space-between; align-items: center; padding: 4px 0;",
span { style: "font-size: 0.9em;",
span { style: "opacity: 0.7;", "{a.kind}: " }
span { style: "font-family: monospace;", "{a.source_ref}" }
}
button {
class: "btn btn-ghost btn-ghost-danger btn-sm",
onclick: move |_| { edit_arts.write().remove(i); },
"Remove"
}
}
}
div { style: "display: flex; gap: 8px; align-items: flex-end; margin-top: 8px;",
div { class: "form-group", style: "margin: 0;",
label { "Kind" }
select {
value: "{e_kind}",
oninput: move |e| e_kind.set(e.value()),
for (v, l) in ARTIFACT_KINDS.iter().copied() {
option { value: "{v}", "{l}" }
}
}
}
div { class: "form-group", style: "margin: 0; flex: 1;",
label { "Reference" }
input {
r#type: "text",
value: "{e_source}",
oninput: move |e| e_source.set(e.value()),
}
}
if e_kind() == "git_repo" {
div { class: "form-group", style: "margin: 0;",
label { "Branch" }
input {
r#type: "text",
value: "{e_branch}",
oninput: move |e| e_branch.set(e.value()),
}
}
}
button {
class: "btn btn-secondary",
disabled: e_source().trim().is_empty() || e_source_err.is_some(),
onclick: move |_| {
let kind = e_kind();
if !e_source().trim().is_empty()
&& validate_artifact_ref(&kind, &e_source()).is_none()
{
let branch = if kind == "git_repo" { Some(e_branch()) } else { None };
edit_arts.write().push(ArtifactInputDto {
kind,
source_ref: e_source(),
branch,
plc_format: None,
});
e_source.set(String::new());
}
},
"+ Add"
}
}
if let Some(err) = e_source_err.clone() {
div { style: "color: var(--danger, #d33); font-size: 0.85em;", "{err}" }
}
div { class: "modal-actions",
button {
class: "btn btn-secondary",
onclick: move |_| edit_id.set(None),
"Cancel"
}
button {
class: "btn btn-primary",
disabled: edit_saving() || name_err.is_some(),
onclick: move |_| {
let id = eid.clone();
let nm = edit_name();
let tt = edit_type();
let arts = edit_arts();
edit_saving.set(true);
spawn(async move {
match update_target(id, Some(nm), Some(tt), Some(arts)).await {
Ok(_) => {
toasts.push(ToastType::Success, "Target updated");
targets.restart();
edit_id.set(None);
}
Err(e) => toasts.push(ToastType::Error, e.to_string()),
}
edit_saving.set(false);
});
},
if edit_saving() { "Saving..." } else { "Save" }
}
}
}
}
}
}
}
{
let targets_snapshot = targets.read().clone();
match &targets_snapshot {
@@ -223,8 +399,12 @@ pub fn TargetsPage() -> Element {
let id_scan = id.clone();
let id_exp = id.clone();
let id_del = id.clone();
let id_edit = id.clone();
let name_del = name.clone();
let name_edit = name.clone();
let ttype_raw = str_at(&t, "target_type").to_string();
let artifacts_detail = artifacts.clone();
let artifacts_edit = artifacts.clone();
rsx! {
tr {
td { strong { "{name}" } }
@@ -253,6 +433,31 @@ pub fn TargetsPage() -> Element {
},
Icon { icon: BsInfoCircle, width: 16, height: 16 }
}
button {
class: "btn btn-ghost",
title: "Edit target",
onclick: move |_| {
edit_name.set(name_edit.clone());
edit_type.set(ttype_raw.clone());
let arts: Vec<ArtifactInputDto> = artifacts_edit
.iter()
.map(|a| ArtifactInputDto {
kind: str_at(a, "kind").to_string(),
source_ref: str_at(a, "source_ref").to_string(),
branch: a
.get("git")
.and_then(|g| g.get("default_branch"))
.and_then(|b| b.as_str())
.map(String::from),
plc_format: None,
})
.collect();
edit_arts.set(arts);
e_source.set(String::new());
edit_id.set(Some(id_edit.clone()));
},
Icon { icon: BsPencil, width: 16, height: 16 }
}
button {
class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" },
title: "Run scan",