Some checks failed
CI / Format (push) Failing after 3s
CI / Clippy (push) Failing after 1m19s
CI / Security Audit (push) Has been skipped
CI / Tests (push) Has been skipped
CI / Format (pull_request) Failing after 2s
CI / Clippy (pull_request) Failing after 1m18s
CI / Security Audit (pull_request) Has been skipped
CI / Tests (pull_request) Has been skipped
- Fix SBOM display bug by removing incorrect BSON serde helpers on DateTime fields
- Add filtered/searchable SBOM list with repo, package manager, search, vuln, and license filters
- Add SBOM export (CycloneDX 1.5 / SPDX 2.3), license compliance tab, and cross-repo diff
- Add vulnerability drill-down with inline CVE details and advisory links
- Add DELETE /api/v1/repositories/{id} with cascade delete of all related data
- Add delete repository button with confirmation modal warning in dashboard
- Add spinner and progress bar for embedding builds with auto-polling status
- Install syft in agent Dockerfile for SBOM generation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
353 lines
9.9 KiB
JavaScript
353 lines
9.9 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════
|
|
// 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: -80,
|
|
centralGravity: 0.005,
|
|
springLength: 120,
|
|
springConstant: 0.04,
|
|
damping: 0.5,
|
|
avoidOverlap: 0.6,
|
|
},
|
|
stabilization: {
|
|
enabled: true,
|
|
iterations: 1500,
|
|
updateInterval: 25,
|
|
},
|
|
maxVelocity: 50,
|
|
minVelocity: 0.75,
|
|
},
|
|
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);
|
|
}
|
|
// Keep physics running so nodes float and respond to dragging,
|
|
// but reduce forces for a calm, settled feel
|
|
network.setOptions({
|
|
physics: {
|
|
enabled: true,
|
|
solver: "forceAtlas2Based",
|
|
forceAtlas2Based: {
|
|
gravitationalConstant: -40,
|
|
centralGravity: 0.003,
|
|
springLength: 120,
|
|
springConstant: 0.03,
|
|
damping: 0.7,
|
|
avoidOverlap: 0.6,
|
|
},
|
|
maxVelocity: 20,
|
|
minVelocity: 0.75,
|
|
},
|
|
});
|
|
});
|
|
|
|
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" },
|
|
});
|
|
};
|
|
})();
|