// DNScope-reduced/static/js/graph.js /** * Graph visualization module for DNScope * Handles network graph rendering using vis.js with proper large entity node hiding * UPDATED: Fixed time-based blue gradient edge coloring system and simplified logic. */ 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; } .time-control-container { margin-bottom: 0.5rem; padding: 0.5rem; background: rgba(42, 42, 42, 0.3); border-radius: 4px; border: 1px solid #444; } .time-control-label { font-size: 0.8rem; color: #c7c7c7; margin-bottom: 0.3rem; display: block; } .time-control-input { width: 100%; padding: 0.3rem; background: #1a1a1a; border: 1px solid #555; border-radius: 3px; color: #c7c7c7; font-family: 'Roboto Mono', monospace; font-size: 0.75rem; } .time-control-input:focus { outline: none; border-color: #00ff41; } .time-gradient-info { font-size: 0.7rem; color: #999; margin-top: 0.3rem; text-align: center; } `; 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.initialTargetIds = new Set(); this.largeEntityMembers = new Set(); this.isScanning = false; this.manualRefreshButton = null; this.manualRefreshHandler = null; this.timeOfInterest = new Date(); this.edgeTimestamps = new Map(); this.gradientColors = { dark: '#6b7280', light: '#00bfff' }; 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()); } createNodeInfoPopup() { this.nodeInfoPopup = document.createElement('div'); this.nodeInfoPopup.className = 'node-info-popup'; this.nodeInfoPopup.style.display = 'none'; document.body.appendChild(this.nodeInfoPopup); } createContextMenu() { 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'; this.contextMenu.addEventListener('click', (event) => { event.stopPropagation(); }); document.body.appendChild(this.contextMenu); } 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; const placeholder = this.container.querySelector('.graph-placeholder'); if (placeholder) { placeholder.style.display = 'none'; } this.addGraphControls(); this.addFilterPanel(); console.log('Graph initialized successfully'); } catch (error) { console.error('Failed to initialize graph:', error); this.showError('Failed to initialize visualization'); } } addGraphControls() { const controlsContainer = document.createElement('div'); controlsContainer.className = 'graph-controls'; const currentDateTime = this.formatDateTimeForInput(this.timeOfInterest); controlsContainer.innerHTML = `
Dark: Old data | Light Blue: Recent data
`; this.container.appendChild(controlsContainer); 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()); document.getElementById('time-of-interest').addEventListener('change', (e) => { this.timeOfInterest = new Date(e.target.value); this.updateEdgeColors(); }); this.manualRefreshButton = document.getElementById('graph-manual-refresh'); if (this.manualRefreshButton && this.manualRefreshHandler) { this.manualRefreshButton.addEventListener('click', this.manualRefreshHandler); } } formatDateTimeForInput(date) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; } extractEdgeTimestamp(edge) { const rawData = edge.raw_data || {}; if (rawData.relevance_timestamp) { return new Date(rawData.relevance_timestamp); } if (edge.discovery_timestamp) { return new Date(edge.discovery_timestamp); } return new Date(); } calculateTimeGradientColor(timestamp, maxTimeDiff) { if (!timestamp || !this.timeOfInterest) { return this.gradientColors.dark; } const timeDiff = Math.abs(timestamp.getTime() - this.timeOfInterest.getTime()); if (maxTimeDiff === 0) { return this.gradientColors.light; } const gradientPosition = timeDiff / maxTimeDiff; return this.interpolateColor( this.gradientColors.light, this.gradientColors.dark, gradientPosition ); } interpolateColor(color1, color2, factor) { const hex1 = color1.replace('#', ''); const hex2 = color2.replace('#', ''); const r1 = parseInt(hex1.substring(0, 2), 16); const g1 = parseInt(hex1.substring(2, 4), 16); const b1 = parseInt(hex1.substring(4, 6), 16); const r2 = parseInt(hex2.substring(0, 2), 16); const g2 = parseInt(hex2.substring(2, 4), 16); const b2 = parseInt(hex2.substring(4, 6), 16); const r = Math.round(r1 + (r2 - r1) * factor); const g = Math.round(g1 + (g2 - g1) * factor); const b = Math.round(b1 + (b2 - b1) * factor); return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; } updateEdgeColors() { const edgeUpdates = []; let maxTimeDiff = 0; this.edgeTimestamps.forEach((edgeTimestamp) => { const diff = Math.abs(edgeTimestamp.getTime() - this.timeOfInterest.getTime()); if (diff > maxTimeDiff) { maxTimeDiff = diff; } }); this.edges.forEach((edge) => { const timestamp = this.edgeTimestamps.get(edge.id); const color = this.calculateTimeGradientColor(timestamp, maxTimeDiff); edgeUpdates.push({ id: edge.id, color: { color: color, highlight: '#00ff41', hover: '#ff9900' } }); }); if (edgeUpdates.length > 0) { this.edges.update(edgeUpdates); } } setManualRefreshHandler(handler) { this.manualRefreshHandler = handler; if (this.manualRefreshButton && typeof handler === 'function') { this.manualRefreshButton.addEventListener('click', handler); } } showManualRefreshButton(show) { if (this.manualRefreshButton) { this.manualRefreshButton.style.display = show ? 'inline-block' : 'none'; } } addFilterPanel() { this.filterPanel = document.createElement('div'); this.filterPanel.className = 'graph-filter-panel'; this.container.appendChild(this.filterPanel); } setupNetworkEvents() { if (!this.network) return; this.container.addEventListener('contextmenu', (event) => { event.preventDefault(); const pointer = { x: event.offsetX, y: event.offsetY }; const nodeId = this.network.getNodeAt(pointer); if (nodeId) { this.showContextMenu(nodeId, event); } else { this.hideContextMenu(); } }); 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(); } }); this.network.on('hoverNode', (params) => { this.highlightConnectedNodes(params.node, true); }); this.network.on('stabilizationIterationsDone', () => { this.onStabilizationComplete(); }); document.addEventListener('click', (e) => { if (this.contextMenu && !this.contextMenu.contains(e.target)) { this.hideContextMenu(); } }); } updateGraph(graphData) { if (!graphData || !graphData.nodes || !graphData.edges) { console.warn('Invalid graph data received'); return; } try { if (!this.isInitialized) { this.initialize(); } this.initialTargetIds = new Set(graphData.initial_targets || []); const hasData = graphData.nodes.length > 0 || graphData.edges.length > 0; const placeholder = this.container.querySelector('.graph-placeholder'); if (placeholder) { placeholder.style.display = hasData ? 'none' : 'flex'; } if (!hasData) { this.nodes.clear(); this.edges.clear(); this.edgeTimestamps.clear(); return; } const nodeMap = new Map(graphData.nodes.map(node => [node.id, node])); // --- START: TWO-PASS LOGIC FOR ACCURATE GRADIENTS --- // 1. First Pass: Re-route edges and gather all timestamps to find the time range const rawEdges = graphData.edges.map(edge => { let fromNode = nodeMap.get(edge.from); let toNode = nodeMap.get(edge.to); let fromId = edge.from; let toId = edge.to; if (fromNode?.metadata?.large_entity_id) { fromId = fromNode.metadata.large_entity_id; } if (toNode?.metadata?.large_entity_id) { toId = toNode.metadata.large_entity_id; } if (fromId === toId) return null; return { ...edge, from: fromId, to: toId }; }).filter(Boolean); this.edgeTimestamps.clear(); rawEdges.forEach(edge => { const edgeId = `${edge.from}-${edge.to}-${edge.label}`; this.edgeTimestamps.set(edgeId, this.extractEdgeTimestamp(edge)); }); // 2. Calculate the global maxTimeDiff for this update let maxTimeDiff = 0; this.edgeTimestamps.forEach((edgeTimestamp) => { const diff = Math.abs(edgeTimestamp.getTime() - this.timeOfInterest.getTime()); if (diff > maxTimeDiff) { maxTimeDiff = diff; } }); // 3. Second Pass: Process nodes and edges with the correct time context const processedNodes = graphData.nodes.map(node => { const processed = this.processNode(node); processed.hidden = !!node.metadata?.large_entity_id; return processed; }); const processedEdges = rawEdges.map(edge => this.processEdge(edge, maxTimeDiff)); // --- END: TWO-PASS LOGIC --- this.nodes.update(processedNodes); this.edges.update(processedEdges); this.updateFilterControls(); this.applyAllFilters(); const newNodes = processedNodes.filter(node => !this.nodes.get(node.id)); const newEdges = processedEdges.filter(edge => !this.edges.get(edge.id)); if (newNodes.length > 0 || newEdges.length > 0) { setTimeout(() => this.highlightNewElements(newNodes, newEdges), 100); } if (this.nodes.length <= 10 || this.nodes.getIds().length === 0) { setTimeout(() => this.fitView(), 800); } } catch (error) { console.error('Failed to update graph:', error); this.showError('Failed to update visualization'); } } processEdge(edge, maxTimeDiff) { const edgeId = `${edge.from}-${edge.to}-${edge.label}`; const timestamp = this.edgeTimestamps.get(edgeId); const timeGradientColor = this.calculateTimeGradientColor(timestamp, maxTimeDiff); return { id: edgeId, from: edge.from, to: edge.to, label: edge.label, title: this.createEdgeTooltip(edge), color: { color: timeGradientColor, highlight: '#00ff41', hover: '#ff9900' }, metadata: { relationship_type: edge.label, source_provider: edge.source_provider, discovery_timestamp: edge.discovery_timestamp } }; } analyzeCertificateInfo(attributes) { let hasCertificates = false; let hasValidCertificates = false; let hasExpiredCertificates = false; for (const attr of attributes) { const attrName = (attr.name || '').toLowerCase(); const attrValue = attr.value; if (attrName.startsWith('cert_')) { hasCertificates = true; if (attrName === 'cert_is_currently_valid') { if (attrValue === true) { hasValidCertificates = true; } else if (attrValue === false) { hasExpiredCertificates = true; } } } } return { hasCertificates, hasValidCertificates, hasExpiredCertificates, hasExpiredOnly: hasExpiredCertificates && !hasValidCertificates }; } findAttributeByName(attributes, name) { if (!Array.isArray(attributes)) { return null; } return attributes.find(attr => attr.name === name) || null; } 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 || [] }; if (node.max_depth_reached) { processedNode.borderColor = '#ff0000'; } if (node.type === 'domain' && Array.isArray(node.attributes)) { const certInfo = this.analyzeCertificateInfo(node.attributes); if (certInfo.hasExpiredOnly) { processedNode.color = '#ff6b6b'; processedNode.borderColor = '#cc5555'; } else if (!certInfo.hasCertificates) { processedNode.color = '#c7c7c7'; processedNode.borderColor = '#999999'; } } if (node.type === 'correlation_object') { const correlationValueAttr = this.findAttributeByName(node.attributes, 'correlation_value'); const value = correlationValueAttr ? correlationValueAttr.value : 'Unknown'; const displayValue = typeof value === 'string' && value.length > 20 ? value.substring(0, 17) + '...' : value; processedNode.label = `${displayValue}`; processedNode.title = `Correlation: ${value}`; } return processedNode; } formatNodeLabel(nodeId, nodeType) { if (typeof nodeId !== 'string') return ''; if (nodeId.length > 20) { return nodeId.substring(0, 17) + '...'; } return nodeId; } getNodeColor(nodeType) { const colors = { 'domain': '#00ff41', 'ip': '#ff9900', 'isp': '#00aaff', 'ca': '#ff6b6b', 'large_entity': '#ff6b6b', 'correlation_object': '#9620c0ff' }; return colors[nodeType] || '#ffffff'; } getNodeBorderColor(nodeType) { const borderColors = { 'domain': '#00aa2e', 'ip': '#cc7700', 'isp': '#0088cc', 'ca': '#cc5555', 'correlation_object': '#c235c9ff' }; return borderColors[nodeType] || '#666666'; } getNodeSize(nodeType) { const sizes = { 'domain': 12, 'ip': 14, 'isp': 16, 'ca': 16, 'correlation_object': 8, 'large_entity': 25 }; return sizes[nodeType] || 12; } getNodeShape(nodeType) { const shapes = { 'domain': 'dot', 'ip': 'square', 'isp': 'triangle', 'ca': 'diamond', 'correlation_object': 'hexagon', 'large_entity': 'dot' }; return shapes[nodeType] || 'dot'; } createEdgeTooltip(edge) { let tooltip = `
`; tooltip += `
${edge.label || 'Relationship'}
`; if (edge.source_provider) { tooltip += `
Provider: ${edge.source_provider}
`; } if (edge.discovery_timestamp) { const discoveryDate = new Date(edge.discovery_timestamp); tooltip += `
Discovered: ${discoveryDate.toLocaleString()}
`; } const edgeId = `${edge.from}-${edge.to}-${edge.label}`; const relevanceTimestamp = this.edgeTimestamps.get(edgeId); if (relevanceTimestamp) { tooltip += `
Data from: ${relevanceTimestamp.toLocaleString()}
`; } tooltip += `
`; return tooltip; } showNodeDetails(node) { const event = new CustomEvent('nodeSelected', { detail: { node } }); document.dispatchEvent(event); } highlightNodeConnections(nodeId) { const connectedNodes = this.network.getConnectedNodes(nodeId); const connectedEdges = this.network.getConnectedEdges(nodeId); const nodeUpdates = connectedNodes.map(id => ({ id: id, borderColor: '#ff9900', borderWidth: 3 })); nodeUpdates.push({ id: nodeId, borderColor: '#00ff41', borderWidth: 4 }); const edgeUpdates = connectedEdges.map(id => ({ id: id, color: { color: '#ff9900' }, width: 3 })); this.nodes.update(nodeUpdates); this.edges.update(edgeUpdates); this.highlightedElements = { nodes: connectedNodes.concat([nodeId]), edges: connectedEdges }; } highlightConnectedNodes(nodeId, highlight) { const connectedNodes = this.network.getConnectedNodes(nodeId); const connectedEdges = this.network.getConnectedEdges(nodeId); if (highlight) { this.dimUnconnectedElements([nodeId, ...connectedNodes], connectedEdges); } } 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); } clearHighlights() { if (this.highlightedElements) { const nodeUpdates = this.highlightedElements.nodes.map(id => { const originalNode = this.nodes.get(id); return { id: id, borderColor: this.getNodeBorderColor(originalNode.type), borderWidth: 2 }; }); const edgeUpdates = this.highlightedElements.edges.map(id => { const timestamp = this.edgeTimestamps.get(id); const color = this.calculateTimeGradientColor(timestamp); return { id: id, color: { color: color, highlight: '#00ff41', hover: '#ff9900' } }; }); this.nodes.update(nodeUpdates); this.edges.update(edgeUpdates); this.highlightedElements = null; } } highlightNewElements(newNodes, newEdges) { const nodeHighlights = newNodes.map(node => ({ id: node.id, borderColor: '#00ff41', borderWidth: 4 })); const edgeHighlights = newEdges.map(edge => ({ id: edge.id, color: '#00ff41', width: 4 })); this.nodes.update(nodeHighlights); this.edges.update(edgeHighlights); setTimeout(() => { const nodeResets = newNodes.map(node => ({ id: node.id, borderColor: this.getNodeBorderColor(node.type), borderWidth: 2, })); const edgeResets = newEdges.map(edge => { const timestamp = this.edgeTimestamps.get(edge.id); const color = this.calculateTimeGradientColor(timestamp); return { id: edge.id, color: { color: color, highlight: '#00ff41', hover: '#ff9900' } }; }); this.nodes.update(nodeResets); this.edges.update(edgeResets); }, 2000); } onStabilizationComplete() { console.log('Graph stabilization complete'); } 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' } }); } } 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]'; } } toggleClustering() { if (this.network.isCluster('domain-cluster')) { this.network.openCluster('domain-cluster'); } else { this.network.cluster({ joinCondition: (nodeOptions) => nodeOptions.type === 'domain', clusterNodeProperties: { id: 'domain-cluster', label: 'Domains', shape: 'database', color: '#00ff41' } }); } } fitView() { if (this.network) { this.network.fit({ animation: { duration: 1000, easingFunction: 'easeInOutQuad' } }); } } clear() { this.nodes.clear(); this.edges.clear(); this.edgeTimestamps.clear(); this.history = []; const placeholder = this.container.querySelector('.graph-placeholder'); if (placeholder) { placeholder.style.display = 'flex'; } } showError(message) { const placeholder = this.container.querySelector('.graph-placeholder .placeholder-text'); if (placeholder) { placeholder.textContent = `Error: ${message}`; } } analyzeGraphReachability(excludedNodeIds = new Set(), excludedEdgeTypes = new Set(), excludedNodeTypes = new Set()) { const analysis = { reachableNodes: new Set(), unreachableNodes: new Set() }; if (this.nodes.length === 0) return analysis; const adjacencyList = {}; this.nodes.getIds().forEach(id => { if (!excludedNodeIds.has(id)) adjacencyList[id] = []; }); this.edges.forEach(edge => { if (!excludedEdgeTypes.has(edge.metadata?.relationship_type || '') && !excludedNodeIds.has(edge.from) && !excludedNodeIds.has(edge.to)) { if (adjacencyList[edge.from]) adjacencyList[edge.from].push(edge.to); } }); const traversalQueue = []; this.initialTargetIds.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); } } } }); 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); } } } } Object.keys(adjacencyList).forEach(nodeId => { if (!analysis.reachableNodes.has(nodeId)) { analysis.unreachableNodes.add(nodeId); } }); return analysis; } 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)); let filterHTML = '
'; filterHTML += '

Nodes

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

Edges

'; edgeTypes.forEach(type => { filterHTML += ``; }); filterHTML += '
'; this.filterPanel.innerHTML = filterHTML; this.filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('change', () => this.applyAllFilters()); }); } applyAllFilters() { if (this.nodes.length === 0) return; 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); }); const analysis = this.analyzeGraphReachability(new Set(), excludedEdgeTypes, excludedNodeTypes); 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); } hideNodeWithReachabilityAnalysis(nodeId) { const analysis = this.analyzeGraphReachability(new Set([nodeId])); const nodesToHide = [nodeId, ...Array.from(analysis.unreachableNodes)]; const historyData = { nodeIds: nodesToHide, operation: 'hide', timestamp: Date.now() }; const updates = nodesToHide.map(id => ({ id: id, hidden: true })); this.nodes.update(updates); this.addToHistory('hide', historyData); } async deleteNodeWithReachabilityAnalysis(nodeId) { const analysis = this.analyzeGraphReachability(new Set([nodeId])); const nodesToDelete = [nodeId, ...Array.from(analysis.unreachableNodes)]; const historyData = { nodes: nodesToDelete.map(id => this.nodes.get(id)).filter(Boolean), edges: [], operation: 'delete_with_reachability', timestamp: Date.now() }; nodesToDelete.forEach(id => { const connectedEdgeIds = this.network.getConnectedEdges(id); historyData.edges.push(...this.edges.get(connectedEdgeIds)); }); historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values()); for (const targetNodeId of nodesToDelete) { try { const response = await fetch(`/api/graph/node/${targetNodeId}`, { method: 'DELETE' }); if (!response.ok) throw new Error(`Backend deletion failed for ${targetNodeId}`); this.nodes.remove({ id: targetNodeId }); } catch (error) { this.nodes.update(historyData.nodes); this.edges.update(historyData.edges); return { success: false, error: "Backend deletion failed, UI reverted" }; } } this.addToHistory('delete', historyData); return { success: true, deletedNodes: nodesToDelete }; } showContextMenu(nodeId, event) { const node = this.nodes.get(nodeId); let menuItems = ``; this.contextMenu.innerHTML = menuItems; this.contextMenu.style.left = `${event.clientX}px`; this.contextMenu.style.top = `${event.clientY}px`; this.contextMenu.style.display = 'block'; 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`; this.contextMenu.querySelectorAll('li').forEach(item => { item.addEventListener('click', (e) => { if (e.currentTarget.hasAttribute('disabled')) return; e.stopPropagation(); this.performContextMenuAction(e.currentTarget.dataset.action, e.currentTarget.dataset.nodeId); this.hideContextMenu(); }); }); } hideContextMenu() { if (this.contextMenu) this.contextMenu.style.display = 'none'; } performContextMenuAction(action, nodeId) { switch (action) { case 'focus': this.focusOnNode(nodeId); break; case 'iterate': document.dispatchEvent(new CustomEvent('iterateScan', { detail: { nodeId } })); break; case 'hide': this.hideNodeWithReachabilityAnalysis(nodeId); break; case 'delete': this.deleteNodeWithReachabilityAnalysis(nodeId); break; case 'details': const node = this.nodes.get(nodeId); if (node) this.showNodeDetails(node); break; } } addToHistory(type, data) { this.history.push({ type, data }); } async revertLastAction() { const lastAction = this.history.pop(); if (!lastAction) return; switch (lastAction.type) { case 'hide': this.nodes.update(lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }))); break; case 'delete': try { const response = await fetch('/api/graph/revert', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(lastAction) }); if (!response.ok) throw new Error('Backend revert failed'); this.nodes.update(lastAction.data.nodes); this.edges.update(lastAction.data.edges); } catch (error) { this.history.push(lastAction); this.showError('Failed to revert the last action.'); } break; } } unhideAll() { const allHiddenNodes = this.nodes.get({ filter: (node) => { if (node.metadata?.large_entity_id || node.hidden !== true) return false; const hasVisibleEdges = this.edges.get().some(edge => (edge.to === node.id || edge.from === node.id) && !edge.hidden); return hasVisibleEdges; } }); if (allHiddenNodes.length > 0) { this.nodes.update(allHiddenNodes.map(node => ({ id: node.id, hidden: false }))); } } } window.GraphManager = GraphManager;