/** * 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.isScanning = false; 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 = ` `; 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, but always include the container itself return !this.largeEntityMembers.has(node.id) || node.type === 'large_entity'; }); 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 = `
`; tooltip += `
${edge.label || 'Relationship'}
`; tooltip += `
Confidence: ${(edge.confidence_score * 100).toFixed(1)}%
`; if (edge.source_provider) { tooltip += `
Provider: ${edge.source_provider}
`; } if (edge.discovery_timestamp) { const date = new Date(edge.discovery_timestamp); tooltip += `
Discovered: ${date.toLocaleString()}
`; } tooltip += `
`; 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 = '
'; // Nodes section filterHTML += '

Nodes

'; nodeTypes.forEach(type => { const label = type === 'correlation_object' ? 'latent correlations' : type; const isChecked = type !== 'correlation_object'; filterHTML += ``; }); filterHTML += '
'; // Edges section filterHTML += '

Edges

'; edgeTypes.forEach(type => { filterHTML += ``; }); filterHTML += '
'; filterHTML += '
'; // 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 - use update instead of add console.log("Reverting UI changes due to backend failure"); this.nodes.update(historyData.nodes); this.edges.update(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); const node = this.nodes.get(nodeId); // Create menu items let menuItems = ` `; this.contextMenu.innerHTML = menuItems; // 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) => { if (e.currentTarget.hasAttribute('disabled')) { // Prevent action if disabled e.stopPropagation(); return; } 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 'iterate': const event = new CustomEvent('iterateScan', { detail: { nodeId } }); document.dispatchEvent(event); 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 - use update instead of add this.nodes.update(lastAction.data.nodes); this.edges.update(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;