Compare commits

...
Author SHA1 Message Date
Sharang ParnerkarandClaude Opus 4.8 e3b918b365 fix(dashboard): Findings/SBOM filter by onboarded targets; accurate target findings_count
CI / Check (pull_request) Successful in 7m4s
CI / Detect Changes (pull_request) Has been skipped
CI / Deploy Agent (pull_request) Has been skipped
CI / Deploy Dashboard (pull_request) Has been skipped
CI / Deploy Docs (pull_request) Has been skipped
CI / Deploy MCP (pull_request) Has been skipped
The Findings and SBOM pages populated their target dropdown from the legacy
`repositories` collection, so onboarded targets never appeared and their
findings/SBOM couldn't be filtered by name (the data was there, keyed by the
target id). Point both dropdowns at `onboarded_targets` via `fetch_targets`.

Also refresh `OnboardedTarget.findings_count` at the end of `run_target`: the
shared pipeline (Stage 7) increments the legacy `repositories` doc, which the
unified path has none of, so the Targets page always showed 0. Set the accurate
total (count of findings keyed by the target id) on the target itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 09:52:09 +02:00
sharang bf32b9939a fix(onboarding): targets visibility + unified pipeline by default (#154)
CI / Detect Changes (push) Successful in 3s
CI / Check (push) Has been skipped
CI / Deploy Agent (push) Successful in 3m41s
CI / Deploy Dashboard (push) Successful in 2m46s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m46s
2026-07-13 07:29:04 +00:00
14 changed files with 432 additions and 256 deletions
+20
View File
@@ -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,
@@ -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,
+4 -1
View File
@@ -47,9 +47,12 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
.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"),
@@ -461,6 +461,25 @@ impl PipelineOrchestrator {
} },
)
.await?;
// Refresh the target's cached findings count. The shared pipeline
// (Stage 7) increments `repositories`, which the unified path does
// not use, so set the accurate total on the target itself.
let total = self
.db
.findings()
.count_documents(doc! { "repo_id": target_id })
.await
.unwrap_or(*count as u64);
self.db
.onboarded_targets()
.update_one(
doc! { "_id": oid },
doc! { "$set": {
"findings_count": total as i64,
"updated_at": mongodb::bson::DateTime::now(),
} },
)
.await?;
}
Err(e) => {
tracing::error!(target_id, error = %e, "Unified scan pipeline failed");
+7 -7
View File
@@ -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
);
}
}
+2 -1
View File
@@ -51,7 +51,8 @@ pub struct AgentConfig {
pub tenant_registry_url: Option<String>,
/// 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,
}
+2
View File
@@ -12,6 +12,8 @@ pub enum Route {
OverviewPage {},
#[route("/repositories")]
RepositoriesPage {},
#[route("/targets")]
TargetsPage {},
#[route("/onboard")]
OnboardingPage {},
#[route("/findings")]
@@ -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 {
@@ -106,6 +106,22 @@ pub async fn fetch_applicable_scans(id: String) -> Result<ApplicableScansRespons
.map_err(|e| ServerFnError::new(e.to_string()))
}
/// Delete a target (and cascade its findings / SBOM / scan runs / CVE alerts).
#[server]
pub async fn delete_target(id: String) -> Result<serde_json::Value, ServerFnError> {
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<serde_json::Value, ServerFnError> {
+5 -5
View File
@@ -20,7 +20,7 @@ pub fn FindingsPage() -> Element {
let mut selected_ids = use_signal(Vec::<String>::new);
let repos = use_resource(|| async {
crate::infrastructure::repositories::fetch_repositories(1)
crate::infrastructure::onboarding::fetch_targets()
.await
.ok()
});
@@ -86,14 +86,14 @@ pub fn FindingsPage() -> Element {
}
select {
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
option { value: "", "All Repositories" }
option { value: "", "All Targets" }
{
match &*repos.read() {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
for t in &resp.data {
{
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
let id = t.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = t.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
rsx! {
option { value: "{id}", "{name}" }
}
+2
View File
@@ -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;
+1 -230
View File
@@ -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::<Toasts>();
let mut confirm_delete = use_signal(|| Option::<(String, String)>::None); // (id, name)
let mut edit_repo_id = use_signal(|| Option::<String>::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 ──
+9 -9
View File
@@ -28,9 +28,9 @@ pub fn SbomPage() -> Element {
let mut diff_repo_a = use_signal(String::new);
let mut diff_repo_b = use_signal(String::new);
// ── Repos for dropdowns ──
// ── Targets for dropdowns ──
let repos = use_resource(|| async {
crate::infrastructure::repositories::fetch_repositories(1)
crate::infrastructure::onboarding::fetch_targets()
.await
.ok()
});
@@ -114,14 +114,14 @@ pub fn SbomPage() -> Element {
select {
class: "sbom-filter-select",
onchange: move |e| { repo_filter.set(e.value()); page.set(1); },
option { value: "", "All Repositories" }
option { value: "", "All Targets" }
{
match &*repos.read() {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
rsx! { option { value: "{id}", "{name}" } }
}
}
@@ -476,8 +476,8 @@ pub fn SbomPage() -> Element {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
rsx! { option { value: "{id}", "{name}" } }
}
}
@@ -498,8 +498,8 @@ pub fn SbomPage() -> Element {
Some(Some(resp)) => rsx! {
for repo in &resp.data {
{
let id = repo.id.as_ref().map(|id| id.to_hex()).unwrap_or_default();
let name = repo.name.clone();
let id = repo.get("_id").and_then(|o| o.get("$oid")).and_then(|s| s.as_str()).unwrap_or_default().to_string();
let name = repo.get("name").and_then(|n| n.as_str()).unwrap_or_default().to_string();
rsx! { option { value: "{id}", "{name}" } }
}
}
+339
View File
@@ -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::<Toasts>();
let mut scanning_ids = use_signal(Vec::<String>::new);
let mut expanded_ids = use_signal(Vec::<String>::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..." }
},
}
}
}
}