use dioxus::prelude::*; use super::helpers::*; /// (phase_index, steps, findings_count, has_failed, has_running, all_done) type PhaseData<'a> = (usize, Vec<&'a serde_json::Value>, usize, bool, bool, bool); #[component] pub 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> = 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}" } } } } } } } } } } } } } } } } } } }