1122 lines
40 KiB
JavaScript
1122 lines
40 KiB
JavaScript
// 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; |