use std::collections::{HashMap, VecDeque}; use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; use crate::components::severity_badge::SeverityBadge; use crate::infrastructure::pentest::{ export_pentest_report, fetch_attack_chain, fetch_pentest_findings, fetch_pentest_session, }; #[component] pub fn PentestSessionPage(session_id: String) -> Element { let sid_for_session = session_id.clone(); let sid_for_findings = session_id.clone(); let sid_for_chain = session_id.clone(); let mut session = use_resource(move || { let id = sid_for_session.clone(); async move { fetch_pentest_session(id).await.ok() } }); let mut findings = use_resource(move || { let id = sid_for_findings.clone(); async move { fetch_pentest_findings(id).await.ok() } }); let mut attack_chain = use_resource(move || { let id = sid_for_chain.clone(); async move { fetch_attack_chain(id).await.ok() } }); let mut active_tab = use_signal(|| "findings".to_string()); let mut show_export_modal = use_signal(|| false); let mut export_password = use_signal(String::new); let mut exporting = use_signal(|| false); let mut export_sha256 = use_signal(|| Option::::None); let mut export_error = use_signal(|| Option::::None); let mut poll_gen = use_signal(|| 0u32); // Extract session data let session_data = session.read().clone(); let sess = session_data.as_ref().and_then(|s| s.as_ref()); let session_status = sess .and_then(|s| s.data.get("status")) .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(); let target_name = sess .and_then(|s| s.data.get("target_name")) .and_then(|v| v.as_str()) .unwrap_or("Pentest Session") .to_string(); let strategy = sess .and_then(|s| s.data.get("strategy")) .and_then(|v| v.as_str()) .unwrap_or("-") .to_string(); let tool_invocations = sess .and_then(|s| s.data.get("tool_invocations")) .and_then(|v| v.as_u64()) .unwrap_or(0); let tool_successes = sess .and_then(|s| s.data.get("tool_successes")) .and_then(|v| v.as_u64()) .unwrap_or(0); let findings_count = { let f = findings.read(); match &*f { Some(Some(data)) => data.total.unwrap_or(0), _ => 0, } }; let started_at = sess .and_then(|s| s.data.get("started_at")) .and_then(|v| v.as_str()) .unwrap_or("-") .to_string(); let completed_at = sess .and_then(|s| s.data.get("completed_at")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let success_rate = if tool_invocations == 0 { 100.0 } else { (tool_successes as f64 / tool_invocations as f64) * 100.0 }; let is_running = session_status == "running"; // Poll while running use_effect(move || { let _gen = *poll_gen.read(); if is_running { spawn(async move { #[cfg(feature = "web")] gloo_timers::future::TimeoutFuture::new(3_000).await; #[cfg(not(feature = "web"))] tokio::time::sleep(std::time::Duration::from_secs(3)).await; findings.restart(); attack_chain.restart(); session.restart(); let next = poll_gen.peek().wrapping_add(1); poll_gen.set(next); }); } }); // Severity counts from findings data let (sev_critical, sev_high, sev_medium, sev_low, sev_info, exploitable_count) = { let f = findings.read(); match &*f { Some(Some(data)) => { let list = &data.data; let c = list .iter() .filter(|f| { f.get("severity").and_then(|v| v.as_str()) == Some("critical") }) .count(); let h = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("high")) .count(); let m = list .iter() .filter(|f| { f.get("severity").and_then(|v| v.as_str()) == Some("medium") }) .count(); let l = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("low")) .count(); let i = list .iter() .filter(|f| f.get("severity").and_then(|v| v.as_str()) == Some("info")) .count(); let e = list .iter() .filter(|f| { f.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false) }) .count(); (c, h, m, l, i, e) } _ => (0, 0, 0, 0, 0, 0), } }; let status_style = match session_status.as_str() { "running" => "background: #16a34a; color: #fff;", "completed" => "background: #2563eb; color: #fff;", "failed" => "background: #dc2626; color: #fff;", "paused" => "background: #d97706; color: #fff;", _ => "background: var(--bg-tertiary); color: var(--text-secondary);", }; // Export handler let sid_for_export = session_id.clone(); let do_export = move |_| { let pw = export_password.read().clone(); if pw.len() < 8 { export_error.set(Some("Password must be at least 8 characters".to_string())); return; } export_error.set(None); export_sha256.set(None); exporting.set(true); let sid = sid_for_export.clone(); spawn(async move { // TODO: get real user info from auth context match export_pentest_report( sid.clone(), pw, String::new(), String::new(), ) .await { Ok(resp) => { export_sha256.set(Some(resp.sha256.clone())); // Trigger download via JS let js = format!( r#" try {{ var raw = atob("{}"); var bytes = new Uint8Array(raw.length); for (var i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); var blob = new Blob([bytes], {{ type: "application/octet-stream" }}); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = "{}"; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }} catch(e) {{ console.error("Download failed:", e); }} "#, resp.archive_base64, resp.filename, ); document::eval(&js); } Err(e) => { export_error.set(Some(format!("{e}"))); } } exporting.set(false); }); }; rsx! { div { class: "back-nav", Link { to: Route::PentestDashboardPage {}, class: "btn btn-ghost btn-back", Icon { icon: BsArrowLeft, width: 16, height: 16 } "Back to Pentest Dashboard" } } // Session header div { style: "display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; flex-wrap: wrap; gap: 8px;", div { h2 { style: "margin: 0 0 4px 0;", "{target_name}" } div { style: "display: flex; gap: 8px; align-items: center; flex-wrap: wrap;", span { class: "badge", style: "{status_style}", "{session_status}" } span { class: "badge", style: "background: var(--bg-tertiary); color: var(--text-secondary);", "{strategy}" } if is_running { span { style: "font-size: 0.8rem; color: var(--text-secondary);", Icon { icon: BsPlayCircle, width: 12, height: 12 } " Running..." } } } } div { style: "display: flex; gap: 8px;", button { class: "btn btn-primary", style: "font-size: 0.85rem;", onclick: move |_| { export_password.set(String::new()); export_sha256.set(None); export_error.set(None); show_export_modal.set(true); }, Icon { icon: BsDownload, width: 14, height: 14 } " Export Report" } } } // Summary cards div { class: "stat-cards", style: "margin-bottom: 20px;", div { class: "stat-card-item", div { class: "stat-card-value", "{findings_count}" } div { class: "stat-card-label", Icon { icon: BsShieldExclamation, width: 14, height: 14 } " Findings" } } div { class: "stat-card-item", div { class: "stat-card-value", style: "color: #dc2626;", "{exploitable_count}" } div { class: "stat-card-label", Icon { icon: BsExclamationTriangle, width: 14, height: 14 } " Exploitable" } } div { class: "stat-card-item", div { class: "stat-card-value", "{tool_invocations}" } div { class: "stat-card-label", Icon { icon: BsWrench, width: 14, height: 14 } " Tool Invocations" } } div { class: "stat-card-item", div { class: "stat-card-value", "{success_rate:.0}%" } div { class: "stat-card-label", Icon { icon: BsCheckCircle, width: 14, height: 14 } " Success Rate" } } } // Severity distribution bar div { class: "card", style: "margin-bottom: 20px; padding: 14px;", div { style: "display: flex; align-items: center; gap: 14px; flex-wrap: wrap;", span { style: "font-weight: 600; color: var(--text-secondary); font-size: 0.85rem;", "Severity Distribution" } span { class: "badge", style: "background: #dc2626; color: #fff;", "Critical: {sev_critical}" } span { class: "badge", style: "background: #ea580c; color: #fff;", "High: {sev_high}" } span { class: "badge", style: "background: #d97706; color: #fff;", "Medium: {sev_medium}" } span { class: "badge", style: "background: #2563eb; color: #fff;", "Low: {sev_low}" } span { class: "badge", style: "background: #6b7280; color: #fff;", "Info: {sev_info}" } } } // Session details row div { class: "card", style: "margin-bottom: 20px; padding: 14px;", div { style: "display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; font-size: 0.85rem;", div { span { style: "color: var(--text-secondary);", "Started: " } span { "{started_at}" } } if !completed_at.is_empty() { div { span { style: "color: var(--text-secondary);", "Completed: " } span { "{completed_at}" } } } div { span { style: "color: var(--text-secondary);", "Tools: " } span { "{tool_successes}/{tool_invocations} successful" } } } } // Tabs: Findings / Attack Chain div { class: "card", style: "overflow: hidden;", div { style: "display: flex; border-bottom: 1px solid var(--border-color);", button { style: if *active_tab.read() == "findings" { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;" } else { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;" }, onclick: move |_| active_tab.set("findings".to_string()), Icon { icon: BsShieldExclamation, width: 14, height: 14 } " Findings ({findings_count})" } button { style: if *active_tab.read() == "chain" { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600; font-size: 0.9rem;" } else { "flex: 1; padding: 12px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer; font-size: 0.9rem;" }, onclick: move |_| { active_tab.set("chain".to_string()); }, Icon { icon: BsDiagram3, width: 14, height: 14 } " Attack Chain" } } // Tab content div { style: "padding: 16px;", if *active_tab.read() == "findings" { // Findings list match &*findings.read() { Some(Some(data)) => { let finding_list = &data.data; if finding_list.is_empty() { rsx! { div { style: "text-align: center; color: var(--text-secondary); padding: 24px;", if is_running { p { "Scan in progress — findings will appear here." } } else { p { "No findings discovered." } } } } } else { rsx! { div { style: "display: flex; flex-direction: column; gap: 10px;", for finding in finding_list { { let title = finding.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string(); let severity = finding.get("severity").and_then(|v| v.as_str()).unwrap_or("info").to_string(); let vuln_type = finding.get("vuln_type").and_then(|v| v.as_str()).unwrap_or("-").to_string(); let endpoint = finding.get("endpoint").and_then(|v| v.as_str()).unwrap_or("").to_string(); let method = finding.get("method").and_then(|v| v.as_str()).unwrap_or("").to_string(); let exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false); let description = finding.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(); let remediation = finding.get("remediation").and_then(|v| v.as_str()).unwrap_or("").to_string(); let cwe = finding.get("cwe").and_then(|v| v.as_str()).unwrap_or("").to_string(); let linked_sast = finding.get("linked_sast_finding_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); rsx! { div { style: "background: var(--bg-tertiary); border-radius: 8px; padding: 14px;", // Header div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;", div { style: "display: flex; align-items: center; gap: 8px;", SeverityBadge { severity: severity } span { style: "font-weight: 600; font-size: 0.95rem;", "{title}" } } div { style: "display: flex; gap: 4px;", if exploitable { span { class: "badge", style: "background: #dc2626; color: #fff; font-size: 0.7rem;", "Exploitable" } } span { class: "badge", style: "font-size: 0.7rem;", "{vuln_type}" } } } // Endpoint if !endpoint.is_empty() { div { style: "font-family: monospace; font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 6px;", "{method} {endpoint}" } } // CWE if !cwe.is_empty() { div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 4px;", "CWE: {cwe}" } } // Description if !description.is_empty() { div { style: "font-size: 0.85rem; margin-bottom: 8px; line-height: 1.5;", "{description}" } } // Remediation if !remediation.is_empty() { div { style: "font-size: 0.8rem; padding: 8px 10px; background: rgba(56, 189, 248, 0.08); border-left: 3px solid #38bdf8; border-radius: 0 4px 4px 0; margin-top: 6px;", span { style: "font-weight: 600;", "Recommendation: " } "{remediation}" } } // Linked SAST if !linked_sast.is_empty() { div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-top: 4px;", "Correlated SAST finding: " code { "{linked_sast}" } } } } } } } } } } }, Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load findings." } }, None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } }, } } else { // Attack chain visualization match &*attack_chain.read() { Some(Some(data)) => { let steps = &data.data; if steps.is_empty() { rsx! { div { style: "text-align: center; color: var(--text-secondary); padding: 24px;", if is_running { p { "Scan in progress — attack chain will appear here." } } else { p { "No attack chain steps recorded." } } } } } else { rsx! { AttackChainView { steps: steps.clone(), is_running: is_running, session_findings: findings_count as usize, session_tool_invocations: tool_invocations as usize, session_success_rate: success_rate, } } } }, Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } }, None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } }, } } } } // Export modal if *show_export_modal.read() { div { style: "position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;", onclick: move |_| show_export_modal.set(false), div { style: "background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; padding: 24px; width: 480px; max-width: 90vw;", onclick: move |e| e.stop_propagation(), h3 { style: "margin: 0 0 4px 0;", "Export Pentest Report" } p { style: "font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 16px;", "The report will be exported as a password-protected ZIP archive (AES-256) containing a professional HTML report and raw findings data. Open with any standard archive tool." } div { style: "margin-bottom: 14px;", label { style: "display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 4px;", "Encryption Password" } input { class: "chat-input", style: "width: 100%; padding: 8px;", r#type: "password", placeholder: "Minimum 8 characters", value: "{export_password}", oninput: move |e| { export_password.set(e.value()); export_error.set(None); }, } } if let Some(err) = &*export_error.read() { div { style: "padding: 8px 12px; background: rgba(220, 38, 38, 0.1); border: 1px solid #dc2626; border-radius: 6px; color: #dc2626; font-size: 0.85rem; margin-bottom: 14px;", "{err}" } } if let Some(sha) = &*export_sha256.read() { { let sha_copy = sha.clone(); rsx! { div { style: "padding: 10px 12px; background: rgba(22, 163, 74, 0.08); border: 1px solid #16a34a; border-radius: 6px; margin-bottom: 14px;", div { style: "font-size: 0.8rem; font-weight: 600; color: #16a34a; margin-bottom: 4px;", Icon { icon: BsCheckCircle, width: 12, height: 12 } " Archive downloaded successfully" } div { style: "font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 2px;", "SHA-256 Checksum:" } div { style: "display: flex; align-items: center; gap: 6px;", div { style: "flex: 1; font-family: monospace; font-size: 0.7rem; word-break: break-all; color: var(--text-primary); background: var(--bg-primary); padding: 6px 8px; border-radius: 4px;", "{sha_copy}" } button { class: "btn btn-ghost", style: "padding: 4px 8px; font-size: 0.75rem; flex-shrink: 0;", onclick: move |_| { let js = format!( "navigator.clipboard.writeText('{}');", sha_copy ); document::eval(&js); }, Icon { icon: BsClipboard, width: 12, height: 12 } } } } } } } div { style: "display: flex; justify-content: flex-end; gap: 8px;", button { class: "btn btn-ghost", onclick: move |_| show_export_modal.set(false), "Close" } button { class: "btn btn-primary", disabled: *exporting.read() || export_password.read().len() < 8, onclick: do_export, if *exporting.read() { "Encrypting..." } else { "Export" } } } } } } } } // ═══════════════════════════════════════ // Attack Chain Visualization Component // ═══════════════════════════════════════ /// Get category CSS class from tool name fn tool_category(name: &str) -> &'static str { let lower = name.to_lowercase(); if lower.contains("recon") { return "recon"; } if lower.contains("openapi") || lower.contains("api") || lower.contains("swagger") { return "api"; } if lower.contains("header") { return "headers"; } if lower.contains("csp") { return "csp"; } if lower.contains("cookie") { return "cookies"; } if lower.contains("log") || lower.contains("console") { return "logs"; } if lower.contains("rate") || lower.contains("limit") { return "ratelimit"; } if lower.contains("cors") { return "cors"; } if lower.contains("tls") || lower.contains("ssl") { return "tls"; } if lower.contains("redirect") { return "redirect"; } if lower.contains("dns") || lower.contains("dmarc") || lower.contains("email") || lower.contains("spf") { return "email"; } if lower.contains("auth") || lower.contains("jwt") || lower.contains("token") || lower.contains("session") { return "auth"; } if lower.contains("xss") { return "xss"; } if lower.contains("sql") || lower.contains("sqli") { return "sqli"; } if lower.contains("ssrf") { return "ssrf"; } if lower.contains("idor") { return "idor"; } if lower.contains("fuzz") { return "fuzzer"; } if lower.contains("cve") || lower.contains("exploit") { return "cve"; } "default" } /// Get emoji icon from tool category fn tool_emoji(cat: &str) -> &'static str { match cat { "recon" => "\u{1F50D}", "api" => "\u{1F517}", "headers" => "\u{1F6E1}", "csp" => "\u{1F6A7}", "cookies" => "\u{1F36A}", "logs" => "\u{1F4DD}", "ratelimit" => "\u{23F1}", "cors" => "\u{1F30D}", "tls" => "\u{1F510}", "redirect" => "\u{21AA}", "email" => "\u{1F4E7}", "auth" => "\u{1F512}", "xss" => "\u{26A1}", "sqli" => "\u{1F489}", "ssrf" => "\u{1F310}", "idor" => "\u{1F511}", "fuzzer" => "\u{1F9EA}", "cve" => "\u{1F4A3}", _ => "\u{1F527}", } } /// Compute display label for category fn cat_label(cat: &str) -> &'static str { match cat { "recon" => "Recon", "api" => "API", "headers" => "Headers", "csp" => "CSP", "cookies" => "Cookies", "logs" => "Logs", "ratelimit" => "Rate Limit", "cors" => "CORS", "tls" => "TLS", "redirect" => "Redirect", "email" => "Email/DNS", "auth" => "Auth", "xss" => "XSS", "sqli" => "SQLi", "ssrf" => "SSRF", "idor" => "IDOR", "fuzzer" => "Fuzzer", "cve" => "CVE", _ => "Other", } } /// Phase name heuristic based on depth fn phase_name(depth: usize) -> &'static str { match depth { 0 => "Reconnaissance", 1 => "Analysis", 2 => "Boundary Testing", 3 => "Injection & Exploitation", 4 => "Authentication Testing", 5 => "Validation", 6 => "Deep Scan", _ => "Final", } } /// Short label for phase rail fn phase_short_name(depth: usize) -> &'static str { match depth { 0 => "Recon", 1 => "Analysis", 2 => "Boundary", 3 => "Exploit", 4 => "Auth", 5 => "Validate", 6 => "Deep", _ => "Final", } } /// Compute BFS phases from attack chain nodes fn compute_phases(steps: &[serde_json::Value]) -> Vec> { let node_ids: Vec = steps .iter() .map(|s| s.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string()) .collect(); let id_to_idx: HashMap = node_ids .iter() .enumerate() .map(|(i, id)| (id.clone(), i)) .collect(); // Compute depth via BFS let mut depths = vec![usize::MAX; steps.len()]; let mut queue = VecDeque::new(); // Root nodes: those with no parents or parents not in the set for (i, step) in steps.iter().enumerate() { let parents = step .get("parent_node_ids") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|p| p.as_str()) .filter(|p| id_to_idx.contains_key(*p)) .count() }) .unwrap_or(0); if parents == 0 { depths[i] = 0; queue.push_back(i); } } // BFS to compute min depth while let Some(idx) = queue.pop_front() { let current_depth = depths[idx]; let node_id = &node_ids[idx]; // Find children: nodes that list this node as a parent for (j, step) in steps.iter().enumerate() { if depths[j] <= current_depth + 1 { continue; } let is_child = step .get("parent_node_ids") .and_then(|v| v.as_array()) .map(|arr| arr.iter().any(|p| p.as_str() == Some(node_id.as_str()))) .unwrap_or(false); if is_child { depths[j] = current_depth + 1; queue.push_back(j); } } } // Handle unreachable nodes for d in depths.iter_mut() { if *d == usize::MAX { *d = 0; } } // Group by depth let max_depth = depths.iter().copied().max().unwrap_or(0); let mut phases: Vec> = Vec::new(); for d in 0..=max_depth { let indices: Vec = depths .iter() .enumerate() .filter(|(_, &dep)| dep == d) .map(|(i, _)| i) .collect(); if !indices.is_empty() { phases.push(indices); } } phases } /// Format BSON datetime to readable string fn format_bson_time(val: &serde_json::Value) -> String { // Handle BSON {"$date":{"$numberLong":"..."}} if let Some(date_obj) = val.get("$date") { if let Some(ms_str) = date_obj.get("$numberLong").and_then(|v| v.as_str()) { if let Ok(ms) = ms_str.parse::() { let secs = ms / 1000; let h = (secs / 3600) % 24; let m = (secs / 60) % 60; let s = secs % 60; return format!("{h:02}:{m:02}:{s:02}"); } } // Handle {"$date": "2025-..."} if let Some(s) = date_obj.as_str() { return s.to_string(); } } // Handle plain string if let Some(s) = val.as_str() { return s.to_string(); } String::new() } /// Compute duration string from started_at and completed_at fn compute_duration(step: &serde_json::Value) -> String { let extract_ms = |val: &serde_json::Value| -> Option { val.get("$date")? .get("$numberLong")? .as_str()? .parse::() .ok() }; let started = step.get("started_at").and_then(extract_ms); let completed = step.get("completed_at").and_then(extract_ms); match (started, completed) { (Some(s), Some(c)) => { let diff_ms = c - s; if diff_ms < 1000 { format!("{}ms", diff_ms) } else { format!("{:.1}s", diff_ms as f64 / 1000.0) } } _ => String::new(), } } #[component] fn AttackChainView( steps: Vec, is_running: bool, session_findings: usize, session_tool_invocations: usize, session_success_rate: f64, ) -> Element { let phases = compute_phases(&steps); // Compute KPIs — prefer session-level stats, fall back to node-level let total_tools = steps.len(); let node_findings: usize = steps .iter() .map(|s| { s.get("findings_produced") .and_then(|v| v.as_array()) .map(|a| a.len()) .unwrap_or(0) }) .sum(); // Use session-level findings count if nodes don't have findings linked let total_findings = if node_findings > 0 { node_findings } else { session_findings }; let completed_count = steps .iter() .filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("completed")) .count(); let failed_count = steps .iter() .filter(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed")) .count(); let finished = completed_count + failed_count; let success_pct = if finished == 0 { 100 } else { (completed_count * 100) / finished }; let max_risk: u8 = steps .iter() .filter_map(|s| s.get("risk_score").and_then(|v| v.as_u64())) .map(|v| v as u8) .max() .unwrap_or(0); let progress_pct = if total_tools == 0 { 0 } else { ((completed_count + failed_count) * 100) / total_tools }; // Build phase data for rail and accordion let phase_data: Vec<(usize, Vec<&serde_json::Value>, usize, bool, bool, bool)> = phases .iter() .enumerate() .map(|(pi, indices)| { let phase_steps: Vec<&serde_json::Value> = indices.iter().map(|&i| &steps[i]).collect(); let phase_findings: usize = phase_steps .iter() .map(|s| { s.get("findings_produced") .and_then(|v| v.as_array()) .map(|a| a.len()) .unwrap_or(0) }) .sum(); let has_failed = phase_steps .iter() .any(|s| s.get("status").and_then(|v| v.as_str()) == Some("failed")); let has_running = phase_steps .iter() .any(|s| s.get("status").and_then(|v| v.as_str()) == Some("running")); let all_done = phase_steps.iter().all(|s| { let st = s.get("status").and_then(|v| v.as_str()).unwrap_or(""); st == "completed" || st == "failed" || st == "skipped" }); (pi, phase_steps, phase_findings, has_failed, has_running, all_done) }) .collect(); let mut active_rail = use_signal(|| 0usize); rsx! { // KPI bar div { class: "ac-kpi-bar", div { class: "ac-kpi-card", div { class: "ac-kpi-value", style: "color: var(--text-primary);", "{total_tools}" } div { class: "ac-kpi-label", "Tools Run" } } div { class: "ac-kpi-card", div { class: "ac-kpi-value", style: "color: var(--danger, #dc2626);", "{total_findings}" } div { class: "ac-kpi-label", "Findings" } } div { class: "ac-kpi-card", div { class: "ac-kpi-value", style: "color: var(--success, #16a34a);", "{success_pct}%" } div { class: "ac-kpi-label", "Success Rate" } } div { class: "ac-kpi-card", div { class: "ac-kpi-value", style: "color: var(--warning, #d97706);", "{max_risk}" } div { class: "ac-kpi-label", "Max Risk" } } } // Phase rail div { class: "ac-phase-rail", for (pi, (_phase_idx, phase_steps, phase_findings, has_failed, has_running, all_done)) in phase_data.iter().enumerate() { { if pi > 0 { let prev_done = phase_data.get(pi - 1).map(|p| p.5).unwrap_or(false); let bar_class = if prev_done && *all_done { "done" } else if prev_done { "running" } else { "" }; rsx! { div { class: "ac-rail-bar", div { class: "ac-rail-bar-inner {bar_class}" } } } } else { rsx! {} } } { let dot_class = if *has_running { "running" } else if *has_failed && *all_done { "mixed" } else if *all_done { "done" } else { "pending" }; let is_active = *active_rail.read() == pi; let active_cls = if is_active { " active" } else { "" }; let findings_cls = if *phase_findings > 0 { "has" } else { "none" }; let findings_text = if *phase_findings > 0 { format!("{phase_findings}") } else { "\u{2014}".to_string() }; let short = phase_short_name(pi); rsx! { div { class: "ac-rail-node{active_cls}", onclick: move |_| { active_rail.set(pi); let js = format!( "document.getElementById('ac-phase-{pi}')?.scrollIntoView({{behavior:'smooth',block:'nearest'}});document.getElementById('ac-phase-{pi}')?.classList.add('open');" ); document::eval(&js); }, div { class: "ac-rail-dot {dot_class}" } div { class: "ac-rail-label", "{short}" } div { class: "ac-rail-findings {findings_cls}", "{findings_text}" } div { class: "ac-rail-heatmap", for step in phase_steps.iter() { { let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending"); let hm_cls = match st { "completed" => "ok", "failed" => "fail", "running" => "run", _ => "wait", }; rsx! { div { class: "ac-hm-cell {hm_cls}" } } } } } } } } } } // Progress bar div { class: "ac-progress-track", div { class: "ac-progress-fill", style: "width: {progress_pct}%;" } } // Expand all div { class: "ac-controls", button { class: "ac-btn-toggle", onclick: move |_| { document::eval( "document.querySelectorAll('.ac-phase').forEach(p => p.classList.toggle('open', !document.querySelector('.ac-phase.open') || !document.querySelectorAll('.ac-phase:not(.open)').length === 0));(function(){var ps=document.querySelectorAll('.ac-phase');var allOpen=Array.from(ps).every(p=>p.classList.contains('open'));ps.forEach(p=>{if(allOpen)p.classList.remove('open');else p.classList.add('open');});})();" ); }, "Expand all" } } // Phase accordion div { class: "ac-phases", for (pi, (_, phase_steps, phase_findings, has_failed, has_running, all_done)) in phase_data.iter().enumerate() { { let open_cls = if pi == 0 { " open" } else { "" }; let phase_label = phase_name(pi); let tool_count = phase_steps.len(); let meta_text = if *has_running { "in progress".to_string() } else { format!("{phase_findings} findings") }; let meta_cls = if *has_running { "running-ct" } else { "findings-ct" }; let phase_num_label = format!("PHASE {}", pi + 1); let phase_el_id = format!("ac-phase-{pi}"); let phase_el_id2 = phase_el_id.clone(); rsx! { div { class: "ac-phase{open_cls}", id: "{phase_el_id}", div { class: "ac-phase-header", onclick: move |_| { let js = format!("document.getElementById('{phase_el_id2}').classList.toggle('open');"); document::eval(&js); }, span { class: "ac-phase-num", "{phase_num_label}" } span { class: "ac-phase-title", "{phase_label}" } div { class: "ac-phase-dots", for step in phase_steps.iter() { { let st = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending"); rsx! { div { class: "ac-phase-dot {st}" } } } } } div { class: "ac-phase-meta", span { "{tool_count} tools" } span { class: "{meta_cls}", "{meta_text}" } } span { class: "ac-phase-chevron", "\u{25B8}" } } div { class: "ac-phase-body", div { class: "ac-phase-body-inner", for step in phase_steps.iter() { { let tool_name_val = step.get("tool_name").and_then(|v| v.as_str()).unwrap_or("Unknown").to_string(); let status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string(); let cat = tool_category(&tool_name_val); let emoji = tool_emoji(cat); let label = cat_label(cat); let findings_n = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); let risk = step.get("risk_score").and_then(|v| v.as_u64()).map(|v| v as u8); let reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string(); let duration = compute_duration(step); let started = step.get("started_at").map(format_bson_time).unwrap_or_default(); let is_pending = status == "pending"; let pending_cls = if is_pending { " is-pending" } else { "" }; let duration_cls = if status == "running" { "ac-tool-duration running-text" } else { "ac-tool-duration" }; let duration_text = if status == "running" { "running\u{2026}".to_string() } else if duration.is_empty() { "\u{2014}".to_string() } else { duration }; let pill_cls = if findings_n > 0 { "ac-findings-pill has" } else { "ac-findings-pill zero" }; let pill_text = if findings_n > 0 { format!("{findings_n}") } else { "\u{2014}".to_string() }; let (risk_cls, risk_text) = match risk { Some(r) if r >= 75 => ("ac-risk-val high", format!("{r}")), Some(r) if r >= 40 => ("ac-risk-val medium", format!("{r}")), Some(r) => ("ac-risk-val low", format!("{r}")), None => ("ac-risk-val none", "\u{2014}".to_string()), }; let node_id = step.get("node_id").and_then(|v| v.as_str()).unwrap_or("").to_string(); let detail_id = format!("ac-detail-{node_id}"); let row_id = format!("ac-row-{node_id}"); let detail_id_clone = detail_id.clone(); rsx! { div { class: "ac-tool-row{pending_cls}", id: "{row_id}", onclick: move |_| { if is_pending { return; } let js = format!( "(function(){{var r=document.getElementById('{row_id}');var d=document.getElementById('{detail_id}');if(r.classList.contains('expanded')){{r.classList.remove('expanded');d.classList.remove('open');}}else{{r.classList.add('expanded');d.classList.add('open');}}}})()" ); document::eval(&js); }, div { class: "ac-status-bar {status}" } div { class: "ac-tool-icon", "{emoji}" } div { class: "ac-tool-info", div { class: "ac-tool-name", "{tool_name_val}" } span { class: "ac-cat-chip {cat}", "{label}" } } div { class: "{duration_cls}", "{duration_text}" } div { span { class: "{pill_cls}", "{pill_text}" } } div { class: "{risk_cls}", "{risk_text}" } } div { class: "ac-tool-detail", id: "{detail_id_clone}", if !reasoning.is_empty() || !started.is_empty() { div { class: "ac-tool-detail-inner", if !reasoning.is_empty() { div { class: "ac-reasoning-block", "{reasoning}" } } if !started.is_empty() { div { class: "ac-detail-grid", span { class: "ac-detail-label", "Started" } span { class: "ac-detail-value", "{started}" } if !duration_text.is_empty() && status != "running" && duration_text != "\u{2014}" { span { class: "ac-detail-label", "Duration" } span { class: "ac-detail-value", "{duration_text}" } } span { class: "ac-detail-label", "Status" } if status == "completed" { span { class: "ac-detail-value", style: "color: var(--success, #16a34a);", "Completed" } } else if status == "failed" { span { class: "ac-detail-value", style: "color: var(--danger, #dc2626);", "Failed" } } else if status == "running" { span { class: "ac-detail-value", style: "color: var(--warning, #d97706);", "Running" } } else { span { class: "ac-detail-value", "{status}" } } } } } } } } } } } } } } } } } } }