Files
compliance-scanner-agent/compliance-dashboard/src/pages/graph_explorer.rs
T
sharang 0065c7c4b2
CI / Format (push) Successful in 3s
CI / Clippy (push) Successful in 3m59s
CI / Security Audit (push) Successful in 1m44s
CI / Tests (push) Successful in 5m2s
CI / Detect Changes (push) Successful in 3s
CI / Deploy Agent (push) Has been skipped
CI / Deploy Dashboard (push) Successful in 2s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Has been skipped
feat: UI improvements with icons, back navigation, and overview cards (#7)
2026-03-09 17:09:40 +00:00

460 lines
20 KiB
Rust

use dioxus::prelude::*;
use dioxus_free_icons::icons::bs_icons::*;
use dioxus_free_icons::Icon;
use crate::components::code_inspector::CodeInspector;
use crate::components::file_tree::{build_file_tree, FileTree};
use crate::components::page_header::PageHeader;
use crate::components::toast::{ToastType, Toasts};
use crate::infrastructure::graph::{fetch_graph, search_nodes, trigger_graph_build};
#[component]
pub fn GraphExplorerPage(repo_id: String) -> Element {
rsx! {
div { class: "back-nav",
button {
class: "btn btn-ghost btn-back",
onclick: move |_| { navigator().go_back(); },
Icon { icon: BsArrowLeft, width: 16, height: 16 }
"Back"
}
}
PageHeader {
title: "Code Knowledge Graph",
description: "Interactive visualization of code structure and relationships",
}
GraphExplorerBody { repo_id: repo_id }
}
}
/// Inline variant without back button and page header — for embedding in other pages.
#[component]
pub fn GraphExplorerInline(repo_id: String) -> Element {
rsx! {
GraphExplorerBody { repo_id: repo_id }
}
}
/// Shared graph explorer body used by both the full page and inline variants.
#[component]
fn GraphExplorerBody(repo_id: String) -> Element {
let repo_id_clone = repo_id.clone();
let mut graph_data = use_resource(move || {
let rid = repo_id_clone.clone();
async move {
if rid.is_empty() {
return None;
}
fetch_graph(rid).await.ok()
}
});
let mut building = use_signal(|| false);
let mut toasts = use_context::<Toasts>();
let mut selected_node = use_signal(|| Option::<serde_json::Value>::None);
let mut inspector_open = use_signal(|| false);
let mut search_query = use_signal(String::new);
let mut search_results = use_signal(Vec::<serde_json::Value>::new);
let mut file_filter = use_signal(String::new);
let mut nodes_json = use_signal(String::new);
let mut edges_json = use_signal(String::new);
let mut graph_ready = use_signal(|| false);
let graph_data_read = graph_data.read();
if let Some(Some(data)) = &*graph_data_read {
if !data.data.nodes.is_empty() && !graph_ready() {
let nj = serde_json::to_string(&data.data.nodes).unwrap_or_else(|_| "[]".into());
let ej = serde_json::to_string(&data.data.edges).unwrap_or_else(|_| "[]".into());
nodes_json.set(nj);
edges_json.set(ej);
graph_ready.set(true);
}
}
let (node_count, edge_count, community_count, languages, file_tree_data) =
if let Some(Some(data)) = &*graph_data_read {
let build = data.data.build.clone().unwrap_or_default();
let nc = build
.get("node_count")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let ec = build
.get("edge_count")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let cc = build
.get("community_count")
.and_then(|n| n.as_u64())
.unwrap_or(0);
let langs: Vec<String> = build
.get("languages_parsed")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let tree = build_file_tree(&data.data.nodes);
(nc, ec, cc, langs, tree)
} else {
(0, 0, 0, Vec::new(), Vec::new())
};
let has_graph_data = matches!(&*graph_data_read, Some(Some(d)) if !d.data.nodes.is_empty());
drop(graph_data_read);
use_effect(move || {
let ready = graph_ready();
if !ready {
return;
}
let nj = nodes_json();
let ej = edges_json();
if nj.is_empty() {
return;
}
spawn(async move {
let js = format!(
r#"
window.__onNodeClick = function(nodeJson) {{
var el = document.getElementById('graph-node-click-data');
if (el) {{
el.value = nodeJson;
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
}}
}};
setTimeout(function() {{
if (window.__loadGraph) {{
window.__loadGraph({nj}, {ej});
}}
}}, 300);
"#
);
let _ = document::eval(&js);
});
});
let sel = selected_node();
let sel_file = sel
.as_ref()
.and_then(|n| n.get("file_path").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let sel_name = sel
.as_ref()
.and_then(|n| n.get("name").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let sel_kind = sel
.as_ref()
.and_then(|n| n.get("kind").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let sel_start = sel
.as_ref()
.and_then(|n| n.get("start_line").and_then(|v| v.as_u64()))
.unwrap_or(0) as u32;
let sel_end = sel
.as_ref()
.and_then(|n| n.get("end_line").and_then(|v| v.as_u64()))
.unwrap_or(0) as u32;
rsx! {
if repo_id.is_empty() {
div { class: "card",
p { "Select a repository to view its code graph." }
}
} else {
// Toolbar
div { class: "graph-toolbar",
button {
class: "btn btn-primary",
disabled: building(),
onclick: {
let rid = repo_id.clone();
move |_| {
let rid = rid.clone();
building.set(true);
graph_ready.set(false);
nodes_json.set(String::new());
edges_json.set(String::new());
spawn(async move {
match trigger_graph_build(rid).await {
Ok(_) => toasts.push(ToastType::Success, "Graph build triggered"),
Err(e) => toasts.push(ToastType::Error, e.to_string()),
}
building.set(false);
graph_data.restart();
});
}
},
if building() { "Building..." } else { "Build Graph" }
}
div { class: "graph-search-bar",
input {
r#type: "text",
placeholder: "Search nodes...",
value: "{search_query}",
oninput: {
let rid = repo_id.clone();
move |e: FormEvent| {
let val = e.value();
search_query.set(val.clone());
if val.len() >= 2 {
let rid = rid.clone();
spawn(async move {
if let Ok(resp) = search_nodes(rid, val).await {
search_results.set(resp.data);
}
});
} else {
search_results.set(Vec::new());
}
}
},
onkeypress: {
let rid = repo_id.clone();
move |e: KeyboardEvent| {
if e.key() == Key::Enter {
search_results.set(Vec::new());
let q = search_query();
let rid = rid.clone();
if !q.is_empty() {
spawn(async move {
match search_nodes(rid, q).await {
Ok(resp) => {
let names: Vec<String> = resp.data.iter()
.filter_map(|n| n.get("qualified_name").and_then(|v| v.as_str()).map(String::from))
.collect();
let names_json = serde_json::to_string(&names).unwrap_or_else(|_| "[]".into());
let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}");
let _ = document::eval(&js);
},
Err(e) => {
toasts.push(ToastType::Error, e.to_string());
}
}
});
} else {
spawn(async move {
let _ = document::eval("if (window.__clearGraphSelection) { window.__clearGraphSelection(); }");
});
}
}
}
},
}
if !search_results().is_empty() {
div { class: "search-suggestions",
for node in search_results() {
{
let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let kind = node.get("kind").and_then(|v| v.as_str()).unwrap_or("").to_string();
let file_path = node.get("file_path").and_then(|v| v.as_str()).unwrap_or("").to_string();
let node_clone = node.clone();
let qname = node.get("qualified_name").and_then(|v| v.as_str()).unwrap_or("").to_string();
rsx! {
div {
class: "search-suggestion-item",
onclick: move |_| {
let nc = node_clone.clone();
let qn = qname.clone();
selected_node.set(Some(nc));
inspector_open.set(true);
search_results.set(Vec::new());
spawn(async move {
let names_json = serde_json::to_string(&vec![&qn]).unwrap_or_else(|_| "[]".into());
let js = format!("if (window.__highlightNodes) {{ window.__highlightNodes({names_json}); }}");
let _ = document::eval(&js);
});
},
span { class: "search-suggestion-name", "{name}" }
span { class: "search-suggestion-kind", "{kind}" }
span { class: "search-suggestion-path", "{file_path}" }
}
}
}
}
}
}
}
button {
class: "btn-sm",
onclick: move |_| {
spawn(async move {
let _ = document::eval("if (window.__fitGraph) { window.__fitGraph(); }");
});
},
"Fit View"
}
}
// Hidden input for JS → Rust node click communication
input {
id: "graph-node-click-data",
r#type: "hidden",
value: "",
oninput: move |e| {
let val = e.value();
if !val.is_empty() {
if let Ok(node) = serde_json::from_str::<serde_json::Value>(&val) {
selected_node.set(Some(node));
inspector_open.set(true);
}
}
},
}
// 3-panel layout: file-tree | [code-inspector] | graph-canvas
div { class: if inspector_open() { "graph-explorer-layout inspector-open" } else { "graph-explorer-layout" },
// Left: File Tree
div { class: "file-tree-panel",
div { class: "file-tree-header",
h4 { "Files" }
}
div { class: "file-tree-search",
input {
r#type: "text",
placeholder: "Filter files...",
value: "{file_filter}",
oninput: move |e| file_filter.set(e.value()),
}
}
div { class: "file-tree-content",
if file_tree_data.is_empty() {
div { class: "file-tree-empty", "No files. Build the graph first." }
} else {
FileTree {
tree: file_tree_data,
filter: file_filter(),
on_file_click: move |path: String| {
// Open code inspector for this file
let file_node = serde_json::json!({
"file_path": &path,
"name": path.rsplit('/').next().unwrap_or(&path),
"kind": "file",
"start_line": 1,
"end_line": 0,
});
selected_node.set(Some(file_node));
inspector_open.set(true);
// Also highlight in graph
spawn(async move {
let js = format!(
"if (window.__highlightFileNodes) {{ window.__highlightFileNodes('{path}'); }}"
);
let _ = document::eval(&js);
});
},
}
}
}
}
// Left-center: Code Inspector (between file tree and graph)
if inspector_open() && selected_node().is_some() {
CodeInspector {
repo_id: repo_id.clone(),
file_path: sel_file,
node_name: sel_name,
node_kind: sel_kind,
start_line: sel_start,
end_line: sel_end,
on_close: move |_| {
inspector_open.set(false);
selected_node.set(None);
},
}
}
// Right: Graph Canvas
div { class: "graph-canvas-panel",
if has_graph_data {
div {
id: "graph-canvas",
class: "graph-canvas",
}
// Stabilization overlay
div {
id: "graph-stabilization-overlay",
class: "graph-stab-overlay",
div { class: "graph-stab-content",
// Orbital rings
div { class: "graph-stab-rings",
div { class: "graph-stab-ring graph-stab-ring-1" }
div { class: "graph-stab-ring graph-stab-ring-2" }
div { class: "graph-stab-ring graph-stab-ring-3" }
div { class: "graph-stab-core" }
}
div { class: "graph-stab-text",
div { class: "graph-stab-title", "Rendering Graph" }
div { class: "graph-stab-subtitle",
id: "graph-stab-node-info",
"{node_count} nodes \u{00B7} {edge_count} edges"
}
}
// Progress bar
div { class: "graph-stab-progress",
div {
class: "graph-stab-progress-fill",
id: "graph-stab-progress-fill",
}
}
div {
class: "graph-stab-pct",
id: "graph-stab-pct",
"Initializing\u{2026}"
}
}
}
} else if node_count > 0 {
// Data exists but nodes array was empty (shouldn't happen)
div { class: "loading", "Loading graph visualization..." }
} else if (*graph_data.read()).is_none() {
div { class: "loading", "Loading graph data..." }
} else {
div { class: "graph-empty-state",
div { class: "graph-empty-icon", "\u{29BB}" }
h3 { "No Graph Data" }
p { "Click \"Build Graph\" to analyze the repository's code structure." }
}
}
// Stats bar
if node_count > 0 {
div { class: "graph-stats-bar",
span { class: "graph-stat",
span { class: "graph-stat-value", "{node_count}" }
span { class: "graph-stat-label", " nodes" }
}
span { class: "graph-stat-divider", "|" }
span { class: "graph-stat",
span { class: "graph-stat-value", "{edge_count}" }
span { class: "graph-stat-label", " edges" }
}
span { class: "graph-stat-divider", "|" }
span { class: "graph-stat",
span { class: "graph-stat-value", "{community_count}" }
span { class: "graph-stat-label", " communities" }
}
if !languages.is_empty() {
span { class: "graph-stat-divider", "|" }
span { class: "graph-stat",
span { class: "graph-stat-label", "{languages.join(\", \")}" }
}
}
}
}
}
}
}
}
}