Add graph explorer components, API handlers, and dependency updates

Adds code inspector, file tree components, graph visualization JS,
graph API handlers, sidebar navigation updates, and misc improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-03-04 21:52:49 +01:00
parent cea8f59e10
commit b18824db25
16 changed files with 838 additions and 35 deletions

View File

@@ -41,6 +41,8 @@ pub enum Route {
const FAVICON: Asset = asset!("/assets/favicon.svg");
const MAIN_CSS: Asset = asset!("/assets/main.css");
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
const VIS_NETWORK_JS: Asset = asset!("/assets/vis-network.min.js");
const GRAPH_VIZ_JS: Asset = asset!("/assets/graph-viz.js");
#[component]
pub fn App() -> Element {
@@ -48,6 +50,8 @@ pub fn App() -> Element {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Script { src: VIS_NETWORK_JS }
document::Script { src: GRAPH_VIZ_JS }
Router::<Route> {}
}
}

View File

@@ -0,0 +1,93 @@
use dioxus::prelude::*;
use crate::infrastructure::graph::fetch_file_content;
#[component]
pub fn CodeInspector(
repo_id: String,
file_path: String,
node_name: String,
node_kind: String,
start_line: u32,
end_line: u32,
on_close: EventHandler<()>,
) -> Element {
let file_path_clone = file_path.clone();
let repo_id_clone = repo_id.clone();
let file_content = use_resource(move || {
let rid = repo_id_clone.clone();
let fp = file_path_clone.clone();
async move {
if fp.is_empty() {
return None;
}
fetch_file_content(rid, fp).await.ok()
}
});
rsx! {
div { class: "code-inspector-panel",
// Header
div { class: "code-inspector-header",
div { class: "code-inspector-title",
span { class: "badge badge-info", "SELECTED" }
span { class: "code-inspector-file-name", "{file_path}" }
}
div { class: "code-inspector-meta",
span { class: "node-label-badge node-kind-{node_kind}", "{node_kind}" }
span { class: "code-inspector-node-name", "{node_name}" }
if start_line > 0 && end_line > 0 {
span { class: "code-inspector-lines", "L{start_line}L{end_line}" }
}
}
button {
class: "btn-sm code-inspector-close",
onclick: move |_| on_close.call(()),
"\u{2715}"
}
}
// Content
div { class: "code-inspector-body",
match &*file_content.read() {
Some(Some(resp)) => {
let lines: Vec<&str> = resp.data.content.split('\n').collect();
rsx! {
div { class: "code-inspector-code",
for (i, line) in lines.iter().enumerate() {
{
let line_num = (i + 1) as u32;
let is_highlighted = start_line > 0
&& line_num >= start_line
&& line_num <= end_line;
let class_name = if is_highlighted {
"code-line code-line-highlight"
} else {
"code-line"
};
rsx! {
div {
class: "{class_name}",
id: if is_highlighted && line_num == start_line { "highlighted-line" } else { "" },
span { class: "code-line-number", "{line_num}" }
span { class: "code-line-content", "{line}" }
}
}
}
}
}
}
},
Some(None) => rsx! {
div { class: "code-inspector-empty",
"Could not load file content."
}
},
None => rsx! {
div { class: "loading", "Loading file..." }
},
}
}
}
}
}

View File

@@ -0,0 +1,176 @@
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct FileTreeNode {
pub name: String,
pub path: String,
pub is_dir: bool,
pub node_count: usize,
pub children: Vec<FileTreeNode>,
}
/// Build a file tree from a list of graph nodes (serde_json::Value with file_path field).
pub fn build_file_tree(nodes: &[serde_json::Value]) -> Vec<FileTreeNode> {
// Count nodes per file
let mut file_counts: BTreeMap<String, usize> = BTreeMap::new();
for node in nodes {
if let Some(fp) = node.get("file_path").and_then(|v| v.as_str()) {
*file_counts.entry(fp.to_string()).or_default() += 1;
}
}
// Build tree structure
let mut root_children: BTreeMap<String, FileTreeNode> = BTreeMap::new();
for (file_path, count) in &file_counts {
let parts: Vec<&str> = file_path.split('/').collect();
insert_path(&mut root_children, &parts, file_path, *count);
}
let mut result: Vec<FileTreeNode> = root_children.into_values().collect();
sort_tree(&mut result);
result
}
fn insert_path(
children: &mut BTreeMap<String, FileTreeNode>,
parts: &[&str],
full_path: &str,
node_count: usize,
) {
if parts.is_empty() {
return;
}
let name = parts[0].to_string();
let is_leaf = parts.len() == 1;
let entry = children.entry(name.clone()).or_insert_with(|| FileTreeNode {
name: name.clone(),
path: if is_leaf {
full_path.to_string()
} else {
String::new()
},
is_dir: !is_leaf,
node_count: 0,
children: Vec::new(),
});
if is_leaf {
entry.node_count = node_count;
entry.is_dir = false;
entry.path = full_path.to_string();
} else {
entry.is_dir = true;
entry.node_count += node_count;
let mut child_map: BTreeMap<String, FileTreeNode> = entry
.children
.drain(..)
.map(|c| (c.name.clone(), c))
.collect();
insert_path(&mut child_map, &parts[1..], full_path, node_count);
entry.children = child_map.into_values().collect();
}
}
fn sort_tree(nodes: &mut [FileTreeNode]) {
nodes.sort_by(|a, b| {
// Directories first, then alphabetical
b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name))
});
for node in nodes.iter_mut() {
sort_tree(&mut node.children);
}
}
#[component]
pub fn FileTree(
tree: Vec<FileTreeNode>,
filter: String,
on_file_click: EventHandler<String>,
) -> Element {
rsx! {
div { class: "file-tree",
for node in tree.iter() {
FileTreeItem {
node: node.clone(),
depth: 0,
filter: filter.clone(),
on_file_click: move |path: String| on_file_click.call(path),
}
}
}
}
}
#[component]
fn FileTreeItem(
node: FileTreeNode,
depth: usize,
filter: String,
on_file_click: EventHandler<String>,
) -> Element {
let mut expanded = use_signal(|| depth == 0);
// Filter: hide nodes that don't match
let matches_filter = filter.is_empty()
|| node.name.to_lowercase().contains(&filter.to_lowercase())
|| node
.children
.iter()
.any(|c| c.name.to_lowercase().contains(&filter.to_lowercase()));
if !matches_filter {
return rsx! {};
}
let padding_left = format!("{}px", 8 + depth * 16);
rsx! {
div {
div {
class: if node.is_dir { "file-tree-item file-tree-dir" } else { "file-tree-item file-tree-file" },
style: "padding-left: {padding_left};",
onclick: {
let path = node.path.clone();
let is_dir = node.is_dir;
move |_| {
if is_dir {
expanded.set(!expanded());
} else {
on_file_click.call(path.clone());
}
}
},
span { class: "file-tree-icon",
if node.is_dir {
if expanded() { "\u{25BE}" } else { "\u{25B8}" }
} else {
"\u{25CB}"
}
}
span { class: "file-tree-name", "{node.name}" }
if node.node_count > 0 {
span { class: "file-tree-badge", "{node.node_count}" }
}
}
if node.is_dir && expanded() {
for child in node.children.iter() {
FileTreeItem {
node: child.clone(),
depth: depth + 1,
filter: filter.clone(),
on_file_click: move |path: String| on_file_click.call(path),
}
}
}
}
}
}

View File

@@ -1,5 +1,7 @@
pub mod app_shell;
pub mod code_inspector;
pub mod code_snippet;
pub mod file_tree;
pub mod page_header;
pub mod pagination;
pub mod severity_badge;

View File

@@ -13,6 +13,7 @@ struct NavItem {
#[component]
pub fn Sidebar() -> Element {
let current_route = use_route::<Route>();
let mut collapsed = use_signal(|| true);
let nav_items = [
NavItem {
@@ -57,11 +58,15 @@ pub fn Sidebar() -> Element {
},
];
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
rsx! {
nav { class: "sidebar",
nav { class: "{sidebar_class}",
div { class: "sidebar-header",
Icon { icon: BsShieldCheck, width: 24, height: 24 }
h1 { "Compliance Scanner" }
if !collapsed() {
h1 { "Compliance Scanner" }
}
}
div { class: "sidebar-nav",
for item in nav_items {
@@ -82,15 +87,25 @@ pub fn Sidebar() -> Element {
to: item.route.clone(),
class: class,
{item.icon}
span { "{item.label}" }
if !collapsed() {
span { "{item.label}" }
}
}
}
}
}
}
div {
style: "padding: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);",
"v0.1.0"
button {
class: "sidebar-toggle",
onclick: move |_| collapsed.set(!collapsed()),
if collapsed() {
Icon { icon: BsChevronRight, width: 14, height: 14 }
} else {
Icon { icon: BsChevronLeft, width: 14, height: 14 }
}
}
if !collapsed() {
div { class: "sidebar-footer", "v0.1.0" }
}
}
}

View File

@@ -30,6 +30,24 @@ pub struct NodesResponse {
pub total: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileContentResponse {
pub data: FileContentData,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileContentData {
pub content: String,
pub path: String,
pub language: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchResponse {
pub data: Vec<serde_json::Value>,
pub total: Option<u64>,
}
#[server]
pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> {
let state: super::server_state::ServerState =
@@ -81,6 +99,48 @@ pub async fn fetch_communities(repo_id: String) -> Result<CommunitiesResponse, S
Ok(body)
}
#[server]
pub async fn fetch_file_content(
repo_id: String,
file_path: String,
) -> Result<FileContentResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/graph/{repo_id}/file-content?path={file_path}",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: FileContentResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn search_nodes(
repo_id: String,
query: String,
) -> Result<SearchResponse, ServerFnError> {
let state: super::server_state::ServerState =
dioxus_fullstack::FullstackContext::extract().await?;
let url = format!(
"{}/api/v1/graph/{repo_id}/search?q={query}&limit=50",
state.agent_api_url
);
let resp = reqwest::get(&url)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let body: SearchResponse = resp
.json()
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(body)
}
#[server]
pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> {
let state: super::server_state::ServerState =

View File

@@ -28,7 +28,7 @@ pub fn FindingDetailPage(id: String) -> Element {
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
}
div { style: "display: flex; gap: 8px; margin-bottom: 16px;",
div { class: "flex gap-2 mb-4",
SeverityBadge { severity: f.severity.to_string() }
if let Some(cwe) = &f.cwe {
span { class: "badge badge-info", "{cwe}" }
@@ -43,7 +43,7 @@ pub fn FindingDetailPage(id: String) -> Element {
div { class: "card",
div { class: "card-header", "Description" }
p { style: "line-height: 1.6;", "{f.description}" }
p { "{f.description}" }
}
if let Some(code) = &f.code_snippet {
@@ -60,7 +60,7 @@ pub fn FindingDetailPage(id: String) -> Element {
if let Some(remediation) = &f.remediation {
div { class: "card",
div { class: "card-header", "Remediation" }
p { style: "line-height: 1.6;", "{remediation}" }
p { "{remediation}" }
}
}
@@ -85,7 +85,7 @@ pub fn FindingDetailPage(id: String) -> Element {
div { class: "card",
div { class: "card-header", "Update Status" }
div { style: "display: flex; gap: 8px;",
div { class: "flex gap-2",
for status in ["open", "triaged", "resolved", "false_positive", "ignored"] {
{
let status_str = status.to_string();

View File

@@ -59,10 +59,9 @@ pub fn OverviewPage() -> Element {
div { class: "card",
div { class: "card-header", "Severity Distribution" }
div {
style: "display: flex; gap: 8px; align-items: flex-end; height: 200px; padding: 16px;",
div { class: "severity-chart",
SeverityBar { label: "Critical", count: s.critical_findings, max: s.total_findings, color: "var(--danger)" }
SeverityBar { label: "High", count: s.high_findings, max: s.total_findings, color: "#f97316" }
SeverityBar { label: "High", count: s.high_findings, max: s.total_findings, color: "var(--orange)" }
SeverityBar { label: "Medium", count: s.medium_findings, max: s.total_findings, color: "var(--warning)" }
SeverityBar { label: "Low", count: s.low_findings, max: s.total_findings, color: "var(--success)" }
}
@@ -89,22 +88,15 @@ fn SeverityBar(label: String, count: u64, max: u64, color: String) -> Element {
} else {
0.0
};
let height = format!("{}%", height_pct.max(2.0));
rsx! {
div {
style: "flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;",
div { class: "severity-bar",
div { class: "severity-bar-count", "{count}" }
div {
style: "font-size: 14px; font-weight: 600;",
"{count}"
}
div {
style: "width: 100%; background: {color}; border-radius: 4px 4px 0 0; height: {height}; min-height: 4px; transition: height 0.3s;",
}
div {
style: "font-size: 11px; color: var(--text-secondary);",
"{label}"
class: "severity-bar-fill",
style: "height: {height_pct.max(2.0)}%; background: {color};",
}
div { class: "severity-bar-label", "{label}" }
}
}
}