- Add interactive attack chain DAG using vis-network with hierarchical layout, status-colored nodes, risk-based sizing, and click handlers - Add pentest session export API (GET /sessions/:id/export) supporting both JSON and Markdown report formats - Redesign attack chain tab with graph/list toggle views - Add export buttons (MD/JSON) to session header with Blob download - Show exploitable badge and endpoint on finding cards - Add export_pentest_report server function for dashboard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
626 lines
35 KiB
Rust
626 lines
35 KiB
Rust
use dioxus::prelude::*;
|
|
use dioxus_free_icons::icons::bs_icons::*;
|
|
use dioxus_free_icons::Icon;
|
|
|
|
use crate::app::Route;
|
|
use crate::infrastructure::pentest::{
|
|
export_pentest_report, 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());
|
|
let mut chain_view = use_signal(|| "graph".to_string()); // "graph" or "list"
|
|
let mut exporting = use_signal(|| false);
|
|
|
|
// 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();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Load attack chain into vis-network when data changes and graph tab is active
|
|
let chain_data_for_viz = attack_chain.read().clone();
|
|
let current_tab = right_tab.read().clone();
|
|
let current_chain_view = chain_view.read().clone();
|
|
use_effect(move || {
|
|
if current_tab == "chain" && current_chain_view == "graph" {
|
|
if let Some(Some(data)) = &chain_data_for_viz {
|
|
if !data.data.is_empty() {
|
|
let nodes_json =
|
|
serde_json::to_string(&data.data).unwrap_or_else(|_| "[]".to_string());
|
|
let js = format!(
|
|
r#"if (window.__loadAttackChain) {{ window.__loadAttackChain({nodes_json}); }}"#
|
|
);
|
|
document::eval(&js);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
|
|
// Export handler
|
|
let sid_for_export = session_id.clone();
|
|
let do_export_md = move |_| {
|
|
let sid = sid_for_export.clone();
|
|
exporting.set(true);
|
|
spawn(async move {
|
|
match export_pentest_report(sid.clone(), "markdown".to_string()).await {
|
|
Ok(content) => {
|
|
// Trigger download via JS
|
|
let escaped = content
|
|
.replace('\\', "\\\\")
|
|
.replace('`', "\\`")
|
|
.replace("${", "\\${");
|
|
let js = format!(
|
|
r#"
|
|
var blob = new Blob([`{escaped}`], {{ type: 'text/markdown' }});
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'pentest-report-{sid}.md';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
"#
|
|
);
|
|
document::eval(&js);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Export failed: {e}");
|
|
}
|
|
}
|
|
exporting.set(false);
|
|
});
|
|
};
|
|
|
|
let sid_for_export_json = session_id.clone();
|
|
let do_export_json = move |_| {
|
|
let sid = sid_for_export_json.clone();
|
|
exporting.set(true);
|
|
spawn(async move {
|
|
match export_pentest_report(sid.clone(), "json".to_string()).await {
|
|
Ok(content) => {
|
|
let escaped = content
|
|
.replace('\\', "\\\\")
|
|
.replace('`', "\\`")
|
|
.replace("${", "\\${");
|
|
let js = format!(
|
|
r#"
|
|
var blob = new Blob([`{escaped}`], {{ type: 'application/json' }});
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'pentest-report-{sid}.json';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
"#
|
|
);
|
|
document::eval(&js);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Export failed: {e}");
|
|
}
|
|
}
|
|
exporting.set(false);
|
|
});
|
|
};
|
|
|
|
// 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: 8px; align-items: center;",
|
|
// Export buttons
|
|
div { style: "display: flex; gap: 4px;",
|
|
button {
|
|
class: "btn btn-ghost",
|
|
style: "font-size: 0.8rem; padding: 4px 10px;",
|
|
disabled: *exporting.read(),
|
|
onclick: do_export_md,
|
|
Icon { icon: BsFileEarmarkText, width: 12, height: 12 }
|
|
" Export MD"
|
|
}
|
|
button {
|
|
class: "btn btn-ghost",
|
|
style: "font-size: 0.8rem; padding: 4px 10px;",
|
|
disabled: *exporting.read(),
|
|
onclick: do_export_json,
|
|
Icon { icon: BsFiletypeJson, width: 12, height: 12 }
|
|
" Export JSON"
|
|
}
|
|
}
|
|
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 420px; 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" {
|
|
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" {
|
|
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 {
|
|
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<KeyboardData>| {
|
|
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; display: flex; flex-direction: column;",
|
|
if *right_tab.read() == "findings" {
|
|
// Findings tab
|
|
div { style: "padding: 12px; flex: 1; overflow-y: auto;",
|
|
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("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 exploitable = finding.get("exploitable").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
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}" }
|
|
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: "{sev_style}", "{severity}" }
|
|
}
|
|
}
|
|
div { style: "font-size: 0.8rem; color: var(--text-secondary);", "{vuln_type}" }
|
|
if !endpoint.is_empty() {
|
|
div { style: "font-size: 0.75rem; color: var(--text-secondary); font-family: monospace; margin-top: 2px;",
|
|
"{endpoint}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load findings." } },
|
|
None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } },
|
|
}
|
|
}
|
|
} else {
|
|
// Attack chain tab — graph/list toggle
|
|
div { style: "display: flex; gap: 4px; padding: 8px 12px; flex-shrink: 0;",
|
|
button {
|
|
class: if *chain_view.read() == "graph" { "btn btn-primary" } else { "btn btn-ghost" },
|
|
style: "font-size: 0.75rem; padding: 3px 8px;",
|
|
onclick: move |_| chain_view.set("graph".to_string()),
|
|
Icon { icon: BsDiagram3, width: 12, height: 12 }
|
|
" Graph"
|
|
}
|
|
button {
|
|
class: if *chain_view.read() == "list" { "btn btn-primary" } else { "btn btn-ghost" },
|
|
style: "font-size: 0.75rem; padding: 3px 8px;",
|
|
onclick: move |_| chain_view.set("list".to_string()),
|
|
Icon { icon: BsListOl, width: 12, height: 12 }
|
|
" List"
|
|
}
|
|
}
|
|
|
|
if *chain_view.read() == "graph" {
|
|
// Interactive DAG visualization
|
|
div { style: "flex: 1; position: relative; min-height: 300px;",
|
|
div {
|
|
id: "attack-chain-canvas",
|
|
style: "width: 100%; height: 100%; position: absolute; inset: 0;",
|
|
}
|
|
match &*attack_chain.read() {
|
|
Some(Some(data)) if data.data.is_empty() => rsx! {
|
|
div { style: "position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--text-secondary);",
|
|
p { "No attack chain steps yet." }
|
|
}
|
|
},
|
|
_ => rsx! {},
|
|
}
|
|
}
|
|
} else {
|
|
// List view
|
|
div { style: "flex: 1; overflow-y: auto; padding: 0 12px 12px;",
|
|
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 tool_name = step.get("tool_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 reasoning = step.get("llm_reasoning").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
|
let findings_count = step.get("findings_produced").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
|
|
let risk_score = step.get("risk_score").and_then(|v| v.as_u64());
|
|
let step_num = i + 1;
|
|
let dot_color = match step_status.as_str() {
|
|
"completed" => "#16a34a",
|
|
"running" => "#d97706",
|
|
"failed" => "#dc2626",
|
|
"skipped" => "#374151",
|
|
_ => "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 { style: "flex: 1; min-width: 0;",
|
|
div { style: "display: flex; justify-content: space-between; align-items: center;",
|
|
span { style: "font-size: 0.85rem; font-weight: 600;",
|
|
"{step_num}. {tool_name}"
|
|
}
|
|
div { style: "display: flex; gap: 4px;",
|
|
if findings_count > 0 {
|
|
span { class: "badge", style: "font-size: 0.65rem; background: #dc2626; color: #fff;",
|
|
"{findings_count} findings"
|
|
}
|
|
}
|
|
if let Some(score) = risk_score {
|
|
span { class: "badge", style: "font-size: 0.65rem;",
|
|
"risk: {score}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !reasoning.is_empty() {
|
|
div { style: "font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;",
|
|
"{reasoning}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
Some(None) => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Failed to load attack chain." } },
|
|
None => rsx! { p { style: "color: var(--text-secondary); padding: 12px;", "Loading..." } },
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|