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:
@@ -47,9 +47,10 @@ pub async fn get_graph(
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
let (nodes, edges) = if let Some(ref b) = build {
|
let (nodes, edges) = if build.is_some() {
|
||||||
let build_id = b.id.map(|oid| oid.to_hex()).unwrap_or_default();
|
// Filter by repo_id only — delete_repo_graph clears old data before each rebuild,
|
||||||
let filter = doc! { "repo_id": &repo_id, "graph_build_id": &build_id };
|
// so there is only one set of nodes/edges per repo.
|
||||||
|
let filter = doc! { "repo_id": &repo_id };
|
||||||
|
|
||||||
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
let nodes: Vec<CodeNode> = match db.graph_nodes().find(filter.clone()).await {
|
||||||
Ok(cursor) => collect_cursor_async(cursor).await,
|
Ok(cursor) => collect_cursor_async(cursor).await,
|
||||||
@@ -198,6 +199,72 @@ pub async fn search_symbols(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/graph/:repo_id/file-content — Read source file from cloned repo
|
||||||
|
pub async fn get_file_content(
|
||||||
|
Extension(agent): AgentExt,
|
||||||
|
Path(repo_id): Path<String>,
|
||||||
|
Query(params): Query<FileContentParams>,
|
||||||
|
) -> Result<Json<ApiResponse<FileContent>>, StatusCode> {
|
||||||
|
let db = &agent.db;
|
||||||
|
|
||||||
|
// Look up the repository to get repo name
|
||||||
|
let repo = db
|
||||||
|
.repositories()
|
||||||
|
.find_one(doc! { "_id": mongodb::bson::oid::ObjectId::parse_str(&repo_id).ok() })
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
let base_path = std::path::Path::new(&agent.config.git_clone_base_path);
|
||||||
|
let file_path = base_path.join(&repo.name).join(¶ms.path);
|
||||||
|
|
||||||
|
// Security: ensure we don't escape the repo directory
|
||||||
|
let canonical = file_path
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
let base_canonical = base_path
|
||||||
|
.join(&repo.name)
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
if !canonical.starts_with(&base_canonical) {
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&canonical).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// Cap at 10,000 lines
|
||||||
|
let truncated: String = content.lines().take(10_000).collect::<Vec<_>>().join("\n");
|
||||||
|
|
||||||
|
let language = params
|
||||||
|
.path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
data: FileContent {
|
||||||
|
content: truncated,
|
||||||
|
path: params.path,
|
||||||
|
language,
|
||||||
|
},
|
||||||
|
total: None,
|
||||||
|
page: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FileContentParams {
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FileContent {
|
||||||
|
pub content: String,
|
||||||
|
pub path: String,
|
||||||
|
pub language: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/v1/graph/:repo_id/build — Trigger graph rebuild
|
/// POST /api/v1/graph/:repo_id/build — Trigger graph rebuild
|
||||||
pub async fn trigger_build(
|
pub async fn trigger_build(
|
||||||
Extension(agent): AgentExt,
|
Extension(agent): AgentExt,
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ pub fn build_router() -> Router {
|
|||||||
"/api/v1/graph/{repo_id}/search",
|
"/api/v1/graph/{repo_id}/search",
|
||||||
get(handlers::graph::search_symbols),
|
get(handlers::graph::search_symbols),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/graph/{repo_id}/file-content",
|
||||||
|
get(handlers::graph::get_file_content),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/graph/{repo_id}/build",
|
"/api/v1/graph/{repo_id}/build",
|
||||||
post(handlers::graph::trigger_build),
|
post(handlers::graph::trigger_build),
|
||||||
|
|||||||
@@ -69,7 +69,19 @@ pub async fn triage_findings(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if let Ok(result) = serde_json::from_str::<TriageResult>(&response) {
|
// Strip markdown code fences if present (e.g. ```json ... ```)
|
||||||
|
let cleaned = response.trim();
|
||||||
|
let cleaned = if cleaned.starts_with("```") {
|
||||||
|
let inner = cleaned
|
||||||
|
.trim_start_matches("```json")
|
||||||
|
.trim_start_matches("```")
|
||||||
|
.trim_end_matches("```")
|
||||||
|
.trim();
|
||||||
|
inner
|
||||||
|
} else {
|
||||||
|
cleaned
|
||||||
|
};
|
||||||
|
if let Ok(result) = serde_json::from_str::<TriageResult>(cleaned) {
|
||||||
finding.confidence = Some(result.confidence);
|
finding.confidence = Some(result.confidence);
|
||||||
if let Some(remediation) = result.remediation {
|
if let Some(remediation) = result.remediation {
|
||||||
finding.remediation = Some(remediation);
|
finding.remediation = Some(remediation);
|
||||||
|
|||||||
335
compliance-dashboard/assets/graph-viz.js
Normal file
335
compliance-dashboard/assets/graph-viz.js
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Graph Visualization — vis-network wrapper for Code Knowledge Graph
|
||||||
|
// Obsidian Control theme
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Color palette matching Obsidian Control
|
||||||
|
const NODE_COLORS = {
|
||||||
|
function: { bg: "#00c8ff", border: "#00a0cc", font: "#060a13" },
|
||||||
|
method: { bg: "#00c8ff", border: "#00a0cc", font: "#060a13" },
|
||||||
|
struct: { bg: "#e040fb", border: "#b030d0", font: "#060a13" },
|
||||||
|
class: { bg: "#e040fb", border: "#b030d0", font: "#060a13" },
|
||||||
|
enum: { bg: "#ffb020", border: "#cc8c18", font: "#060a13" },
|
||||||
|
interface: { bg: "#7c4dff", border: "#5c38c0", font: "#ffffff" },
|
||||||
|
trait: { bg: "#7c4dff", border: "#5c38c0", font: "#ffffff" },
|
||||||
|
module: { bg: "#ff8a3d", border: "#cc6e30", font: "#060a13" },
|
||||||
|
file: { bg: "#00e676", border: "#00b85e", font: "#060a13" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const EDGE_COLORS = {
|
||||||
|
calls: "#00c8ff",
|
||||||
|
imports: "#ffb020",
|
||||||
|
inherits: "#e040fb",
|
||||||
|
implements: "#7c4dff",
|
||||||
|
contains: "rgba(94, 114, 145, 0.6)",
|
||||||
|
type_ref: "rgba(94, 114, 145, 0.5)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_NODE_COLOR = { bg: "#5e7291", border: "#3d506b", font: "#e4eaf4" };
|
||||||
|
const DEFAULT_EDGE_COLOR = "rgba(94, 114, 145, 0.3)";
|
||||||
|
|
||||||
|
let network = null;
|
||||||
|
let nodesDataset = null;
|
||||||
|
let edgesDataset = null;
|
||||||
|
let rawNodes = [];
|
||||||
|
let rawEdges = [];
|
||||||
|
|
||||||
|
function getNodeColor(kind) {
|
||||||
|
return NODE_COLORS[kind] || DEFAULT_NODE_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeColor(kind) {
|
||||||
|
return EDGE_COLORS[kind] || DEFAULT_EDGE_COLOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a vis-network node from raw graph data
|
||||||
|
function toVisNode(node, edgeCounts) {
|
||||||
|
const color = getNodeColor(node.kind);
|
||||||
|
const connections = edgeCounts[node.qualified_name] || 0;
|
||||||
|
const size = Math.max(8, Math.min(35, 8 + connections * 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: node.qualified_name,
|
||||||
|
label: node.name,
|
||||||
|
title: `${node.kind}: ${node.qualified_name}\nFile: ${node.file_path}:${node.start_line}`,
|
||||||
|
size: size,
|
||||||
|
color: {
|
||||||
|
background: color.bg,
|
||||||
|
border: color.border,
|
||||||
|
highlight: { background: color.bg, border: "#ffffff" },
|
||||||
|
hover: { background: color.bg, border: "#ffffff" },
|
||||||
|
},
|
||||||
|
font: {
|
||||||
|
color: color.font,
|
||||||
|
size: 10,
|
||||||
|
face: "'JetBrains Mono', monospace",
|
||||||
|
strokeWidth: 2,
|
||||||
|
strokeColor: "#060a13",
|
||||||
|
},
|
||||||
|
borderWidth: 1,
|
||||||
|
borderWidthSelected: 3,
|
||||||
|
shape: node.kind === "file" ? "diamond" : node.kind === "module" ? "square" : "dot",
|
||||||
|
// Store raw data for click handler
|
||||||
|
_raw: node,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVisEdge(edge) {
|
||||||
|
const color = getEdgeColor(edge.kind);
|
||||||
|
const isDim = edge.kind === "contains" || edge.kind === "type_ref";
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: edge.source,
|
||||||
|
to: edge.target,
|
||||||
|
color: {
|
||||||
|
color: color,
|
||||||
|
highlight: "#ffffff",
|
||||||
|
hover: color,
|
||||||
|
},
|
||||||
|
width: isDim ? 1 : 2,
|
||||||
|
arrows: {
|
||||||
|
to: {
|
||||||
|
enabled: edge.kind !== "contains",
|
||||||
|
scaleFactor: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
smooth: {
|
||||||
|
enabled: true,
|
||||||
|
type: "continuous",
|
||||||
|
roundness: 0.3,
|
||||||
|
},
|
||||||
|
title: `${edge.kind}: ${edge.source} → ${edge.target}`,
|
||||||
|
_raw: edge,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize or reload the graph visualization.
|
||||||
|
* Called from Rust via eval().
|
||||||
|
*/
|
||||||
|
window.__loadGraph = function (nodes, edges) {
|
||||||
|
var container = document.getElementById("graph-canvas");
|
||||||
|
if (!container) {
|
||||||
|
console.error("[graph-viz] #graph-canvas not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count edges per node for sizing
|
||||||
|
const edgeCounts = {};
|
||||||
|
edges.forEach(function (e) {
|
||||||
|
edgeCounts[e.source] = (edgeCounts[e.source] || 0) + 1;
|
||||||
|
edgeCounts[e.target] = (edgeCounts[e.target] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate nodes by qualified_name (keep first occurrence)
|
||||||
|
var seenNodes = {};
|
||||||
|
var uniqueNodes = [];
|
||||||
|
nodes.forEach(function (n) {
|
||||||
|
if (!seenNodes[n.qualified_name]) {
|
||||||
|
seenNodes[n.qualified_name] = true;
|
||||||
|
uniqueNodes.push(n);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deduplicate edges by source+target+kind
|
||||||
|
var seenEdges = {};
|
||||||
|
var uniqueEdges = [];
|
||||||
|
edges.forEach(function (e) {
|
||||||
|
var key = e.source + "|" + e.target + "|" + e.kind;
|
||||||
|
if (!seenEdges[key]) {
|
||||||
|
seenEdges[key] = true;
|
||||||
|
uniqueEdges.push(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rawNodes = uniqueNodes;
|
||||||
|
rawEdges = uniqueEdges;
|
||||||
|
|
||||||
|
var visNodes = uniqueNodes.map(function (n) {
|
||||||
|
return toVisNode(n, edgeCounts);
|
||||||
|
});
|
||||||
|
var visEdges = uniqueEdges.map(toVisEdge);
|
||||||
|
|
||||||
|
nodesDataset = new vis.DataSet(visNodes);
|
||||||
|
edgesDataset = new vis.DataSet(visEdges);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
nodes: {
|
||||||
|
font: { color: "#e4eaf4", size: 10 },
|
||||||
|
scaling: { min: 8, max: 35 },
|
||||||
|
},
|
||||||
|
edges: {
|
||||||
|
font: { color: "#5e7291", size: 9, strokeWidth: 0 },
|
||||||
|
selectionWidth: 3,
|
||||||
|
},
|
||||||
|
physics: {
|
||||||
|
enabled: true,
|
||||||
|
solver: "forceAtlas2Based",
|
||||||
|
forceAtlas2Based: {
|
||||||
|
gravitationalConstant: -60,
|
||||||
|
centralGravity: 0.012,
|
||||||
|
springLength: 80,
|
||||||
|
springConstant: 0.06,
|
||||||
|
damping: 0.4,
|
||||||
|
avoidOverlap: 0.5,
|
||||||
|
},
|
||||||
|
stabilization: {
|
||||||
|
enabled: true,
|
||||||
|
iterations: 1000,
|
||||||
|
updateInterval: 25,
|
||||||
|
},
|
||||||
|
maxVelocity: 40,
|
||||||
|
minVelocity: 0.1,
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
hover: true,
|
||||||
|
tooltipDelay: 200,
|
||||||
|
hideEdgesOnDrag: false,
|
||||||
|
hideEdgesOnZoom: false,
|
||||||
|
multiselect: false,
|
||||||
|
navigationButtons: false,
|
||||||
|
keyboard: { enabled: true },
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
improvedLayout: uniqueNodes.length < 300,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Destroy previous instance
|
||||||
|
if (network) {
|
||||||
|
network.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
network = new vis.Network(
|
||||||
|
container,
|
||||||
|
{ nodes: nodesDataset, edges: edgesDataset },
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click handler — sends data to Rust
|
||||||
|
network.on("click", function (params) {
|
||||||
|
if (params.nodes.length > 0) {
|
||||||
|
const nodeId = params.nodes[0];
|
||||||
|
const visNode = nodesDataset.get(nodeId);
|
||||||
|
if (visNode && visNode._raw && window.__onNodeClick) {
|
||||||
|
window.__onNodeClick(JSON.stringify(visNode._raw));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stabilization overlay
|
||||||
|
var overlay = document.getElementById("graph-stabilization-overlay");
|
||||||
|
var progressFill = document.getElementById("graph-stab-progress-fill");
|
||||||
|
var pctLabel = document.getElementById("graph-stab-pct");
|
||||||
|
|
||||||
|
// Show the overlay
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove("graph-stab-fade-out");
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
|
||||||
|
network.on("stabilizationProgress", function (params) {
|
||||||
|
var pct = Math.round((params.iterations / params.total) * 100);
|
||||||
|
if (progressFill) {
|
||||||
|
progressFill.style.width = pct + "%";
|
||||||
|
}
|
||||||
|
if (pctLabel) {
|
||||||
|
pctLabel.textContent = pct + "% — computing layout";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
network.on("stabilizationIterationsDone", function () {
|
||||||
|
if (progressFill) progressFill.style.width = "100%";
|
||||||
|
if (pctLabel) pctLabel.textContent = "Complete";
|
||||||
|
|
||||||
|
// Fade out overlay
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.add("graph-stab-fade-out");
|
||||||
|
setTimeout(function () {
|
||||||
|
overlay.style.display = "none";
|
||||||
|
}, 900);
|
||||||
|
}
|
||||||
|
network.setOptions({ physics: { enabled: false } });
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[graph-viz] Loaded " + nodes.length + " nodes, " + edges.length + " edges"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight a node by qualified name. Focuses + selects it.
|
||||||
|
*/
|
||||||
|
window.__highlightNode = function (qualifiedName) {
|
||||||
|
if (!network || !nodesDataset) return;
|
||||||
|
|
||||||
|
const node = nodesDataset.get(qualifiedName);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
network.selectNodes([qualifiedName]);
|
||||||
|
network.focus(qualifiedName, {
|
||||||
|
scale: 1.5,
|
||||||
|
animation: { duration: 500, easingFunction: "easeInOutQuad" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger click callback too
|
||||||
|
if (node._raw && window.__onNodeClick) {
|
||||||
|
window.__onNodeClick(JSON.stringify(node._raw));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight multiple nodes (e.g., search results or file nodes).
|
||||||
|
*/
|
||||||
|
window.__highlightNodes = function (qualifiedNames) {
|
||||||
|
if (!network || !nodesDataset) return;
|
||||||
|
network.selectNodes(qualifiedNames);
|
||||||
|
if (qualifiedNames.length > 0) {
|
||||||
|
network.fit({
|
||||||
|
nodes: qualifiedNames,
|
||||||
|
animation: { duration: 500, easingFunction: "easeInOutQuad" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight all nodes belonging to a specific file path.
|
||||||
|
*/
|
||||||
|
window.__highlightFileNodes = function (filePath) {
|
||||||
|
if (!network || !nodesDataset) return;
|
||||||
|
var matching = [];
|
||||||
|
rawNodes.forEach(function (n) {
|
||||||
|
if (n.file_path === filePath) {
|
||||||
|
matching.push(n.qualified_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (matching.length > 0) {
|
||||||
|
network.selectNodes(matching);
|
||||||
|
network.fit({
|
||||||
|
nodes: matching,
|
||||||
|
animation: { duration: 500, easingFunction: "easeInOutQuad" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear selection.
|
||||||
|
*/
|
||||||
|
window.__clearGraphSelection = function () {
|
||||||
|
if (!network) return;
|
||||||
|
network.unselectAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fit entire graph in view.
|
||||||
|
*/
|
||||||
|
window.__fitGraph = function () {
|
||||||
|
if (!network) return;
|
||||||
|
network.fit({
|
||||||
|
animation: { duration: 400, easingFunction: "easeInOutQuad" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
34
compliance-dashboard/assets/vis-network.min.js
vendored
Normal file
34
compliance-dashboard/assets/vis-network.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -41,6 +41,8 @@ pub enum Route {
|
|||||||
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
const FAVICON: Asset = asset!("/assets/favicon.svg");
|
||||||
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||||
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.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]
|
#[component]
|
||||||
pub fn App() -> Element {
|
pub fn App() -> Element {
|
||||||
@@ -48,6 +50,8 @@ pub fn App() -> Element {
|
|||||||
document::Link { rel: "icon", href: FAVICON }
|
document::Link { rel: "icon", href: FAVICON }
|
||||||
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||||
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
document::Script { src: VIS_NETWORK_JS }
|
||||||
|
document::Script { src: GRAPH_VIZ_JS }
|
||||||
Router::<Route> {}
|
Router::<Route> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
compliance-dashboard/src/components/code_inspector.rs
Normal file
93
compliance-dashboard/src/components/code_inspector.rs
Normal 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..." }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
compliance-dashboard/src/components/file_tree.rs
Normal file
176
compliance-dashboard/src/components/file_tree.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod app_shell;
|
pub mod app_shell;
|
||||||
|
pub mod code_inspector;
|
||||||
pub mod code_snippet;
|
pub mod code_snippet;
|
||||||
|
pub mod file_tree;
|
||||||
pub mod page_header;
|
pub mod page_header;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
pub mod severity_badge;
|
pub mod severity_badge;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ struct NavItem {
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn Sidebar() -> Element {
|
pub fn Sidebar() -> Element {
|
||||||
let current_route = use_route::<Route>();
|
let current_route = use_route::<Route>();
|
||||||
|
let mut collapsed = use_signal(|| true);
|
||||||
|
|
||||||
let nav_items = [
|
let nav_items = [
|
||||||
NavItem {
|
NavItem {
|
||||||
@@ -57,11 +58,15 @@ pub fn Sidebar() -> Element {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let sidebar_class = if collapsed() { "sidebar collapsed" } else { "sidebar" };
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
nav { class: "sidebar",
|
nav { class: "{sidebar_class}",
|
||||||
div { class: "sidebar-header",
|
div { class: "sidebar-header",
|
||||||
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
Icon { icon: BsShieldCheck, width: 24, height: 24 }
|
||||||
h1 { "Compliance Scanner" }
|
if !collapsed() {
|
||||||
|
h1 { "Compliance Scanner" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
div { class: "sidebar-nav",
|
div { class: "sidebar-nav",
|
||||||
for item in nav_items {
|
for item in nav_items {
|
||||||
@@ -82,15 +87,25 @@ pub fn Sidebar() -> Element {
|
|||||||
to: item.route.clone(),
|
to: item.route.clone(),
|
||||||
class: class,
|
class: class,
|
||||||
{item.icon}
|
{item.icon}
|
||||||
span { "{item.label}" }
|
if !collapsed() {
|
||||||
|
span { "{item.label}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div {
|
button {
|
||||||
style: "padding: 16px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-secondary);",
|
class: "sidebar-toggle",
|
||||||
"v0.1.0"
|
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" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,24 @@ pub struct NodesResponse {
|
|||||||
pub total: Option<u64>,
|
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]
|
#[server]
|
||||||
pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> {
|
pub async fn fetch_graph(repo_id: String) -> Result<GraphDataResponse, ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
@@ -81,6 +99,48 @@ pub async fn fetch_communities(repo_id: String) -> Result<CommunitiesResponse, S
|
|||||||
Ok(body)
|
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]
|
#[server]
|
||||||
pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> {
|
pub async fn trigger_graph_build(repo_id: String) -> Result<(), ServerFnError> {
|
||||||
let state: super::server_state::ServerState =
|
let state: super::server_state::ServerState =
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
description: format!("{} | {} | {}", f.scanner, f.scan_type, f.status),
|
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() }
|
SeverityBadge { severity: f.severity.to_string() }
|
||||||
if let Some(cwe) = &f.cwe {
|
if let Some(cwe) = &f.cwe {
|
||||||
span { class: "badge badge-info", "{cwe}" }
|
span { class: "badge badge-info", "{cwe}" }
|
||||||
@@ -43,7 +43,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", "Description" }
|
div { class: "card-header", "Description" }
|
||||||
p { style: "line-height: 1.6;", "{f.description}" }
|
p { "{f.description}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(code) = &f.code_snippet {
|
if let Some(code) = &f.code_snippet {
|
||||||
@@ -60,7 +60,7 @@ pub fn FindingDetailPage(id: String) -> Element {
|
|||||||
if let Some(remediation) = &f.remediation {
|
if let Some(remediation) = &f.remediation {
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", "Remediation" }
|
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",
|
||||||
div { class: "card-header", "Update Status" }
|
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"] {
|
for status in ["open", "triaged", "resolved", "false_positive", "ignored"] {
|
||||||
{
|
{
|
||||||
let status_str = status.to_string();
|
let status_str = status.to_string();
|
||||||
|
|||||||
@@ -59,10 +59,9 @@ pub fn OverviewPage() -> Element {
|
|||||||
|
|
||||||
div { class: "card",
|
div { class: "card",
|
||||||
div { class: "card-header", "Severity Distribution" }
|
div { class: "card-header", "Severity Distribution" }
|
||||||
div {
|
div { class: "severity-chart",
|
||||||
style: "display: flex; gap: 8px; align-items: flex-end; height: 200px; padding: 16px;",
|
|
||||||
SeverityBar { label: "Critical", count: s.critical_findings, max: s.total_findings, color: "var(--danger)" }
|
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: "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)" }
|
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 {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let height = format!("{}%", height_pct.max(2.0));
|
|
||||||
|
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div { class: "severity-bar",
|
||||||
style: "flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px;",
|
div { class: "severity-bar-count", "{count}" }
|
||||||
div {
|
div {
|
||||||
style: "font-size: 14px; font-weight: 600;",
|
class: "severity-bar-fill",
|
||||||
"{count}"
|
style: "height: {height_pct.max(2.0)}%; background: {color};",
|
||||||
}
|
|
||||||
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}"
|
|
||||||
}
|
}
|
||||||
|
div { class: "severity-bar-label", "{label}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,5 @@ chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-feature
|
|||||||
bollard = "0.18"
|
bollard = "0.18"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
bson = "2"
|
bson = { version = "2", features = ["chrono-0_4"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ petgraph = "0.7"
|
|||||||
tantivy = "0.22"
|
tantivy = "0.22"
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
bson = "2"
|
bson = { version = "2", features = ["chrono-0_4"] }
|
||||||
|
|
||||||
# Async streams
|
# Async streams
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
|||||||
@@ -102,13 +102,22 @@ impl GraphEngine {
|
|||||||
node_map.insert(node.qualified_name.clone(), idx);
|
node_map.insert(node.qualified_name.clone(), idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve and add edges
|
// Resolve and add edges — rewrite target to the resolved qualified name
|
||||||
|
// so the persisted edge references match node qualified_names.
|
||||||
let mut resolved_edges = Vec::new();
|
let mut resolved_edges = Vec::new();
|
||||||
for edge in parse_output.edges {
|
for mut edge in parse_output.edges {
|
||||||
let source_idx = node_map.get(&edge.source);
|
let source_idx = node_map.get(&edge.source);
|
||||||
let target_idx = self.resolve_edge_target(&edge.target, &node_map);
|
let resolved = self.resolve_edge_target(&edge.target, &node_map);
|
||||||
|
|
||||||
if let (Some(&src), Some(tgt)) = (source_idx, target_idx) {
|
if let (Some(&src), Some(tgt)) = (source_idx, resolved) {
|
||||||
|
// Update target to the resolved qualified name
|
||||||
|
let resolved_name = node_map
|
||||||
|
.iter()
|
||||||
|
.find(|(_, &idx)| idx == tgt)
|
||||||
|
.map(|(name, _)| name.clone());
|
||||||
|
if let Some(name) = resolved_name {
|
||||||
|
edge.target = name;
|
||||||
|
}
|
||||||
graph.add_edge(src, tgt, edge.kind.clone());
|
graph.add_edge(src, tgt, edge.kind.clone());
|
||||||
resolved_edges.push(edge);
|
resolved_edges.push(edge);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user