dnscope/static/js/graph.js
2025-09-24 11:36:27 +02:00

1122 lines
40 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<div class="time-control-container">
<label class="time-control-label">Time of Interest (for edge coloring)</label>
<input type="datetime-local" id="time-of-interest" class="time-control-input"
value="${currentDateTime}" title="Reference time for edge color gradient">
<div class="time-gradient-info">
Dark: Old data | Light Blue: Recent data
</div>
</div>
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
<button class="graph-control-btn" id="graph-unhide" title="Unhide All">[UNHIDE]</button>
<button class="graph-control-btn" id="graph-revert" title="Revert Last Action">[REVERT]</button>
<button class="graph-control-btn manual-refresh-btn" id="graph-manual-refresh"
title="Manual Refresh - Auto-refresh disabled due to large graph"
style="display: none;">[REFRESH]</button>
`;
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 = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`;
tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`;
if (edge.source_provider) {
tooltip += `<div style="color: #999; margin-bottom: 2px;">Provider: ${edge.source_provider}</div>`;
}
if (edge.discovery_timestamp) {
const discoveryDate = new Date(edge.discovery_timestamp);
tooltip += `<div style="color: #666; font-size: 10px;">Discovered: ${discoveryDate.toLocaleString()}</div>`;
}
const edgeId = `${edge.from}-${edge.to}-${edge.label}`;
const relevanceTimestamp = this.edgeTimestamps.get(edgeId);
if (relevanceTimestamp) {
tooltip += `<div style="color: #888; font-size: 10px;">Data from: ${relevanceTimestamp.toLocaleString()}</div>`;
}
tooltip += `</div>`;
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 = '<div class="filter-container">';
filterHTML += '<div class="filter-column"><h4>Nodes</h4><div class="checkbox-group">';
nodeTypes.forEach(type => {
const label = type === 'correlation_object' ? 'latent correlations' : type;
filterHTML += `<label><input type="checkbox" data-filter-type="node" value="${type}" checked> ${label}</label>`;
});
filterHTML += '</div></div>';
filterHTML += '<div class="filter-column"><h4>Edges</h4><div class="checkbox-group">';
edgeTypes.forEach(type => {
filterHTML += `<label><input type="checkbox" data-filter-type="edge" value="${type}" checked> ${type}</label>`;
});
filterHTML += '</div></div></div>';
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 = `<ul><li data-action="focus" data-node-id="${nodeId}"><span>🎯</span> Focus on Node</li>`;
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 += `<li data-action="iterate" data-node-id="${nodeId}" ${disabled} title="${title}"><span></span> Iterate Scan</li>`;
}
menuItems += `
<li data-action="hide" data-node-id="${nodeId}"><span>👻</span> Hide Node</li>
<li data-action="delete" data-node-id="${nodeId}"><span>🗑️</span> Delete Node</li>
<li data-action="details" data-node-id="${nodeId}"><span></span> Show Details</li>
</ul>`;
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;