// 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 = `
`;
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 += '
';
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 = `- đ¯ Focus on Node
`;
if (node && (node.type === 'domain' || node.type === 'ip')) {
const disabled = this.isScanning ? 'disabled' : '';
const title = this.isScanning ? 'A scan is already in progress' : 'Iterate Scan';
menuItems += `- â Iterate Scan
`;
}
menuItems += `
- đģ Hide Node
- đī¸ Delete Node
- âšī¸ Show Details
`;
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;