refactor: modularize codebase and add 404 unit tests (#13)
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
All checks were successful
CI / Format (push) Successful in 4s
CI / Clippy (push) Successful in 4m19s
CI / Security Audit (push) Successful in 1m44s
CI / Detect Changes (push) Successful in 5s
CI / Tests (push) Successful in 5m15s
CI / Deploy Agent (push) Successful in 2s
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 2s
This commit was merged in pull request #13.
This commit is contained in:
283
compliance-dashboard/src/components/attack_chain/helpers.rs
Normal file
283
compliance-dashboard/src/components/attack_chain/helpers.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
|
||||
/// Get category CSS class from tool name
|
||||
pub(crate) 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
|
||||
pub(crate) 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
|
||||
pub(crate) 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
|
||||
pub(crate) 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
|
||||
pub(crate) 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
|
||||
pub(crate) fn compute_phases(steps: &[serde_json::Value]) -> Vec<Vec<usize>> {
|
||||
let node_ids: Vec<String> = steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
s.get("node_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let id_to_idx: HashMap<String, usize> = 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<usize>> = Vec::new();
|
||||
for d in 0..=max_depth {
|
||||
let indices: Vec<usize> = 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
|
||||
pub(crate) 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::<i64>() {
|
||||
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
|
||||
pub(crate) fn compute_duration(step: &serde_json::Value) -> String {
|
||||
let extract_ms = |val: &serde_json::Value| -> Option<i64> {
|
||||
val.get("$date")?
|
||||
.get("$numberLong")?
|
||||
.as_str()?
|
||||
.parse::<i64>()
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
4
compliance-dashboard/src/components/attack_chain/mod.rs
Normal file
4
compliance-dashboard/src/components/attack_chain/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod helpers;
|
||||
mod view;
|
||||
|
||||
pub use view::AttackChainView;
|
||||
363
compliance-dashboard/src/components/attack_chain/view.rs
Normal file
363
compliance-dashboard/src/components/attack_chain/view.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
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<serde_json::Value>,
|
||||
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<PhaseData<'_>> = 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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod app_shell;
|
||||
pub mod attack_chain;
|
||||
pub mod code_inspector;
|
||||
pub mod code_snippet;
|
||||
pub mod file_tree;
|
||||
|
||||
Reference in New Issue
Block a user