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

@@ -47,9 +47,10 @@ pub async fn get_graph(
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let (nodes, edges) = if let Some(ref b) = build {
let build_id = b.id.map(|oid| oid.to_hex()).unwrap_or_default();
let filter = doc! { "repo_id": &repo_id, "graph_build_id": &build_id };
let (nodes, edges) = if build.is_some() {
// Filter by repo_id only — delete_repo_graph clears old data before each rebuild,
// 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 {
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(&params.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
pub async fn trigger_build(
Extension(agent): AgentExt,

View File

@@ -43,6 +43,10 @@ pub fn build_router() -> Router {
"/api/v1/graph/{repo_id}/search",
get(handlers::graph::search_symbols),
)
.route(
"/api/v1/graph/{repo_id}/file-content",
get(handlers::graph::get_file_content),
)
.route(
"/api/v1/graph/{repo_id}/build",
post(handlers::graph::trigger_build),

View File

@@ -69,7 +69,19 @@ pub async fn triage_findings(
.await
{
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);
if let Some(remediation) = result.remediation {
finding.remediation = Some(remediation);

View 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" },
});
};
})();

File diff suppressed because one or more lines are too long

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}" }
}
}
}

View File

@@ -28,5 +28,5 @@ chromiumoxide = { version = "0.7", features = ["tokio-runtime"], default-feature
bollard = "0.18"
# Serialization
bson = "2"
bson = { version = "2", features = ["chrono-0_4"] }
url = "2"

View File

@@ -31,7 +31,7 @@ petgraph = "0.7"
tantivy = "0.22"
# Serialization
bson = "2"
bson = { version = "2", features = ["chrono-0_4"] }
# Async streams
futures-util = "0.3"

View File

@@ -102,13 +102,22 @@ impl GraphEngine {
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();
for edge in parse_output.edges {
for mut edge in parse_output.edges {
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());
resolved_edges.push(edge);
}