use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; use crate::infrastructure::pentest::{ fetch_attack_chain, fetch_pentest_findings, fetch_pentest_messages, fetch_pentest_session, send_pentest_message, }; #[component] pub fn PentestSessionPage(session_id: String) -> Element { let sid = session_id.clone(); 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 messages_res = use_resource(move || { let id = sid.clone(); async move { fetch_pentest_messages(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 input_text = use_signal(String::new); let mut sending = use_signal(|| false); let mut right_tab = use_signal(|| "findings".to_string()); // Auto-poll messages every 3s when session is running let session_status = { let s = session.read(); match &*s { Some(Some(resp)) => resp .data .get("status") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(), _ => "unknown".to_string(), } }; let is_running = session_status == "running"; let sid_for_poll = session_id.clone(); use_effect(move || { if is_running { let _sid = sid_for_poll.clone(); 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; messages_res.restart(); findings.restart(); attack_chain.restart(); session.restart(); }); } }); // Send message handler let sid_for_send = session_id.clone(); let mut do_send = move || { let text = input_text.read().trim().to_string(); if text.is_empty() || *sending.read() { return; } let sid = sid_for_send.clone(); input_text.set(String::new()); sending.set(true); spawn(async move { let _ = send_pentest_message(sid, text).await; sending.set(false); messages_res.restart(); }); }; let mut do_send_click = do_send.clone(); // Session header info let target_name = { let s = session.read(); match &*s { Some(Some(resp)) => resp .data .get("target_name") .and_then(|v| v.as_str()) .unwrap_or("Pentest Session") .to_string(), _ => "Pentest Session".to_string(), } }; let strategy = { let s = session.read(); match &*s { Some(Some(resp)) => resp .data .get("strategy") .and_then(|v| v.as_str()) .unwrap_or("-") .to_string(), _ => "-".to_string(), } }; let header_tool_count = { let s = session.read(); match &*s { Some(Some(resp)) => resp .data .get("tool_invocations") .and_then(|v| v.as_u64()) .unwrap_or(0), _ => 0, } }; let header_findings_count = { let f = findings.read(); match &*f { Some(Some(data)) => data.total.unwrap_or(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);", }; 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: 16px; 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}" } } } div { style: "display: flex; gap: 16px; font-size: 0.85rem; color: var(--text-secondary);", span { Icon { icon: BsWrench, width: 14, height: 14 } " {header_tool_count} tools" } span { Icon { icon: BsShieldExclamation, width: 14, height: 14 } " {header_findings_count} findings" } } } // Split layout: chat left, findings/chain right div { style: "display: grid; grid-template-columns: 1fr 380px; gap: 16px; height: calc(100vh - 220px); min-height: 400px;", // Left: Chat area div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;", div { class: "card-header", style: "flex-shrink: 0;", "Chat" } // Messages div { style: "flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 12px;", match &*messages_res.read() { Some(Some(data)) => { let msgs = &data.data; if msgs.is_empty() { rsx! { div { style: "text-align: center; color: var(--text-secondary); padding: 32px;", h3 { style: "margin-bottom: 8px;", "Start the conversation" } p { "Send a message to guide the pentest agent." } } } } else { rsx! { for (i, msg) in msgs.iter().enumerate() { { let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("assistant").to_string(); let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(); let msg_type = msg.get("type").and_then(|v| v.as_str()).unwrap_or("text").to_string(); let tool_name = msg.get("tool_name").and_then(|v| v.as_str()).unwrap_or("").to_string(); let tool_status = msg.get("tool_status").and_then(|v| v.as_str()).unwrap_or("").to_string(); if msg_type == "tool_call" || msg_type == "tool_result" { // Tool invocation indicator let tool_icon_style = match tool_status.as_str() { "success" => "color: #16a34a;", "error" => "color: #dc2626;", "running" => "color: #d97706;", _ => "color: var(--text-secondary);", }; rsx! { div { key: "{i}", style: "display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-tertiary); border-radius: 6px; font-size: 0.8rem; color: var(--text-secondary);", span { style: "{tool_icon_style}", Icon { icon: BsWrench, width: 12, height: 12 } } span { style: "font-family: monospace;", "{tool_name}" } if !tool_status.is_empty() { span { class: "badge", style: "font-size: 0.7rem;", "{tool_status}" } } if !content.is_empty() { details { style: "margin-left: auto; cursor: pointer;", summary { style: "font-size: 0.75rem;", "details" } pre { style: "margin-top: 4px; padding: 8px; background: var(--bg-primary); border-radius: 4px; font-size: 0.75rem; overflow-x: auto; max-height: 200px; white-space: pre-wrap;", "{content}" } } } } } } else if role == "user" { // User message - right aligned rsx! { div { key: "{i}", style: "display: flex; justify-content: flex-end;", div { style: "max-width: 80%; padding: 10px 14px; background: #2563eb; color: #fff; border-radius: 12px 12px 2px 12px; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap;", "{content}" } } } } else { // Assistant message - left aligned rsx! { div { key: "{i}", style: "display: flex; gap: 8px; align-items: flex-start;", div { style: "flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center;", Icon { icon: BsCpu, width: 14, height: 14 } } div { style: "max-width: 80%; padding: 10px 14px; background: var(--bg-tertiary); border-radius: 12px 12px 12px 2px; font-size: 0.9rem; line-height: 1.5; white-space: pre-wrap;", "{content}" } } } } } } } } }, Some(None) => rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "Failed to load messages." } }, None => rsx! { p { style: "padding: 16px; color: var(--text-secondary);", "Loading messages..." } }, } if *sending.read() { div { style: "display: flex; gap: 8px; align-items: flex-start;", div { style: "flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-tertiary); display: flex; align-items: center; justify-content: center;", Icon { icon: BsCpu, width: 14, height: 14 } } div { style: "padding: 10px 14px; background: var(--bg-tertiary); border-radius: 12px 12px 12px 2px; font-size: 0.9rem; color: var(--text-secondary);", "Thinking..." } } } } // Input area div { style: "flex-shrink: 0; padding: 12px; border-top: 1px solid var(--border-color); display: flex; gap: 8px;", textarea { class: "chat-input", style: "flex: 1;", placeholder: "Guide the pentest agent...", value: "{input_text}", oninput: move |e| input_text.set(e.value()), onkeydown: move |e: Event| { if e.key() == Key::Enter && !e.modifiers().shift() { e.prevent_default(); do_send(); } }, } button { class: "btn btn-primary", style: "align-self: flex-end;", disabled: *sending.read(), onclick: move |_| do_send_click(), "Send" } } } // Right: Findings / Attack Chain tabs div { class: "card", style: "display: flex; flex-direction: column; overflow: hidden;", // Tab bar div { style: "display: flex; border-bottom: 1px solid var(--border-color); flex-shrink: 0;", button { style: if *right_tab.read() == "findings" { "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600;" } else { "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer;" }, onclick: move |_| right_tab.set("findings".to_string()), Icon { icon: BsShieldExclamation, width: 14, height: 14 } " Findings ({header_findings_count})" } button { style: if *right_tab.read() == "chain" { "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid #2563eb; color: var(--text-primary); cursor: pointer; font-weight: 600;" } else { "flex: 1; padding: 10px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-secondary); cursor: pointer;" }, onclick: move |_| right_tab.set("chain".to_string()), Icon { icon: BsDiagram3, width: 14, height: 14 } " Attack Chain" } } // Tab content div { style: "flex: 1; overflow-y: auto; padding: 12px;", if *right_tab.read() == "findings" { // Findings tab 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;", p { "No findings yet." } } } } else { rsx! { div { style: "display: flex; flex-direction: column; gap: 8px;", 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("vulnerability_type").and_then(|v| v.as_str()).unwrap_or("-").to_string(); let sev_style = match severity.as_str() { "critical" => "background: #dc2626; color: #fff;", "high" => "background: #ea580c; color: #fff;", "medium" => "background: #d97706; color: #fff;", "low" => "background: #2563eb; color: #fff;", _ => "background: var(--bg-tertiary); color: var(--text-secondary);", }; rsx! { div { style: "padding: 10px; background: var(--bg-tertiary); border-radius: 8px;", div { style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;", span { style: "font-weight: 600; font-size: 0.85rem;", "{title}" } span { class: "badge", style: "{sev_style}", "{severity}" } } div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" } } } } } } } } }, 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 tab 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;", p { "No attack chain steps yet." } } } } else { rsx! { div { style: "display: flex; flex-direction: column; gap: 4px;", for (i, step) in steps.iter().enumerate() { { let step_name = step.get("name").and_then(|v| v.as_str()).unwrap_or("Step").to_string(); let step_status = step.get("status").and_then(|v| v.as_str()).unwrap_or("pending").to_string(); let description = step.get("description").and_then(|v| v.as_str()).unwrap_or("").to_string(); let step_num = i + 1; let dot_color = match step_status.as_str() { "completed" => "#16a34a", "running" => "#d97706", "failed" => "#dc2626", _ => "var(--text-secondary)", }; rsx! { div { style: "display: flex; gap: 10px; padding: 8px 0;", div { style: "display: flex; flex-direction: column; align-items: center;", div { style: "width: 10px; height: 10px; border-radius: 50%; background: {dot_color}; flex-shrink: 0;" } if i < steps.len() - 1 { div { style: "width: 2px; flex: 1; background: var(--border-color); margin-top: 4px;" } } } div { div { style: "font-size: 0.85rem; font-weight: 600;", "{step_num}. {step_name}" } if !description.is_empty() { div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;", "{description}" } } } } } } } } } } }, Some(None) => rsx! { p { style: "color: var(--text-secondary);", "Failed to load attack chain." } }, None => rsx! { p { style: "color: var(--text-secondary);", "Loading..." } }, } } } } } } }