diff --git a/static/js/graph.js b/static/js/graph.js index d775a00..fb5881c 100644 --- a/static/js/graph.js +++ b/static/js/graph.js @@ -1002,16 +1002,159 @@ class GraphManager { } } + /* + * @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; + } + /** - * Get network statistics - * @returns {Object} Statistics object + * 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() { - return { + 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() { @@ -1069,75 +1212,185 @@ class GraphManager { }); } + /** + * ENHANCED: Apply filters using consolidated reachability analysis + * Replaces the existing applyAllFilters() method + */ applyAllFilters() { - console.log("Applying all filters (robust orphan detection)..."); + console.log("Applying filters with enhanced reachability analysis..."); if (this.nodes.length === 0) return; - - // 1. Get filter criteria from the UI - const hiddenNodeTypes = new Set(); - this.filterPanel.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => { - hiddenNodeTypes.add(cb.value); + + // 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 hiddenEdgeTypes = new Set(); - this.filterPanel.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => { - hiddenEdgeTypes.add(cb.value); + + const excludedEdgeTypes = new Set(); + this.filterPanel?.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => { + excludedEdgeTypes.add(cb.value); }); - - // 2. Build adjacency list for the visible part of the graph - const adj = {}; - this.nodes.getIds().forEach(id => adj[id] = []); - this.edges.forEach(edge => { - if (!hiddenEdgeTypes.has(edge.metadata.relationship_type)) { - adj[edge.from].push(edge.to); - } - }); - - // 3. Traverse from "true roots" to find all reachable nodes - const reachableNodes = new Set(); - const queue = []; - - // Start the traversal from true roots that aren't hidden by type - this.trueRootIds.forEach(rootId => { - const node = this.nodes.get(rootId); - if (node && !hiddenNodeTypes.has(node.type)) { - if (!reachableNodes.has(rootId)) { - queue.push(rootId); - reachableNodes.add(rootId); - } - } - }); - - let head = 0; - while (head < queue.length) { - const u = queue[head++]; - - for (const v of (adj[u] || [])) { - if (!reachableNodes.has(v)) { - const node = this.nodes.get(v); - if (node && !hiddenNodeTypes.has(node.type)) { - reachableNodes.add(v); - queue.push(v); - } - } - } - } - - // 4. Create final node and edge visibility updates + + // Perform comprehensive analysis + const analysis = this.analyzeGraphReachability(new Set(), excludedEdgeTypes, excludedNodeTypes); + + // Apply visibility updates const nodeUpdates = this.nodes.map(node => ({ id: node.id, - hidden: !reachableNodes.has(node.id) + hidden: !analysis.reachableNodes.has(node.id) })); - + const edgeUpdates = this.edges.map(edge => ({ id: edge.id, - hidden: hiddenEdgeTypes.has(edge.metadata.relationship_type) || !reachableNodes.has(edge.from) || !reachableNodes.has(edge.to) + 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(`Filters applied. Reachable nodes: ${reachableNodes.size}`); + + 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" + }; + } } /** @@ -1207,29 +1460,34 @@ class GraphManager { } /** - * Perform action from the context menu - * @param {string} action - The action to perform ('hide' or 'delete') - * @param {string} nodeId - The ID of the node + * UPDATED: Enhanced context menu actions using new methods + * Updates the existing performContextMenuAction() method */ performContextMenuAction(action, nodeId) { - console.log('Performing action:', action, 'on node:', nodeId); + console.log('Performing enhanced action:', action, 'on node:', nodeId); switch (action) { case 'focus': this.focusOnNode(nodeId); break; + case 'hide': - this.hideNodeAndOrphans(nodeId); + // Use enhanced method with reachability analysis + this.hideNodeWithReachabilityAnalysis(nodeId); break; + case 'delete': - this.deleteNodeAndOrphans(nodeId); + // 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); } @@ -1289,121 +1547,6 @@ class GraphManager { } } - /** - * Hide a node and recursively hide any neighbors that become disconnected. - * @param {string} nodeId - The ID of the node to start hiding from. - */ - hideNodeAndOrphans(nodeId) { - const historyData = { nodeIds: [] }; - const queue = [nodeId]; - const visited = new Set([nodeId]); - - while (queue.length > 0) { - const currentId = queue.shift(); - const node = this.nodes.get(currentId); - if (!node || node.hidden) continue; - - // 1. Hide the current node and add to history - this.nodes.update({ id: currentId, hidden: true }); - historyData.nodeIds.push(currentId); - - // 2. Check its neighbors - const neighbors = this.network.getConnectedNodes(currentId); - for (const neighborId of neighbors) { - if (visited.has(neighborId)) continue; - - const connectedEdges = this.network.getConnectedEdges(neighborId); - let hasVisibleEdge = false; - // 3. See if the neighbor still has any visible connections - for (const edgeId of connectedEdges) { - const edge = this.edges.get(edgeId); - const sourceNode = this.nodes.get(edge.from); - const targetNode = this.nodes.get(edge.to); - if ((sourceNode && !sourceNode.hidden) && (targetNode && !targetNode.hidden)) { - hasVisibleEdge = true; - break; - } - } - - // 4. If no visible connections, add to queue to be hidden - if (!hasVisibleEdge) { - queue.push(neighborId); - visited.add(neighborId); - } - } - } - - if (historyData.nodeIds.length > 0) { - this.addToHistory('hide', historyData); - } - } - - /** - * Delete a node and recursively delete any neighbors that become disconnected. - * @param {string} nodeId - The ID of the node to start deleting from. - */ - async deleteNodeAndOrphans(nodeId) { - const deletionQueue = [nodeId]; - const processedForDeletion = new Set([nodeId]); - const historyData = { nodes: [], edges: [] }; - let operationFailed = false; - - while (deletionQueue.length > 0) { - const currentId = deletionQueue.shift(); - const node = this.nodes.get(currentId); - if (!node) continue; - - const neighbors = this.network.getConnectedNodes(currentId); - const connectedEdgeIds = this.network.getConnectedEdges(currentId); - const edges = this.edges.get(connectedEdgeIds); - - // Store state for potential revert - historyData.nodes.push(node); - historyData.edges.push(...edges); - - try { - const response = await fetch(`/api/graph/node/${currentId}`, { method: 'DELETE' }); - const result = await response.json(); - - if (!result.success) { - console.error(`Failed to delete node ${currentId} from backend:`, result.error); - operationFailed = true; - break; - } - - console.log(`Node ${currentId} deleted from backend.`); - this.nodes.remove({ id: currentId }); // Remove from view - - // Check if former neighbors are now orphans - neighbors.forEach(neighborId => { - if (!processedForDeletion.has(neighborId) && this.nodes.get(neighborId)) { - if (this.network.getConnectedEdges(neighborId).length === 0) { - deletionQueue.push(neighborId); - processedForDeletion.add(neighborId); - } - } - }); - - } catch (error) { - console.error('Error during node deletion API call:', error); - operationFailed = true; - break; - } - } - - // Add to history only if the entire operation was successful - if (!operationFailed && historyData.nodes.length > 0) { - // Ensure edges in history are unique - historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values()); - this.addToHistory('delete', historyData); - } else if (operationFailed) { - console.log("Reverting UI changes due to failed delete operation."); - // If any part of the chain failed, restore the UI to its original state - this.nodes.add(historyData.nodes); - this.edges.add(historyData.edges); - } - } - /** * Unhide all hidden nodes */