1564 lines
53 KiB
JavaScript
1564 lines
53 KiB
JavaScript
/**
|
||
* Graph visualization module for DNSRecon
|
||
* Handles network graph rendering using vis.js with proper large entity node hiding
|
||
*/
|
||
const contextMenuCSS = `
|
||
.graph-context-menu {
|
||
position: fixed;
|
||
z-index: 1000;
|
||
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
|
||
border: 1px solid #444;
|
||
border-radius: 6px;
|
||
box-shadow: 0 8px 25px rgba(0,0,0,0.6);
|
||
display: none;
|
||
font-family: 'Roboto Mono', monospace;
|
||
font-size: 0.9rem;
|
||
color: #c7c7c7;
|
||
min-width: 180px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.graph-context-menu ul {
|
||
list-style: none;
|
||
padding: 0.5rem 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.graph-context-menu ul li {
|
||
padding: 0.75rem 1rem;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.graph-context-menu ul li:hover {
|
||
background: linear-gradient(135deg, #3a3a3a 0%, #2e2e2e 100%);
|
||
color: #00ff41;
|
||
}
|
||
|
||
.graph-context-menu .menu-icon {
|
||
font-size: 0.9rem;
|
||
width: 1.2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.graph-context-menu ul li:first-child {
|
||
border-top: none;
|
||
}
|
||
|
||
.graph-context-menu ul li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
`;
|
||
|
||
class GraphManager {
|
||
constructor(containerId) {
|
||
this.container = document.getElementById(containerId);
|
||
this.network = null;
|
||
this.nodes = new vis.DataSet();
|
||
this.edges = new vis.DataSet();
|
||
this.isInitialized = false;
|
||
this.currentLayout = 'physics';
|
||
this.nodeInfoPopup = null;
|
||
this.contextMenu = null;
|
||
this.history = [];
|
||
this.filterPanel = null;
|
||
this.trueRootIds = new Set();
|
||
// Track large entity members for proper hiding
|
||
this.largeEntityMembers = new Set();
|
||
|
||
this.options = {
|
||
nodes: {
|
||
shape: 'dot',
|
||
size: 15,
|
||
font: {
|
||
size: 12,
|
||
color: '#c7c7c7',
|
||
face: 'Roboto Mono, monospace',
|
||
background: 'rgba(26, 26, 26, 0.9)',
|
||
strokeWidth: 2,
|
||
strokeColor: '#000000'
|
||
},
|
||
borderWidth: 2,
|
||
borderColor: '#444',
|
||
scaling: {
|
||
min: 10,
|
||
max: 30,
|
||
label: {
|
||
enabled: true,
|
||
min: 8,
|
||
max: 16
|
||
}
|
||
},
|
||
chosen: {
|
||
node: (values, id, selected, hovering) => {
|
||
values.borderColor = '#00ff41';
|
||
values.borderWidth = 3;
|
||
}
|
||
}
|
||
},
|
||
edges: {
|
||
width: 2,
|
||
color: {
|
||
color: '#555',
|
||
highlight: '#00ff41',
|
||
hover: '#ff9900',
|
||
inherit: false
|
||
},
|
||
font: {
|
||
size: 10,
|
||
color: '#999',
|
||
face: 'Roboto Mono, monospace',
|
||
background: 'rgba(26, 26, 26, 0.8)',
|
||
strokeWidth: 1,
|
||
strokeColor: '#000000'
|
||
},
|
||
arrows: {
|
||
to: {
|
||
enabled: true,
|
||
scaleFactor: 1,
|
||
type: 'arrow'
|
||
}
|
||
},
|
||
smooth: {
|
||
enabled: true,
|
||
type: 'dynamic',
|
||
roundness: 0.6
|
||
},
|
||
chosen: {
|
||
edge: (values, id, selected, hovering) => {
|
||
values.color = '#00ff41';
|
||
values.width = 4;
|
||
}
|
||
}
|
||
},
|
||
physics: {
|
||
enabled: true,
|
||
stabilization: {
|
||
enabled: true,
|
||
iterations: 150,
|
||
updateInterval: 50
|
||
},
|
||
barnesHut: {
|
||
gravitationalConstant: -3000,
|
||
centralGravity: 0.4,
|
||
springLength: 120,
|
||
springConstant: 0.05,
|
||
damping: 0.1,
|
||
avoidOverlap: 0.2
|
||
},
|
||
maxVelocity: 30,
|
||
minVelocity: 0.1,
|
||
solver: 'barnesHut',
|
||
timestep: 0.4,
|
||
adaptiveTimestep: true
|
||
},
|
||
interaction: {
|
||
hover: true,
|
||
hoverConnectedEdges: true,
|
||
selectConnectedEdges: true,
|
||
tooltipDelay: 300,
|
||
hideEdgesOnDrag: false,
|
||
hideNodesOnDrag: false,
|
||
zoomView: true,
|
||
dragView: true,
|
||
multiselect: true
|
||
},
|
||
layout: {
|
||
improvedLayout: true,
|
||
randomSeed: 2
|
||
}
|
||
};
|
||
if (typeof document !== 'undefined') {
|
||
const style = document.createElement('style');
|
||
style.textContent = contextMenuCSS;
|
||
document.head.appendChild(style);
|
||
}
|
||
this.createNodeInfoPopup();
|
||
this.createContextMenu();
|
||
document.body.addEventListener('click', () => this.hideContextMenu());
|
||
}
|
||
|
||
/**
|
||
* Create floating node info popup
|
||
*/
|
||
createNodeInfoPopup() {
|
||
this.nodeInfoPopup = document.createElement('div');
|
||
this.nodeInfoPopup.className = 'node-info-popup';
|
||
this.nodeInfoPopup.style.display = 'none';
|
||
document.body.appendChild(this.nodeInfoPopup);
|
||
}
|
||
|
||
/**
|
||
* Create context menu
|
||
*/
|
||
createContextMenu() {
|
||
// Remove existing context menu if it exists
|
||
const existing = document.getElementById('graph-context-menu');
|
||
if (existing) {
|
||
existing.remove();
|
||
}
|
||
|
||
this.contextMenu = document.createElement('div');
|
||
this.contextMenu.id = 'graph-context-menu';
|
||
this.contextMenu.className = 'graph-context-menu';
|
||
this.contextMenu.style.display = 'none';
|
||
|
||
// Prevent body click listener from firing when clicking the menu itself
|
||
this.contextMenu.addEventListener('click', (event) => {
|
||
event.stopPropagation();
|
||
});
|
||
|
||
document.body.appendChild(this.contextMenu);
|
||
console.log('Context menu created and added to body');
|
||
}
|
||
|
||
/**
|
||
* Initialize the network graph
|
||
*/
|
||
initialize() {
|
||
if (this.isInitialized) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const data = {
|
||
nodes: this.nodes,
|
||
edges: this.edges
|
||
};
|
||
|
||
this.network = new vis.Network(this.container, data, this.options);
|
||
this.setupNetworkEvents();
|
||
this.isInitialized = true;
|
||
|
||
// Hide placeholder
|
||
const placeholder = this.container.querySelector('.graph-placeholder');
|
||
if (placeholder) {
|
||
placeholder.style.display = 'none';
|
||
}
|
||
|
||
// Add graph controls
|
||
this.addGraphControls();
|
||
this.addFilterPanel();
|
||
|
||
console.log('Graph initialized successfully');
|
||
} catch (error) {
|
||
console.error('Failed to initialize graph:', error);
|
||
this.showError('Failed to initialize visualization');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add interactive graph controls
|
||
*/
|
||
addGraphControls() {
|
||
const controlsContainer = document.createElement('div');
|
||
controlsContainer.className = 'graph-controls';
|
||
controlsContainer.innerHTML = `
|
||
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
|
||
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
|
||
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
|
||
<button class="graph-control-btn" id="graph-unhide" title="Unhide All">[UNHIDE]</button>
|
||
<button class="graph-control-btn" id="graph-revert" title="Revert Last Action">[REVERT]</button>
|
||
`;
|
||
|
||
this.container.appendChild(controlsContainer);
|
||
|
||
// Add control event listeners
|
||
document.getElementById('graph-fit').addEventListener('click', () => this.fitView());
|
||
document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics());
|
||
document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering());
|
||
document.getElementById('graph-unhide').addEventListener('click', () => this.unhideAll());
|
||
document.getElementById('graph-revert').addEventListener('click', () => this.revertLastAction());
|
||
}
|
||
|
||
addFilterPanel() {
|
||
this.filterPanel = document.createElement('div');
|
||
this.filterPanel.className = 'graph-filter-panel';
|
||
this.container.appendChild(this.filterPanel);
|
||
}
|
||
|
||
/**
|
||
* Setup network event handlers
|
||
*/
|
||
setupNetworkEvents() {
|
||
if (!this.network) return;
|
||
|
||
// FIXED: Right-click context menu
|
||
this.container.addEventListener('contextmenu', (event) => {
|
||
event.preventDefault();
|
||
console.log('Right-click detected at:', event.offsetX, event.offsetY);
|
||
|
||
// Get coordinates relative to the canvas
|
||
const pointer = {
|
||
x: event.offsetX,
|
||
y: event.offsetY
|
||
};
|
||
|
||
const nodeId = this.network.getNodeAt(pointer);
|
||
console.log('Node at pointer:', nodeId);
|
||
|
||
if (nodeId) {
|
||
// Pass the original client event for positioning
|
||
this.showContextMenu(nodeId, event);
|
||
} else {
|
||
this.hideContextMenu();
|
||
}
|
||
});
|
||
|
||
// Node click event with details
|
||
this.network.on('click', (params) => {
|
||
this.hideContextMenu();
|
||
if (params.nodes.length > 0) {
|
||
const nodeId = params.nodes[0];
|
||
if (this.network.isCluster(nodeId)) {
|
||
this.network.openCluster(nodeId);
|
||
} else {
|
||
const node = this.nodes.get(nodeId);
|
||
if (node) {
|
||
this.showNodeDetails(node);
|
||
this.highlightNodeConnections(nodeId);
|
||
}
|
||
}
|
||
} else {
|
||
this.clearHighlights();
|
||
}
|
||
});
|
||
|
||
// Hover events
|
||
this.network.on('hoverNode', (params) => {
|
||
const nodeId = params.node;
|
||
const node = this.nodes.get(nodeId);
|
||
if (node) {
|
||
this.highlightConnectedNodes(nodeId, true);
|
||
}
|
||
});
|
||
|
||
// Stabilization events with progress
|
||
this.network.on('stabilizationProgress', (params) => {
|
||
const progress = params.iterations / params.total;
|
||
this.updateStabilizationProgress(progress);
|
||
});
|
||
|
||
this.network.on('stabilizationIterationsDone', () => {
|
||
this.onStabilizationComplete();
|
||
});
|
||
|
||
// Selection events
|
||
this.network.on('select', (params) => {
|
||
console.log('Selected nodes:', params.nodes);
|
||
console.log('Selected edges:', params.edges);
|
||
});
|
||
|
||
// Click away to hide context menu
|
||
document.addEventListener('click', (e) => {
|
||
if (!this.contextMenu.contains(e.target)) {
|
||
this.hideContextMenu();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* @param {Object} graphData - Graph data from backend
|
||
*/
|
||
updateGraph(graphData) {
|
||
if (!graphData || !graphData.nodes || !graphData.edges) {
|
||
console.warn('Invalid graph data received');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Initialize if not already done
|
||
if (!this.isInitialized) {
|
||
this.initialize();
|
||
}
|
||
|
||
this.largeEntityMembers.clear();
|
||
const largeEntityMap = new Map();
|
||
|
||
graphData.nodes.forEach(node => {
|
||
if (node.type === 'large_entity' && node.attributes && Array.isArray(node.attributes.nodes)) {
|
||
node.attributes.nodes.forEach(nodeId => {
|
||
largeEntityMap.set(nodeId, node.id);
|
||
this.largeEntityMembers.add(nodeId);
|
||
});
|
||
}
|
||
});
|
||
|
||
const filteredNodes = graphData.nodes.filter(node => {
|
||
// Only include nodes that are NOT members of large entities
|
||
return !this.largeEntityMembers.has(node.id);
|
||
});
|
||
|
||
console.log(`Filtered ${graphData.nodes.length - filteredNodes.length} large entity member nodes from visualization`);
|
||
|
||
// Process only the filtered nodes
|
||
const processedNodes = filteredNodes.map(node => {
|
||
return this.processNode(node);
|
||
});
|
||
|
||
const mergedEdges = {};
|
||
graphData.edges.forEach(edge => {
|
||
const fromNode = largeEntityMap.has(edge.from) ? largeEntityMap.get(edge.from) : edge.from;
|
||
const toNode = largeEntityMap.has(edge.to) ? largeEntityMap.get(edge.to) : edge.to;
|
||
const mergeKey = `${fromNode}-${toNode}-${edge.label}`;
|
||
|
||
if (!mergedEdges[mergeKey]) {
|
||
mergedEdges[mergeKey] = {
|
||
...edge,
|
||
from: fromNode,
|
||
to: toNode,
|
||
count: 0,
|
||
confidence_score: 0
|
||
};
|
||
}
|
||
|
||
mergedEdges[mergeKey].count++;
|
||
if (edge.confidence_score > mergedEdges[mergeKey].confidence_score) {
|
||
mergedEdges[mergeKey].confidence_score = edge.confidence_score;
|
||
}
|
||
});
|
||
|
||
const processedEdges = Object.values(mergedEdges).map(edge => {
|
||
const processed = this.processEdge(edge);
|
||
if (edge.count > 1) {
|
||
processed.label = `${edge.label} (${edge.count})`;
|
||
}
|
||
return processed;
|
||
});
|
||
|
||
// Update datasets with animation
|
||
const existingNodeIds = this.nodes.getIds();
|
||
const existingEdgeIds = this.edges.getIds();
|
||
|
||
// Add new nodes with fade-in animation
|
||
const newNodes = processedNodes.filter(node => !existingNodeIds.includes(node.id));
|
||
const newEdges = processedEdges.filter(edge => !existingEdgeIds.includes(edge.id));
|
||
|
||
// Update existing data
|
||
this.nodes.update(processedNodes);
|
||
this.edges.update(processedEdges);
|
||
|
||
// After data is loaded, compute roots and apply filters
|
||
this.computeTrueRoots();
|
||
this.updateFilterControls();
|
||
this.applyAllFilters();
|
||
|
||
// Highlight new additions briefly
|
||
if (newNodes.length > 0 || newEdges.length > 0) {
|
||
setTimeout(() => this.highlightNewElements(newNodes, newEdges), 100);
|
||
}
|
||
|
||
// Auto-fit view for small graphs or first update
|
||
if (processedNodes.length <= 10 || existingNodeIds.length === 0) {
|
||
setTimeout(() => this.fitView(), 800);
|
||
}
|
||
|
||
console.log(`Graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges (${newNodes.length} new nodes, ${newEdges.length} new edges)`);
|
||
console.log(`Large entity members hidden: ${this.largeEntityMembers.size}`);
|
||
|
||
} catch (error) {
|
||
console.error('Failed to update graph:', error);
|
||
this.showError('Failed to update visualization');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Process node data with styling and metadata
|
||
* @param {Object} node - Raw node data
|
||
* @returns {Object} Processed node data
|
||
*/
|
||
processNode(node) {
|
||
const processedNode = {
|
||
id: node.id,
|
||
label: this.formatNodeLabel(node.id, node.type),
|
||
color: this.getNodeColor(node.type),
|
||
size: this.getNodeSize(node.type),
|
||
borderColor: this.getNodeBorderColor(node.type),
|
||
shape: this.getNodeShape(node.type),
|
||
attributes: node.attributes || {},
|
||
description: node.description || '',
|
||
metadata: node.metadata || {},
|
||
type: node.type,
|
||
incoming_edges: node.incoming_edges || [],
|
||
outgoing_edges: node.outgoing_edges || []
|
||
};
|
||
|
||
// Add confidence-based styling
|
||
if (node.confidence) {
|
||
processedNode.borderWidth = Math.max(2, Math.floor(node.confidence * 5));
|
||
}
|
||
|
||
// Style based on certificate validity
|
||
if (node.type === 'domain') {
|
||
if (node.attributes && node.attributes.certificates && node.attributes.certificates.has_valid_cert === false) {
|
||
processedNode.color = { background: '#888888', border: '#666666' };
|
||
}
|
||
}
|
||
|
||
// Handle merged correlation objects (similar to large entities)
|
||
if (node.type === 'correlation_object') {
|
||
const metadata = node.metadata || {};
|
||
const values = metadata.values || [];
|
||
const mergeCount = metadata.merge_count || 1;
|
||
|
||
if (mergeCount > 1) {
|
||
// Display as merged correlation container
|
||
processedNode.label = `Correlations (${mergeCount})`;
|
||
processedNode.title = `Merged correlation container with ${mergeCount} values: ${values.slice(0, 3).join(', ')}${values.length > 3 ? '...' : ''}`;
|
||
processedNode.borderWidth = 3; // Thicker border for merged nodes
|
||
} else {
|
||
// Single correlation value
|
||
const value = Array.isArray(values) && values.length > 0 ? values[0] : (metadata.value || 'Unknown');
|
||
const displayValue = typeof value === 'string' && value.length > 20 ? value.substring(0, 17) + '...' : value;
|
||
processedNode.label = `${displayValue}`;
|
||
processedNode.title = `Correlation: ${value}`;
|
||
}
|
||
}
|
||
|
||
return processedNode;
|
||
}
|
||
|
||
/**
|
||
* Process edge data with styling and metadata
|
||
* @param {Object} edge - Raw edge data
|
||
* @returns {Object} Processed edge data
|
||
*/
|
||
processEdge(edge) {
|
||
const confidence = edge.confidence_score || 0;
|
||
const processedEdge = {
|
||
id: `${edge.from}-${edge.to}`,
|
||
from: edge.from,
|
||
to: edge.to,
|
||
label: this.formatEdgeLabel(edge.label, confidence),
|
||
title: this.createEdgeTooltip(edge),
|
||
width: this.getEdgeWidth(confidence),
|
||
color: this.getEdgeColor(confidence),
|
||
dashes: confidence < 0.6 ? [5, 5] : false,
|
||
metadata: {
|
||
relationship_type: edge.label,
|
||
confidence_score: confidence,
|
||
source_provider: edge.source_provider,
|
||
discovery_timestamp: edge.discovery_timestamp
|
||
}
|
||
};
|
||
|
||
return processedEdge;
|
||
}
|
||
|
||
/**
|
||
* Format node label for display
|
||
* @param {string} nodeId - Node identifier
|
||
* @param {string} nodeType - Node type
|
||
* @returns {string} Formatted label
|
||
*/
|
||
formatNodeLabel(nodeId, nodeType) {
|
||
if (typeof nodeId !== 'string') return '';
|
||
if (nodeId.length > 20) {
|
||
return nodeId.substring(0, 17) + '...';
|
||
}
|
||
return nodeId;
|
||
}
|
||
|
||
/**
|
||
* Format edge label for display
|
||
* @param {string} relationshipType - Type of relationship
|
||
* @param {number} confidence - Confidence score
|
||
* @returns {string} Formatted label
|
||
*/
|
||
formatEdgeLabel(relationshipType, confidence) {
|
||
if (!relationshipType) return '';
|
||
|
||
const confidenceText = confidence >= 0.8 ? '●' : confidence >= 0.6 ? '◐' : '○';
|
||
return `${relationshipType} ${confidenceText}`;
|
||
}
|
||
|
||
/**
|
||
* Get node color based on type
|
||
* @param {string} nodeType - Node type
|
||
* @returns {string} Color value
|
||
*/
|
||
getNodeColor(nodeType) {
|
||
const colors = {
|
||
'domain': '#00ff41', // Green
|
||
'ip': '#ff9900', // Amber
|
||
'asn': '#00aaff', // Blue
|
||
'large_entity': '#ff6b6b', // Red for large entities
|
||
'correlation_object': '#9620c0ff'
|
||
};
|
||
return colors[nodeType] || '#ffffff';
|
||
}
|
||
|
||
/**
|
||
* Get node border color based on type
|
||
* @param {string} nodeType - Node type
|
||
* @returns {string} Border color value
|
||
*/
|
||
getNodeBorderColor(nodeType) {
|
||
const borderColors = {
|
||
'domain': '#00aa2e',
|
||
'ip': '#cc7700',
|
||
'asn': '#0088cc',
|
||
'correlation_object': '#c235c9ff'
|
||
};
|
||
return borderColors[nodeType] || '#666666';
|
||
}
|
||
|
||
/**
|
||
* Get node size based on type
|
||
* @param {string} nodeType - Node type
|
||
* @returns {number} Node size
|
||
*/
|
||
getNodeSize(nodeType) {
|
||
const sizes = {
|
||
'domain': 12,
|
||
'ip': 14,
|
||
'asn': 16,
|
||
'correlation_object': 8,
|
||
'large_entity': 5
|
||
};
|
||
return sizes[nodeType] || 12;
|
||
}
|
||
|
||
/**
|
||
* Get node shape based on type
|
||
* @param {string} nodeType - Node type
|
||
* @returns {string} Shape name
|
||
*/
|
||
getNodeShape(nodeType) {
|
||
const shapes = {
|
||
'domain': 'dot',
|
||
'ip': 'square',
|
||
'asn': 'triangle',
|
||
'correlation_object': 'hexagon',
|
||
'large_entity': 'database'
|
||
};
|
||
return shapes[nodeType] || 'dot';
|
||
}
|
||
|
||
/**
|
||
* Get edge color based on confidence
|
||
* @param {number} confidence - Confidence score
|
||
* @returns {string} Edge color
|
||
*/
|
||
getEdgeColor(confidence) {
|
||
if (confidence >= 0.8) {
|
||
return '#00ff41'; // High confidence - green
|
||
} else if (confidence >= 0.6) {
|
||
return '#ff9900'; // Medium confidence - amber
|
||
} else {
|
||
return '#666666'; // Low confidence - gray
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get edge width based on confidence
|
||
* @param {number} confidence - Confidence score
|
||
* @returns {number} Edge width
|
||
*/
|
||
getEdgeWidth(confidence) {
|
||
if (confidence >= 0.8) {
|
||
return 3;
|
||
} else if (confidence >= 0.6) {
|
||
return 2;
|
||
} else {
|
||
return 1;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create edge tooltip with correct provider information
|
||
* @param {Object} edge - Edge data
|
||
* @returns {string} HTML tooltip content
|
||
*/
|
||
createEdgeTooltip(edge) {
|
||
let tooltip = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`;
|
||
tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`;
|
||
tooltip += `<div style="color: #999; margin-bottom: 2px;">Confidence: ${(edge.confidence_score * 100).toFixed(1)}%</div>`;
|
||
|
||
if (edge.source_provider) {
|
||
tooltip += `<div style="color: #999; margin-bottom: 2px;">Provider: ${edge.source_provider}</div>`;
|
||
}
|
||
|
||
if (edge.discovery_timestamp) {
|
||
const date = new Date(edge.discovery_timestamp);
|
||
tooltip += `<div style="color: #666; font-size: 10px;">Discovered: ${date.toLocaleString()}</div>`;
|
||
}
|
||
|
||
tooltip += `</div>`;
|
||
return tooltip;
|
||
}
|
||
|
||
/**
|
||
* Determine if node is important based on connections or metadata
|
||
* @param {Object} node - Node data
|
||
* @returns {boolean} True if node is important
|
||
*/
|
||
isImportantNode(node) {
|
||
// Mark nodes as important based on criteria
|
||
if (node.type === 'domain' && node.id.includes('www.')) return false;
|
||
if (node.metadata && node.metadata.connection_count > 3) return true;
|
||
if (node.type === 'asn') return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Show node details in modal
|
||
* @param {Object} node - Node object
|
||
*/
|
||
showNodeDetails(node) {
|
||
// Trigger custom event for main application to handle
|
||
const event = new CustomEvent('nodeSelected', {
|
||
detail: { node }
|
||
});
|
||
document.dispatchEvent(event);
|
||
}
|
||
|
||
/**
|
||
* Hide node info popup
|
||
*/
|
||
hideNodeInfoPopup() {
|
||
if (this.nodeInfoPopup) {
|
||
this.nodeInfoPopup.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Highlight node connections
|
||
* @param {string} nodeId - Node to highlight
|
||
*/
|
||
highlightNodeConnections(nodeId) {
|
||
const connectedNodes = this.network.getConnectedNodes(nodeId);
|
||
const connectedEdges = this.network.getConnectedEdges(nodeId);
|
||
|
||
// Update node colors
|
||
const nodeUpdates = connectedNodes.map(id => ({
|
||
id: id,
|
||
borderColor: '#ff9900',
|
||
borderWidth: 3
|
||
}));
|
||
|
||
nodeUpdates.push({
|
||
id: nodeId,
|
||
borderColor: '#00ff41',
|
||
borderWidth: 4
|
||
});
|
||
|
||
// Update edge colors
|
||
const edgeUpdates = connectedEdges.map(id => ({
|
||
id: id,
|
||
color: { color: '#ff9900' },
|
||
width: 3
|
||
}));
|
||
|
||
this.nodes.update(nodeUpdates);
|
||
this.edges.update(edgeUpdates);
|
||
|
||
// Store for cleanup
|
||
this.highlightedElements = {
|
||
nodes: connectedNodes.concat([nodeId]),
|
||
edges: connectedEdges
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Highlight connected nodes on hover
|
||
* @param {string} nodeId - Node ID
|
||
* @param {boolean} highlight - Whether to highlight or unhighlight
|
||
*/
|
||
highlightConnectedNodes(nodeId, highlight) {
|
||
const connectedNodes = this.network.getConnectedNodes(nodeId);
|
||
const connectedEdges = this.network.getConnectedEdges(nodeId);
|
||
|
||
if (highlight) {
|
||
// Dim all other elements
|
||
this.dimUnconnectedElements([nodeId, ...connectedNodes], connectedEdges);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dim elements not connected to the specified nodes
|
||
* @param {Array} nodeIds - Node IDs to keep highlighted
|
||
* @param {Array} edgeIds - Edge IDs to keep highlighted
|
||
*/
|
||
dimUnconnectedElements(nodeIds, edgeIds) {
|
||
const allNodes = this.nodes.get();
|
||
const allEdges = this.edges.get();
|
||
|
||
const nodeUpdates = allNodes.map(node => ({
|
||
id: node.id,
|
||
opacity: nodeIds.includes(node.id) ? 1 : 0.3
|
||
}));
|
||
|
||
const edgeUpdates = allEdges.map(edge => ({
|
||
id: edge.id,
|
||
opacity: edgeIds.includes(edge.id) ? 1 : 0.1
|
||
}));
|
||
|
||
this.nodes.update(nodeUpdates);
|
||
this.edges.update(edgeUpdates);
|
||
}
|
||
|
||
/**
|
||
* Clear all highlights
|
||
*/
|
||
clearHighlights() {
|
||
if (this.highlightedElements) {
|
||
// Reset highlighted nodes
|
||
const nodeUpdates = this.highlightedElements.nodes.map(id => {
|
||
const originalNode = this.nodes.get(id);
|
||
return {
|
||
id: id,
|
||
borderColor: this.getNodeBorderColor(originalNode.type),
|
||
borderWidth: 2
|
||
};
|
||
});
|
||
|
||
// Reset highlighted edges
|
||
const edgeUpdates = this.highlightedElements.edges.map(id => {
|
||
const originalEdge = this.edges.get(id);
|
||
return {
|
||
id: id,
|
||
color: this.getEdgeColor(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5),
|
||
width: this.getEdgeWidth(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5)
|
||
};
|
||
});
|
||
|
||
this.nodes.update(nodeUpdates);
|
||
this.edges.update(edgeUpdates);
|
||
|
||
this.highlightedElements = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear hover highlights
|
||
*/
|
||
clearHoverHighlights() {
|
||
const allNodes = this.nodes.get();
|
||
const allEdges = this.edges.get();
|
||
|
||
const nodeUpdates = allNodes.map(node => ({ id: node.id, opacity: 1 }));
|
||
const edgeUpdates = allEdges.map(edge => ({ id: edge.id, opacity: 1 }));
|
||
|
||
this.nodes.update(nodeUpdates);
|
||
this.edges.update(edgeUpdates);
|
||
}
|
||
|
||
/**
|
||
* Highlight newly added elements
|
||
* @param {Array} newNodes - New nodes
|
||
* @param {Array} newEdges - New edges
|
||
*/
|
||
highlightNewElements(newNodes, newEdges) {
|
||
// Briefly highlight new nodes
|
||
const nodeHighlights = newNodes.map(node => ({
|
||
id: node.id,
|
||
borderColor: '#00ff41',
|
||
borderWidth: 4
|
||
}));
|
||
|
||
// Briefly highlight new edges
|
||
const edgeHighlights = newEdges.map(edge => ({
|
||
id: edge.id,
|
||
color: '#00ff41',
|
||
width: 4
|
||
}));
|
||
|
||
this.nodes.update(nodeHighlights);
|
||
this.edges.update(edgeHighlights);
|
||
|
||
// Reset after animation
|
||
setTimeout(() => {
|
||
const nodeResets = newNodes.map(node => ({
|
||
id: node.id,
|
||
borderColor: this.getNodeBorderColor(node.type),
|
||
borderWidth: 2,
|
||
}));
|
||
|
||
const edgeResets = newEdges.map(edge => ({
|
||
id: edge.id,
|
||
color: this.getEdgeColor(edge.metadata ? edge.metadata.confidence_score : 0.5),
|
||
width: this.getEdgeWidth(edge.metadata ? edge.metadata.confidence_score : 0.5)
|
||
}));
|
||
|
||
this.nodes.update(nodeResets);
|
||
this.edges.update(edgeResets);
|
||
}, 2000);
|
||
}
|
||
|
||
/**
|
||
* Update stabilization progress
|
||
* @param {number} progress - Progress value (0-1)
|
||
*/
|
||
updateStabilizationProgress(progress) {
|
||
// Could show a progress indicator if needed
|
||
console.log(`Graph stabilization: ${(progress * 100).toFixed(1)}%`);
|
||
}
|
||
|
||
/**
|
||
* Handle stabilization completion
|
||
*/
|
||
onStabilizationComplete() {
|
||
console.log('Graph stabilization complete');
|
||
}
|
||
|
||
/**
|
||
* Focus view on specific node
|
||
* @param {string} nodeId - Node to focus on
|
||
*/
|
||
focusOnNode(nodeId) {
|
||
const nodePosition = this.network.getPositions([nodeId]);
|
||
if (nodePosition[nodeId]) {
|
||
this.network.moveTo({
|
||
position: nodePosition[nodeId],
|
||
scale: 1.5,
|
||
animation: {
|
||
duration: 1000,
|
||
easingFunction: 'easeInOutQuart'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle physics simulation
|
||
*/
|
||
togglePhysics() {
|
||
const currentPhysics = this.network.physics.physicsEnabled;
|
||
this.network.setOptions({ physics: !currentPhysics });
|
||
|
||
const button = document.getElementById('graph-physics');
|
||
if (button) {
|
||
button.textContent = currentPhysics ? '[PHYSICS OFF]' : '[PHYSICS ON]';
|
||
button.style.color = currentPhysics ? '#ff9900' : '#00ff41';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle node clustering
|
||
*/
|
||
toggleClustering() {
|
||
if (this.network.isCluster('domain-cluster')) {
|
||
this.network.openCluster('domain-cluster');
|
||
} else {
|
||
const clusterOptions = {
|
||
joinCondition: (nodeOptions) => {
|
||
return nodeOptions.type === 'domain';
|
||
},
|
||
clusterNodeProperties: {
|
||
id: 'domain-cluster',
|
||
label: 'Domains',
|
||
shape: 'database',
|
||
color: '#00ff41',
|
||
borderWidth: 3,
|
||
}
|
||
};
|
||
this.network.cluster(clusterOptions);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Fit the view to show all nodes
|
||
*/
|
||
fitView() {
|
||
if (this.network) {
|
||
this.network.fit({
|
||
animation: {
|
||
duration: 1000,
|
||
easingFunction: 'easeInOutQuad'
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear the graph
|
||
*/
|
||
clear() {
|
||
this.nodes.clear();
|
||
this.edges.clear();
|
||
this.history = [];
|
||
this.largeEntityMembers.clear(); // Clear large entity tracking
|
||
|
||
// Show placeholder
|
||
const placeholder = this.container.querySelector('.graph-placeholder');
|
||
if (placeholder) {
|
||
placeholder.style.display = 'flex';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show error message
|
||
* @param {string} message - Error message
|
||
*/
|
||
showError(message) {
|
||
const placeholder = this.container.querySelector('.graph-placeholder .placeholder-text');
|
||
if (placeholder) {
|
||
placeholder.textContent = `Error: ${message}`;
|
||
placeholder.style.color = '#ff6b6b';
|
||
}
|
||
}
|
||
|
||
/*
|
||
* @param {Set} excludedNodeIds - Node IDs to exclude from analysis (for simulation)
|
||
* @param {Set} excludedEdgeTypes - Edge types to exclude from traversal
|
||
* @param {Set} excludedNodeTypes - Node types to exclude from traversal
|
||
* @returns {Object} Analysis results with reachable/unreachable nodes
|
||
*/
|
||
analyzeGraphReachability(excludedNodeIds = new Set(), excludedEdgeTypes = new Set(), excludedNodeTypes = new Set()) {
|
||
console.log("Performing comprehensive reachability analysis...");
|
||
|
||
const analysis = {
|
||
reachableNodes: new Set(),
|
||
unreachableNodes: new Set(),
|
||
isolatedClusters: [],
|
||
affectedNodes: new Set()
|
||
};
|
||
|
||
if (this.nodes.length === 0) return analysis;
|
||
|
||
// Build adjacency list excluding specified elements
|
||
const adjacencyList = {};
|
||
this.nodes.getIds().forEach(id => {
|
||
if (!excludedNodeIds.has(id)) {
|
||
adjacencyList[id] = [];
|
||
}
|
||
});
|
||
|
||
this.edges.forEach(edge => {
|
||
const edgeType = edge.metadata?.relationship_type || '';
|
||
if (!excludedEdgeTypes.has(edgeType) &&
|
||
!excludedNodeIds.has(edge.from) &&
|
||
!excludedNodeIds.has(edge.to)) {
|
||
|
||
if (adjacencyList[edge.from]) {
|
||
adjacencyList[edge.from].push(edge.to);
|
||
}
|
||
}
|
||
});
|
||
|
||
// BFS traversal from true roots
|
||
const traversalQueue = [];
|
||
|
||
// Start from true roots that aren't excluded
|
||
this.trueRootIds.forEach(rootId => {
|
||
if (!excludedNodeIds.has(rootId)) {
|
||
const node = this.nodes.get(rootId);
|
||
if (node && !excludedNodeTypes.has(node.type)) {
|
||
if (!analysis.reachableNodes.has(rootId)) {
|
||
traversalQueue.push(rootId);
|
||
analysis.reachableNodes.add(rootId);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// BFS to find all reachable nodes
|
||
let queueIndex = 0;
|
||
while (queueIndex < traversalQueue.length) {
|
||
const currentNode = traversalQueue[queueIndex++];
|
||
|
||
for (const neighbor of (adjacencyList[currentNode] || [])) {
|
||
if (!analysis.reachableNodes.has(neighbor)) {
|
||
const node = this.nodes.get(neighbor);
|
||
if (node && !excludedNodeTypes.has(node.type)) {
|
||
analysis.reachableNodes.add(neighbor);
|
||
traversalQueue.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Identify unreachable nodes (maintaining forensic integrity)
|
||
Object.keys(adjacencyList).forEach(nodeId => {
|
||
if (!analysis.reachableNodes.has(nodeId)) {
|
||
analysis.unreachableNodes.add(nodeId);
|
||
}
|
||
});
|
||
|
||
// Find isolated clusters among unreachable nodes
|
||
analysis.isolatedClusters = this.findIsolatedClusters(
|
||
Array.from(analysis.unreachableNodes),
|
||
adjacencyList
|
||
);
|
||
|
||
console.log(`Reachability analysis complete:`, {
|
||
reachable: analysis.reachableNodes.size,
|
||
unreachable: analysis.unreachableNodes.size,
|
||
clusters: analysis.isolatedClusters.length
|
||
});
|
||
|
||
return analysis;
|
||
}
|
||
|
||
/**
|
||
* Find isolated clusters within a set of nodes
|
||
* Used for forensic analysis to identify disconnected subgraphs
|
||
*/
|
||
findIsolatedClusters(nodeIds, adjacencyList) {
|
||
const visited = new Set();
|
||
const clusters = [];
|
||
|
||
for (const nodeId of nodeIds) {
|
||
if (!visited.has(nodeId)) {
|
||
const cluster = [];
|
||
const stack = [nodeId];
|
||
|
||
while (stack.length > 0) {
|
||
const current = stack.pop();
|
||
if (!visited.has(current)) {
|
||
visited.add(current);
|
||
cluster.push(current);
|
||
|
||
// Add unvisited neighbors within the unreachable set
|
||
for (const neighbor of (adjacencyList[current] || [])) {
|
||
if (nodeIds.includes(neighbor) && !visited.has(neighbor)) {
|
||
stack.push(neighbor);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cluster.length > 0) {
|
||
clusters.push(cluster);
|
||
}
|
||
}
|
||
}
|
||
|
||
return clusters;
|
||
}
|
||
|
||
/**
|
||
* ENHANCED: Get comprehensive graph statistics with forensic information
|
||
* Updates the existing getStatistics() method
|
||
*/
|
||
getStatistics() {
|
||
const basicStats = {
|
||
nodeCount: this.nodes.length,
|
||
edgeCount: this.edges.length,
|
||
largeEntityMembersHidden: this.largeEntityMembers.size
|
||
};
|
||
|
||
// Add forensic statistics
|
||
const visibleNodes = this.nodes.get({ filter: node => !node.hidden });
|
||
const hiddenNodes = this.nodes.get({ filter: node => node.hidden });
|
||
|
||
return {
|
||
...basicStats,
|
||
forensicStatistics: {
|
||
visibleNodes: visibleNodes.length,
|
||
hiddenNodes: hiddenNodes.length,
|
||
trueRoots: this.trueRootIds.size,
|
||
integrityStatus: visibleNodes.length > 0 && this.trueRootIds.size > 0 ? 'INTACT' : 'COMPROMISED'
|
||
}
|
||
};
|
||
}
|
||
|
||
computeTrueRoots() {
|
||
this.trueRootIds.clear();
|
||
const allNodes = this.nodes.get({ returnType: 'Object' });
|
||
const allEdges = this.edges.get();
|
||
const inDegrees = {};
|
||
|
||
for (const nodeId in allNodes) {
|
||
inDegrees[nodeId] = 0;
|
||
}
|
||
allEdges.forEach(edge => {
|
||
if (inDegrees[edge.to] !== undefined) {
|
||
inDegrees[edge.to]++;
|
||
}
|
||
});
|
||
|
||
for (const nodeId in allNodes) {
|
||
if (inDegrees[nodeId] === 0) {
|
||
this.trueRootIds.add(nodeId);
|
||
}
|
||
}
|
||
console.log("Computed true roots:", this.trueRootIds);
|
||
}
|
||
|
||
updateFilterControls() {
|
||
if (!this.filterPanel) return;
|
||
const nodeTypes = new Set(this.nodes.get().map(n => n.type));
|
||
const edgeTypes = new Set(this.edges.get().map(e => e.metadata.relationship_type));
|
||
|
||
// Wrap both columns in a single container with vertical layout
|
||
let filterHTML = '<div class="filter-container">';
|
||
|
||
// Nodes section
|
||
filterHTML += '<div class="filter-column"><h4>Nodes</h4><div class="checkbox-group">';
|
||
nodeTypes.forEach(type => {
|
||
const label = type === 'correlation_object' ? 'latent correlations' : type;
|
||
const isChecked = type !== 'correlation_object';
|
||
filterHTML += `<label><input type="checkbox" data-filter-type="node" value="${type}" ${isChecked ? 'checked' : ''}> ${label}</label>`;
|
||
});
|
||
filterHTML += '</div></div>';
|
||
|
||
// Edges section
|
||
filterHTML += '<div class="filter-column"><h4>Edges</h4><div class="checkbox-group">';
|
||
edgeTypes.forEach(type => {
|
||
filterHTML += `<label><input type="checkbox" data-filter-type="edge" value="${type}" checked> ${type}</label>`;
|
||
});
|
||
filterHTML += '</div></div>';
|
||
|
||
filterHTML += '</div>'; // Close filter-container
|
||
this.filterPanel.innerHTML = filterHTML;
|
||
|
||
this.filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
||
checkbox.addEventListener('change', () => this.applyAllFilters());
|
||
});
|
||
}
|
||
|
||
/**
|
||
* ENHANCED: Apply filters using consolidated reachability analysis
|
||
* Replaces the existing applyAllFilters() method
|
||
*/
|
||
applyAllFilters() {
|
||
console.log("Applying filters with enhanced reachability analysis...");
|
||
if (this.nodes.length === 0) return;
|
||
|
||
// Get filter criteria from UI
|
||
const excludedNodeTypes = new Set();
|
||
this.filterPanel?.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => {
|
||
excludedNodeTypes.add(cb.value);
|
||
});
|
||
|
||
const excludedEdgeTypes = new Set();
|
||
this.filterPanel?.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => {
|
||
excludedEdgeTypes.add(cb.value);
|
||
});
|
||
|
||
// Perform comprehensive analysis
|
||
const analysis = this.analyzeGraphReachability(new Set(), excludedEdgeTypes, excludedNodeTypes);
|
||
|
||
// Apply visibility updates
|
||
const nodeUpdates = this.nodes.map(node => ({
|
||
id: node.id,
|
||
hidden: !analysis.reachableNodes.has(node.id)
|
||
}));
|
||
|
||
const edgeUpdates = this.edges.map(edge => ({
|
||
id: edge.id,
|
||
hidden: excludedEdgeTypes.has(edge.metadata?.relationship_type || '') ||
|
||
!analysis.reachableNodes.has(edge.from) ||
|
||
!analysis.reachableNodes.has(edge.to)
|
||
}));
|
||
|
||
this.nodes.update(nodeUpdates);
|
||
this.edges.update(edgeUpdates);
|
||
|
||
console.log(`Enhanced filters applied. Visible nodes: ${analysis.reachableNodes.size}`);
|
||
}
|
||
|
||
/**
|
||
* ENHANCED: Hide node with forensic integrity using reachability analysis
|
||
* Replaces the existing hideNodeAndOrphans() method
|
||
*/
|
||
hideNodeWithReachabilityAnalysis(nodeId) {
|
||
console.log(`Hiding node ${nodeId} with reachability analysis...`);
|
||
|
||
// Simulate hiding this node and analyze impact
|
||
const excludedNodes = new Set([nodeId]);
|
||
const analysis = this.analyzeGraphReachability(excludedNodes);
|
||
|
||
// Nodes that will become unreachable (should be hidden)
|
||
const nodesToHide = [nodeId, ...Array.from(analysis.unreachableNodes)];
|
||
|
||
// Store history for potential revert
|
||
const historyData = {
|
||
nodeIds: nodesToHide,
|
||
operation: 'hide_with_reachability',
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// Apply hiding with forensic documentation
|
||
const updates = nodesToHide.map(id => ({
|
||
id: id,
|
||
hidden: true,
|
||
forensicNote: `Hidden due to reachability analysis from ${nodeId}`
|
||
}));
|
||
|
||
this.nodes.update(updates);
|
||
this.addToHistory('hide', historyData);
|
||
|
||
console.log(`Forensic hide operation: ${nodesToHide.length} nodes hidden`, {
|
||
originalTarget: nodeId,
|
||
cascadeNodes: nodesToHide.length - 1,
|
||
isolatedClusters: analysis.isolatedClusters.length
|
||
});
|
||
|
||
return {
|
||
hiddenNodes: nodesToHide,
|
||
isolatedClusters: analysis.isolatedClusters
|
||
};
|
||
}
|
||
|
||
/**
|
||
* ENHANCED: Delete node with forensic integrity using reachability analysis
|
||
* Replaces the existing deleteNodeAndOrphans() method
|
||
*/
|
||
async deleteNodeWithReachabilityAnalysis(nodeId) {
|
||
console.log(`Deleting node ${nodeId} with reachability analysis...`);
|
||
|
||
// Simulate deletion and analyze impact
|
||
const excludedNodes = new Set([nodeId]);
|
||
const analysis = this.analyzeGraphReachability(excludedNodes);
|
||
|
||
// Nodes that will become unreachable (should be deleted)
|
||
const nodesToDelete = [nodeId, ...Array.from(analysis.unreachableNodes)];
|
||
|
||
// Collect forensic data before deletion
|
||
const historyData = {
|
||
nodes: nodesToDelete.map(id => this.nodes.get(id)).filter(Boolean),
|
||
edges: [],
|
||
operation: 'delete_with_reachability',
|
||
timestamp: Date.now(),
|
||
forensicAnalysis: {
|
||
originalTarget: nodeId,
|
||
cascadeNodes: nodesToDelete.length - 1,
|
||
isolatedClusters: analysis.isolatedClusters.length,
|
||
clusterSizes: analysis.isolatedClusters.map(cluster => cluster.length)
|
||
}
|
||
};
|
||
|
||
// Collect affected edges
|
||
nodesToDelete.forEach(id => {
|
||
const connectedEdgeIds = this.network.getConnectedEdges(id);
|
||
const edges = this.edges.get(connectedEdgeIds);
|
||
historyData.edges.push(...edges);
|
||
});
|
||
|
||
// Remove duplicates from edges
|
||
historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values());
|
||
|
||
// Perform backend deletion with forensic logging
|
||
let operationFailed = false;
|
||
|
||
for (const targetNodeId of nodesToDelete) {
|
||
try {
|
||
const response = await fetch(`/api/graph/node/${targetNodeId}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
forensicContext: {
|
||
operation: 'reachability_cascade_delete',
|
||
originalTarget: nodeId,
|
||
analysisTimestamp: historyData.timestamp
|
||
}
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
if (!result.success) {
|
||
console.error(`Backend deletion failed for node ${targetNodeId}:`, result.error);
|
||
operationFailed = true;
|
||
break;
|
||
}
|
||
|
||
console.log(`Node ${targetNodeId} deleted from backend with forensic context`);
|
||
this.nodes.remove({ id: targetNodeId });
|
||
|
||
} catch (error) {
|
||
console.error(`API error during deletion of node ${targetNodeId}:`, error);
|
||
operationFailed = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Handle operation results
|
||
if (!operationFailed) {
|
||
this.addToHistory('delete', historyData);
|
||
console.log(`Forensic delete operation completed:`, historyData.forensicAnalysis);
|
||
|
||
return {
|
||
success: true,
|
||
deletedNodes: nodesToDelete,
|
||
forensicAnalysis: historyData.forensicAnalysis
|
||
};
|
||
} else {
|
||
// Revert UI changes if backend operations failed
|
||
console.log("Reverting UI changes due to backend failure");
|
||
this.nodes.add(historyData.nodes);
|
||
this.edges.add(historyData.edges);
|
||
|
||
return {
|
||
success: false,
|
||
error: "Backend deletion failed, UI reverted"
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show context menu for a node
|
||
* @param {string} nodeId - The ID of the node
|
||
* @param {Event} event - The contextmenu event
|
||
*/
|
||
showContextMenu(nodeId, event) {
|
||
console.log('Showing context menu for node:', nodeId);
|
||
|
||
// Create menu items
|
||
this.contextMenu.innerHTML = `
|
||
<ul>
|
||
<li data-action="focus" data-node-id="${nodeId}">
|
||
<span class="menu-icon">🎯</span>
|
||
<span>Focus on Node</span>
|
||
</li>
|
||
<li data-action="hide" data-node-id="${nodeId}">
|
||
<span class="menu-icon">👁️🗨️</span>
|
||
<span>Hide Node</span>
|
||
</li>
|
||
<li data-action="delete" data-node-id="${nodeId}">
|
||
<span class="menu-icon">🗑️</span>
|
||
<span>Delete Node</span>
|
||
</li>
|
||
<li data-action="details" data-node-id="${nodeId}">
|
||
<span class="menu-icon">ℹ️</span>
|
||
<span>Show Details</span>
|
||
</li>
|
||
</ul>
|
||
`;
|
||
|
||
// Position the menu
|
||
this.contextMenu.style.left = `${event.clientX}px`;
|
||
this.contextMenu.style.top = `${event.clientY}px`;
|
||
this.contextMenu.style.display = 'block';
|
||
|
||
// Ensure menu stays within viewport
|
||
const rect = this.contextMenu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth) {
|
||
this.contextMenu.style.left = `${event.clientX - rect.width}px`;
|
||
}
|
||
if (rect.bottom > window.innerHeight) {
|
||
this.contextMenu.style.top = `${event.clientY - rect.height}px`;
|
||
}
|
||
|
||
// Add event listeners to menu items
|
||
this.contextMenu.querySelectorAll('li').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const action = e.currentTarget.dataset.action;
|
||
const nodeId = e.currentTarget.dataset.nodeId;
|
||
console.log('Context menu action:', action, 'for node:', nodeId);
|
||
this.performContextMenuAction(action, nodeId);
|
||
this.hideContextMenu();
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Hide the context menu
|
||
*/
|
||
hideContextMenu() {
|
||
if (this.contextMenu) {
|
||
this.contextMenu.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* UPDATED: Enhanced context menu actions using new methods
|
||
* Updates the existing performContextMenuAction() method
|
||
*/
|
||
performContextMenuAction(action, nodeId) {
|
||
console.log('Performing enhanced action:', action, 'on node:', nodeId);
|
||
|
||
switch (action) {
|
||
case 'focus':
|
||
this.focusOnNode(nodeId);
|
||
break;
|
||
|
||
case 'hide':
|
||
// Use enhanced method with reachability analysis
|
||
this.hideNodeWithReachabilityAnalysis(nodeId);
|
||
break;
|
||
|
||
case 'delete':
|
||
// Use enhanced method with reachability analysis
|
||
this.deleteNodeWithReachabilityAnalysis(nodeId);
|
||
break;
|
||
|
||
case 'details':
|
||
const node = this.nodes.get(nodeId);
|
||
if (node) {
|
||
this.showNodeDetails(node);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
console.warn('Unknown action:', action);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add an operation to the history stack
|
||
* @param {string} type - The type of operation ('hide', 'delete')
|
||
* @param {Object} data - The data needed to revert the operation
|
||
*/
|
||
addToHistory(type, data) {
|
||
this.history.push({ type, data });
|
||
}
|
||
|
||
/**
|
||
* Revert the last action
|
||
*/
|
||
async revertLastAction() {
|
||
const lastAction = this.history.pop();
|
||
if (!lastAction) {
|
||
console.log('No actions to revert.');
|
||
return;
|
||
}
|
||
|
||
switch (lastAction.type) {
|
||
case 'hide':
|
||
// Revert hiding nodes by un-hiding them
|
||
const updates = lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }));
|
||
this.nodes.update(updates);
|
||
break;
|
||
case 'delete':
|
||
try {
|
||
const response = await fetch('/api/graph/revert', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(lastAction)
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
console.log('Delete action reverted successfully on backend.');
|
||
// Re-add all nodes and edges from the history to the local view
|
||
this.nodes.add(lastAction.data.nodes);
|
||
this.edges.add(lastAction.data.edges);
|
||
} else {
|
||
console.error('Failed to revert delete action on backend:', result.error);
|
||
// Push the action back onto the history stack if the API call failed
|
||
this.history.push(lastAction);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error during revert API call:', error);
|
||
this.history.push(lastAction);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Unhide all hidden nodes
|
||
*/
|
||
unhideAll() {
|
||
const allNodes = this.nodes.get({
|
||
filter: (node) => node.hidden === true
|
||
});
|
||
const updates = allNodes.map(node => ({ id: node.id, hidden: false }));
|
||
this.nodes.update(updates);
|
||
}
|
||
|
||
}
|
||
|
||
// Export for use in main.js
|
||
window.GraphManager = GraphManager; |