From 9717a1efdab1b866cb5989ebb656e795b2a0f8a9 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:11:37 +0200 Subject: [PATCH] fix(onboarding): targets visibility + unified pipeline by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboarding wizard created OnboardedTargets that were invisible in the dashboard, and triggering a scan failed with "Repository not found": `/targets/{id}/scan` went through `run_scan`, which consulted the global `unified_pipeline` flag and fell to the legacy repository pipeline (reads `repositories`, not `onboarded_targets`). Agent - Add `ComplianceAgent::run_target_scan`, always dispatching to the unified `run_target` pipeline. The target-scan endpoint operates on `onboarded_targets` by construction, so it must not depend on the transition flag. `trigger_target_scan` now calls it. - Default `UNIFIED_PIPELINE` to on (no legacy `repositories` data in prod); set `UNIFIED_PIPELINE=0` to opt back to the legacy pipeline. - Scheduler now scans `onboarded_targets` (via `run_target_scan`) instead of the legacy `repositories` collection. Dashboard - New Targets page (`/targets`): lists onboarded targets with detected type, artifacts, findings count, applicable-scans matrix (on expand), plus Run scan and Delete. Sidebar "Repositories" nav becomes "Targets". - Remove the "Add Repository" form from the Repositories page — onboarding is the single entry point (private-repo auth + issue tracker move into the onboarding flow, revisable on the target). - Add `delete_target` server fn. Co-Authored-By: Claude Opus 4.8 --- compliance-agent/src/agent.rs | 20 ++ .../src/api/handlers/onboarding.rs | 5 +- compliance-agent/src/config.rs | 5 +- compliance-agent/src/scheduler.rs | 14 +- compliance-core/src/config.rs | 3 +- compliance-dashboard/src/app.rs | 2 + .../src/components/sidebar.rs | 4 +- .../src/infrastructure/onboarding.rs | 16 + compliance-dashboard/src/pages/mod.rs | 2 + .../src/pages/repositories.rs | 231 +----------- compliance-dashboard/src/pages/targets.rs | 339 ++++++++++++++++++ 11 files changed, 399 insertions(+), 242 deletions(-) create mode 100644 compliance-dashboard/src/pages/targets.rs diff --git a/compliance-agent/src/agent.rs b/compliance-agent/src/agent.rs index 4fa59fa..92d12f8 100644 --- a/compliance-agent/src/agent.rs +++ b/compliance-agent/src/agent.rs @@ -70,6 +70,26 @@ impl ComplianceAgent { } } + /// Run a scan for an onboarded target through the unified pipeline, + /// unconditionally. + /// + /// Unlike [`Self::run_scan`], this does *not* consult the + /// `unified_pipeline` transition flag: the caller (the `/targets/{id}/scan` + /// endpoint) operates on `onboarded_targets` by construction, so it must + /// always dispatch to `run_target` regardless of how the legacy paths + /// (scheduler, webhooks, `/repositories/{id}/scan`) are configured. + pub async fn run_target_scan( + &self, + tenant_id: &str, + target_id: &str, + trigger: compliance_core::models::ScanTrigger, + ) -> Result<(), crate::error::AgentError> { + let db = self.db_pool.for_tenant_id(tenant_id).await?; + let orchestrator = + PipelineOrchestrator::new(self.config.clone(), db, self.llm.clone(), self.http.clone()); + orchestrator.run_target(target_id, trigger).await + } + /// Run a PR review: scan the diff and post review comments. pub async fn run_pr_review( &self, diff --git a/compliance-agent/src/api/handlers/onboarding.rs b/compliance-agent/src/api/handlers/onboarding.rs index 6784353..f3d7f9d 100644 --- a/compliance-agent/src/api/handlers/onboarding.rs +++ b/compliance-agent/src/api/handlers/onboarding.rs @@ -375,8 +375,11 @@ pub async fn trigger_target_scan( let agent_clone = (*agent).clone(); let tenant_id = tenant.0.tenant_id.clone(); tokio::spawn(async move { + // Always the unified target pipeline — this endpoint is about an + // onboarded target by construction, independent of the global + // `unified_pipeline` transition flag used by the legacy paths. if let Err(e) = agent_clone - .run_scan( + .run_target_scan( &tenant_id, &id, compliance_core::models::ScanTrigger::Manual, diff --git a/compliance-agent/src/config.rs b/compliance-agent/src/config.rs index f05d74a..ad86563 100644 --- a/compliance-agent/src/config.rs +++ b/compliance-agent/src/config.rs @@ -47,9 +47,12 @@ pub fn load_config() -> Result { .unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()), artifact_store_base_path: env_var_opt("ARTIFACT_STORE_BASE_PATH") .unwrap_or_else(|| "/data/compliance-scanner/artifacts".to_string()), + // Defaults ON: the unified onboarded-target pipeline is now the primary + // path (no legacy `repositories` data in production). Set + // `UNIFIED_PIPELINE=0` to fall back to the legacy repository pipeline. unified_pipeline: env_var_opt("UNIFIED_PIPELINE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false), + .unwrap_or(true), ssh_key_path: env_var_opt("SSH_KEY_PATH") .unwrap_or_else(|| "/data/compliance-scanner/ssh/id_ed25519".to_string()), keycloak_url: env_var_opt("KEYCLOAK_URL"), diff --git a/compliance-agent/src/scheduler.rs b/compliance-agent/src/scheduler.rs index d1d1e1d..98f8a87 100644 --- a/compliance-agent/src/scheduler.rs +++ b/compliance-agent/src/scheduler.rs @@ -288,25 +288,25 @@ async fn scan_all_repos(agent: &ComplianceAgent, tenant_id: &str) { None => return, }; - let cursor = match db.repositories().find(doc! {}).await { + let cursor = match db.onboarded_targets().find(doc! {}).await { Ok(c) => c, Err(e) => { - tracing::error!("Failed to list repos for tenant '{tenant_id}': {e}"); + tracing::error!("Failed to list targets for tenant '{tenant_id}': {e}"); return; } }; - let repos: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await; + let targets: Vec<_> = cursor.filter_map(|r| async { r.ok() }).collect().await; - for repo in repos { - let repo_id = repo.id.map(|id| id.to_hex()).unwrap_or_default(); + for target in targets { + let target_id = target.id.map(|id| id.to_hex()).unwrap_or_default(); if let Err(e) = agent - .run_scan(tenant_id, &repo_id, ScanTrigger::Scheduled) + .run_target_scan(tenant_id, &target_id, ScanTrigger::Scheduled) .await { tracing::error!( "Scheduled scan failed for {} (tenant '{tenant_id}'): {e}", - repo.name + target.name ); } } diff --git a/compliance-core/src/config.rs b/compliance-core/src/config.rs index 41e406d..a787cc4 100644 --- a/compliance-core/src/config.rs +++ b/compliance-core/src/config.rs @@ -51,7 +51,8 @@ pub struct AgentConfig { pub tenant_registry_url: Option, /// When true, `run_scan` dispatches to the unified `run_target` pipeline /// (reads `onboarded_targets`) instead of the legacy repository pipeline. - /// Env `UNIFIED_PIPELINE`. Defaults off during the transition. + /// Env `UNIFIED_PIPELINE`. Defaults on; set `UNIFIED_PIPELINE=0` to use the + /// legacy repository pipeline. pub unified_pipeline: bool, } diff --git a/compliance-dashboard/src/app.rs b/compliance-dashboard/src/app.rs index a14d517..9c47ac4 100644 --- a/compliance-dashboard/src/app.rs +++ b/compliance-dashboard/src/app.rs @@ -12,6 +12,8 @@ pub enum Route { OverviewPage {}, #[route("/repositories")] RepositoriesPage {}, + #[route("/targets")] + TargetsPage {}, #[route("/onboard")] OnboardingPage {}, #[route("/findings")] diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index 3fd4138..62b8c9f 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -24,8 +24,8 @@ pub fn Sidebar() -> Element { icon: rsx! { Icon { icon: BsSpeedometer2, width: 18, height: 18 } }, }, NavItem { - label: "Repositories", - route: Route::RepositoriesPage {}, + label: "Targets", + route: Route::TargetsPage {}, icon: rsx! { Icon { icon: BsFolder2Open, width: 18, height: 18 } }, }, NavItem { diff --git a/compliance-dashboard/src/infrastructure/onboarding.rs b/compliance-dashboard/src/infrastructure/onboarding.rs index df2380b..5cd050e 100644 --- a/compliance-dashboard/src/infrastructure/onboarding.rs +++ b/compliance-dashboard/src/infrastructure/onboarding.rs @@ -106,6 +106,22 @@ pub async fn fetch_applicable_scans(id: String) -> Result Result { + let resp = super::agent_client::agent_request( + reqwest::Method::DELETE, + &format!("/api/v1/targets/{id}"), + ) + .await? + .send() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + resp.json() + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + /// Trigger a scan for a target. #[server] pub async fn trigger_target_scan(id: String) -> Result { diff --git a/compliance-dashboard/src/pages/mod.rs b/compliance-dashboard/src/pages/mod.rs index 4613d46..bd9722c 100644 --- a/compliance-dashboard/src/pages/mod.rs +++ b/compliance-dashboard/src/pages/mod.rs @@ -18,6 +18,7 @@ pub mod pentest_dashboard; pub mod pentest_session; pub mod repositories; pub mod sbom; +pub mod targets; pub use chat::ChatPage; pub use chat_index::ChatIndexPage; @@ -39,3 +40,4 @@ pub use pentest_dashboard::PentestDashboardPage; pub use pentest_session::PentestSessionPage; pub use repositories::RepositoriesPage; pub use sbom::SbomPage; +pub use targets::TargetsPage; diff --git a/compliance-dashboard/src/pages/repositories.rs b/compliance-dashboard/src/pages/repositories.rs index 22ba48b..89bf163 100644 --- a/compliance-dashboard/src/pages/repositories.rs +++ b/compliance-dashboard/src/pages/repositories.rs @@ -23,20 +23,6 @@ async fn async_sleep_5s() { #[component] pub fn RepositoriesPage() -> Element { let mut page = use_signal(|| 1u64); - let mut show_add_form = use_signal(|| false); - let mut name = use_signal(String::new); - let mut git_url = use_signal(String::new); - let mut branch = use_signal(|| "main".to_string()); - let mut auth_token = use_signal(String::new); - let mut auth_username = use_signal(String::new); - let mut show_auth = use_signal(|| false); - let mut ssh_public_key = use_signal(String::new); - let mut show_tracker = use_signal(|| false); - let mut tracker_type_val = use_signal(String::new); - let mut tracker_owner_val = use_signal(String::new); - let mut tracker_repo_val = use_signal(String::new); - let mut tracker_token_val = use_signal(String::new); - let mut adding = use_signal(|| false); let mut toasts = use_context::(); let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name) let mut edit_repo_id = use_signal(|| Option::::None); @@ -64,222 +50,7 @@ pub fn RepositoriesPage() -> Element { rsx! { PageHeader { title: "Repositories", - description: "Tracked git repositories", - } - - div { style: "margin-bottom: 16px;", - button { - class: "btn btn-primary", - onclick: move |_| show_add_form.toggle(), - if show_add_form() { "Cancel" } else { "+ Add Repository" } - } - } - - if show_add_form() { - div { class: "card", - div { class: "card-header", "Add Repository" } - div { class: "form-group", - label { "Name" } - input { - r#type: "text", - placeholder: "my-project", - value: "{name}", - oninput: move |e| name.set(e.value()), - } - } - div { class: "form-group", - label { "Git URL" } - input { - r#type: "text", - placeholder: "https://github.com/org/repo.git or git@github.com:org/repo.git", - value: "{git_url}", - oninput: move |e| git_url.set(e.value()), - } - } - div { class: "form-group", - label { "Default Branch" } - input { - r#type: "text", - placeholder: "main", - value: "{branch}", - oninput: move |e| branch.set(e.value()), - } - } - - // Private repo auth section - div { style: "margin-top: 8px;", - button { - class: "btn btn-ghost", - style: "font-size: 12px; padding: 4px 8px;", - onclick: move |_| { - let opening = !show_auth(); - show_auth.toggle(); - if opening { - // Fetch SSH key every time the section opens - ssh_public_key.set(String::new()); - spawn(async move { - match crate::infrastructure::repositories::fetch_ssh_public_key().await { - Ok(key) => ssh_public_key.set(key), - Err(_) => ssh_public_key.set("(not available)".to_string()), - } - }); - } - }, - if show_auth() { "Hide auth options" } else { "Private repository?" } - } - } - - if show_auth() { - div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", - // SSH deploy key display - div { style: "margin-bottom: 12px;", - label { style: "font-size: 12px; color: var(--text-secondary);", - "For SSH URLs: add this deploy key (read-only) to your repository" - } - div { - class: "copyable", - style: "margin-top: 4px; padding: 8px; background: var(--bg-secondary); border-radius: 4px;", - code { - style: "font-size: 11px; word-break: break-all; user-select: all;", - if ssh_public_key().is_empty() { - "Loading..." - } else { - "{ssh_public_key}" - } - } - if !ssh_public_key().is_empty() { - crate::components::copy_button::CopyButton { value: ssh_public_key(), small: true } - } - } - } - - // HTTPS auth fields - p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", - "For HTTPS URLs: provide an access token (PAT) or username/password" - } - div { class: "form-group", - label { "Auth Token / Password" } - input { - r#type: "password", - placeholder: "ghp_xxxx or personal access token", - value: "{auth_token}", - oninput: move |e| auth_token.set(e.value()), - } - } - div { class: "form-group", - label { "Username (optional, defaults to x-access-token)" } - input { - r#type: "text", - placeholder: "x-access-token", - value: "{auth_username}", - oninput: move |e| auth_username.set(e.value()), - } - } - } - } - - // Issue tracker config section - div { style: "margin-top: 8px;", - button { - class: "btn btn-ghost", - style: "font-size: 12px; padding: 4px 8px;", - onclick: move |_| show_tracker.toggle(), - if show_tracker() { "Hide tracker options" } else { "Issue tracker?" } - } - } - - if show_tracker() { - div { class: "auth-section", style: "margin-top: 12px; padding: 12px; border: 1px solid var(--border-subtle); border-radius: 8px;", - p { style: "font-size: 12px; color: var(--text-secondary); margin-bottom: 8px;", - "Configure an issue tracker to auto-create issues from findings" - } - div { class: "form-group", - label { "Tracker Type" } - select { - value: "{tracker_type_val}", - onchange: move |e| tracker_type_val.set(e.value()), - option { value: "", "None" } - option { value: "github", "GitHub" } - option { value: "gitlab", "GitLab" } - option { value: "gitea", "Gitea" } - option { value: "jira", "Jira" } - } - } - div { class: "form-group", - label { "Owner / Namespace" } - input { - r#type: "text", - placeholder: "org-name", - value: "{tracker_owner_val}", - oninput: move |e| tracker_owner_val.set(e.value()), - } - } - div { class: "form-group", - label { "Repository / Project" } - input { - r#type: "text", - placeholder: "repo-name", - value: "{tracker_repo_val}", - oninput: move |e| tracker_repo_val.set(e.value()), - } - } - div { class: "form-group", - label { "Tracker Token (PAT)" } - input { - r#type: "password", - placeholder: "ghp_xxxx / glpat-xxxx", - value: "{tracker_token_val}", - oninput: move |e| tracker_token_val.set(e.value()), - } - } - } - } - - button { - class: "btn btn-primary", - disabled: adding(), - onclick: move |_| { - let n = name(); - let u = git_url(); - let b = branch(); - let tok = { - let v = auth_token(); - if v.is_empty() { None } else { Some(v) } - }; - let usr = { - let v = auth_username(); - if v.is_empty() { None } else { Some(v) } - }; - let tt = { let v = tracker_type_val(); if v.is_empty() { None } else { Some(v) } }; - let t_owner = { let v = tracker_owner_val(); if v.is_empty() { None } else { Some(v) } }; - let t_repo = { let v = tracker_repo_val(); if v.is_empty() { None } else { Some(v) } }; - let t_tok = { let v = tracker_token_val(); if v.is_empty() { None } else { Some(v) } }; - adding.set(true); - spawn(async move { - match crate::infrastructure::repositories::add_repository(n, u, b, tok, usr, tt, t_owner, t_repo, t_tok).await { - Ok(_) => { - toasts.push(ToastType::Success, "Repository added"); - repos.restart(); - } - Err(e) => toasts.push(ToastType::Error, e.to_string()), - } - adding.set(false); - }); - show_add_form.set(false); - show_auth.set(false); - show_tracker.set(false); - name.set(String::new()); - git_url.set(String::new()); - auth_token.set(String::new()); - auth_username.set(String::new()); - tracker_type_val.set(String::new()); - tracker_owner_val.set(String::new()); - tracker_repo_val.set(String::new()); - tracker_token_val.set(String::new()); - }, - if adding() { "Validating..." } else { "Add" } - } - } + description: "Legacy git repositories. Onboard new targets from Targets / Onboard.", } // ── Delete confirmation dialog ── diff --git a/compliance-dashboard/src/pages/targets.rs b/compliance-dashboard/src/pages/targets.rs new file mode 100644 index 0000000..a202d38 --- /dev/null +++ b/compliance-dashboard/src/pages/targets.rs @@ -0,0 +1,339 @@ +//! Targets page — lists onboarded targets (the unified `OnboardedTarget` +//! records the wizard creates) and lets the user run a scan, inspect the +//! classification / applicable scans, or delete a target. +//! +//! Creation lives in the Onboard wizard (`/onboard`); this page is the +//! "where is my target, and what did the scan find" surface. + +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::*; +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, +}; + +/// Prettify a snake_case target-type value into a human label. +fn pretty_type(v: &str) -> String { + match v { + "web_app" => "Web Application".into(), + "backend_service" => "Backend / API".into(), + "desktop_app" => "Desktop App".into(), + "android_app" => "Android App".into(), + "ios_app" => "iOS App".into(), + "firmware_bare_metal" => "Firmware — bare metal".into(), + "firmware_rtos" => "Firmware — RTOS".into(), + "embedded_linux_yocto" => "Embedded Linux / Yocto".into(), + "plc_sps" => "PLC / SPS".into(), + other => other.replace('_', " "), + } +} + +fn str_at<'a>(v: &'a serde_json::Value, key: &str) -> &'a str { + v.get(key).and_then(|x| x.as_str()).unwrap_or("") +} + +fn target_id(t: &serde_json::Value) -> String { + t.get("_id") + .and_then(|o| o.get("$oid")) + .and_then(|s| s.as_str()) + .unwrap_or_default() + .to_string() +} + +/// The applicable-scans matrix for one target, fetched on expand. +#[component] +fn TargetScans(id: String) -> Element { + let scan_id = id.clone(); + let scans = use_resource(move || { + let id = scan_id.clone(); + async move { fetch_applicable_scans(id).await.ok() } + }); + + let snapshot = scans.read().clone(); + match &snapshot { + Some(Some(resp)) => { + let rows = resp.data.scans.clone(); + let pentest = resp.data.pentest_supported; + rsx! { + div { style: "margin-top: 8px;", + if rows.is_empty() { + div { style: "opacity: 0.6;", "No scans available (no code / URL / firmware artifact present)." } + } + for s in rows { + { + let name = str_at(&s, "scan").to_string(); + let rationale = str_at(&s, "rationale").to_string(); + let blocked = s.get("blocked_reason").and_then(|b| b.as_str()).map(String::from); + let default_on = s.get("default_on").and_then(|b| b.as_bool()).unwrap_or(false); + let badge = 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: 4px 0;", + span { class: "{badge}", "{name}" } + span { style: "opacity: 0.8; font-size: 0.9em;", "{rationale}" } + if let Some(b) = blocked { + span { style: "opacity: 0.6; font-style: italic; font-size: 0.9em;", "— {b}" } + } + } + } + } + } + if pentest { + div { style: "margin-top: 6px; opacity: 0.75; font-size: 0.85em;", + "Active pentest is supported for this target type." + } + } + } + } + } + Some(None) => rsx! { div { style: "opacity: 0.6;", "Failed to load applicable scans." } }, + None => rsx! { div { style: "opacity: 0.6;", "Loading scans..." } }, + } +} + +#[component] +pub fn TargetsPage() -> Element { + let mut toasts = use_context::(); + let mut scanning_ids = use_signal(Vec::::new); + let mut expanded_ids = use_signal(Vec::::new); + let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); + + let mut targets = use_resource(move || async move { fetch_targets().await.ok() }); + + rsx! { + PageHeader { + title: "Targets", + description: "Onboarded targets and what their scans found. Add new targets from Onboard.", + } + + div { style: "margin-bottom: 16px; display: flex; gap: 8px;", + Link { to: crate::app::Route::OnboardingPage {}, class: "btn btn-primary", + "+ Onboard a target" + } + button { + class: "btn btn-secondary", + onclick: move |_| targets.restart(), + "Refresh" + } + } + + // ── Delete confirmation ── + if let Some((del_id, del_name)) = confirm_delete() { + div { class: "modal-overlay", + div { class: "modal-dialog", + h3 { "Delete Target" } + p { "Delete " strong { "{del_name}" } "?" } + p { class: "modal-warning", + "This permanently removes the target and its findings, SBOM entries, scan runs, and CVE alerts." + } + div { class: "modal-actions", + button { + class: "btn btn-secondary", + onclick: move |_| confirm_delete.set(None), + "Cancel" + } + button { + class: "btn btn-danger", + onclick: move |_| { + let id = del_id.clone(); + let name = del_name.clone(); + confirm_delete.set(None); + spawn(async move { + match delete_target(id).await { + Ok(_) => { + toasts.push(ToastType::Success, format!("{name} deleted")); + targets.restart(); + } + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + }); + }, + "Delete" + } + } + } + } + } + + { + let targets_snapshot = targets.read().clone(); + match &targets_snapshot { + Some(Some(resp)) => { + let rows = resp.data.clone(); + if rows.is_empty() { + rsx! { + div { class: "card", style: "padding: 24px; text-align: center;", + p { style: "opacity: 0.7;", "No targets yet." } + Link { to: crate::app::Route::OnboardingPage {}, class: "btn btn-primary", + "Onboard your first target" + } + } + } + } else { + rsx! { + div { class: "card", + div { class: "table-wrapper", + table { + thead { + tr { + th { "Name" } + th { "Type" } + th { "Detected" } + th { "Artifacts" } + th { "Findings" } + th { "Actions" } + } + } + tbody { + for t in rows { + { + let id = target_id(&t); + let name = str_at(&t, "name").to_string(); + let ttype = pretty_type(str_at(&t, "target_type")); + let suggested = t + .get("classification") + .and_then(|c| c.get("suggested")) + .and_then(|s| s.as_str()) + .map(pretty_type); + let artifacts = t + .get("artifacts") + .and_then(|a| a.as_array()) + .cloned() + .unwrap_or_default(); + let findings = t + .get("findings_count") + .and_then(|n| n.as_u64()) + .unwrap_or(0); + let facts = t + .get("classification") + .and_then(|c| c.get("facts")) + .and_then(|f| f.as_array()) + .cloned() + .unwrap_or_default(); + let is_scanning = scanning_ids().contains(&id); + let is_expanded = expanded_ids().contains(&id); + let id_scan = id.clone(); + let id_exp = id.clone(); + let id_del = id.clone(); + let name_del = name.clone(); + let artifacts_detail = artifacts.clone(); + rsx! { + tr { + td { strong { "{name}" } } + td { "{ttype}" } + td { + if let Some(sug) = suggested.clone() { + span { class: "badge badge-success", "{sug}" } + } else { + span { style: "opacity: 0.5;", "—" } + } + } + td { "{artifacts.len()}" } + td { "{findings}" } + td { style: "display: flex; gap: 4px;", + button { + class: "btn btn-ghost", + title: "Details", + onclick: move |_| { + let mut ids = expanded_ids(); + if ids.contains(&id_exp) { + ids.retain(|i| i != &id_exp); + } else { + ids.push(id_exp.clone()); + } + expanded_ids.set(ids); + }, + Icon { icon: BsInfoCircle, width: 16, height: 16 } + } + button { + class: if is_scanning { "btn btn-ghost btn-scanning" } else { "btn btn-ghost" }, + title: "Run scan", + disabled: is_scanning, + onclick: move |_| { + let id = id_scan.clone(); + let mut ids = scanning_ids(); + ids.push(id.clone()); + scanning_ids.set(ids); + spawn(async move { + match trigger_target_scan(id.clone()).await { + Ok(_) => toasts.push(ToastType::Success, "Scan triggered — findings appear as it runs. Use Refresh."), + Err(e) => toasts.push(ToastType::Error, e.to_string()), + } + let mut ids = scanning_ids(); + ids.retain(|i| i != &id); + scanning_ids.set(ids); + }); + }, + if is_scanning { + span { class: "spinner" } + } else { + Icon { icon: BsPlayCircle, width: 16, height: 16 } + } + } + button { + class: "btn btn-ghost btn-ghost-danger", + title: "Delete target", + onclick: move |_| { + confirm_delete.set(Some((id_del.clone(), name_del.clone()))); + }, + Icon { icon: BsTrash, width: 16, height: 16 } + } + } + } + if is_expanded { + tr { + td { colspan: "6", + div { style: "padding: 12px 8px;", + h4 { style: "margin: 0 0 6px;", "Artifacts" } + if artifacts_detail.is_empty() { + div { style: "opacity: 0.6;", "No artifacts." } + } + for a in artifacts_detail { + div { style: "font-size: 0.9em; padding: 2px 0;", + span { style: "opacity: 0.7;", "{str_at(&a, \"kind\")}: " } + span { style: "font-family: monospace;", "{str_at(&a, \"source_ref\")}" } + } + } + if !facts.is_empty() { + h4 { style: "margin: 12px 0 6px;", "Detected facts" } + for f in facts { + div { style: "font-size: 0.9em; padding: 2px 0;", + span { style: "font-family: monospace;", "{str_at(&f, \"key\")}={str_at(&f, \"value\")}" } + span { style: "opacity: 0.5;", " ({str_at(&f, \"source\")})" } + } + } + } + h4 { style: "margin: 12px 0 6px;", "Applicable scans" } + TargetScans { id: id.clone() } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + Some(None) => rsx! { + div { class: "card", p { "Failed to load targets." } } + }, + None => rsx! { + div { class: "loading", "Loading targets..." } + }, + } + } + } +} -- 2.54.0