2442 lines
92 KiB
JavaScript
2442 lines
92 KiB
JavaScript
/**
|
||
* Main application logic for DNSRecon web interface
|
||
* Handles UI interactions, API communication, and data flow
|
||
*/
|
||
|
||
class DNSReconApp {
|
||
constructor() {
|
||
console.log('DNSReconApp constructor called');
|
||
this.graphManager = null;
|
||
this.scanStatus = 'idle';
|
||
this.pollInterval = null;
|
||
this.currentSessionId = null;
|
||
|
||
this.elements = {};
|
||
|
||
this.isScanning = false;
|
||
this.lastGraphUpdate = null;
|
||
|
||
this.init();
|
||
}
|
||
|
||
/**
|
||
* Initialize the application
|
||
*/
|
||
init() {
|
||
console.log('DNSReconApp init called');
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
console.log('DOM loaded, initializing application...');
|
||
try {
|
||
this.initializeElements();
|
||
this.setupEventHandlers();
|
||
this.initializeGraph();
|
||
this.updateStatus();
|
||
this.loadProviders();
|
||
this.initializeEnhancedModals();
|
||
|
||
console.log('DNSRecon application initialized successfully');
|
||
} catch (error) {
|
||
console.error('Failed to initialize DNSRecon application:', error);
|
||
this.showError(`Initialization failed: ${error.message}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize DOM element references
|
||
*/
|
||
initializeElements() {
|
||
console.log('Initializing DOM elements...');
|
||
this.elements = {
|
||
// Form elements
|
||
targetInput: document.getElementById('target-input'),
|
||
maxDepth: document.getElementById('max-depth'),
|
||
startScan: document.getElementById('start-scan'),
|
||
addToGraph: document.getElementById('add-to-graph'),
|
||
stopScan: document.getElementById('stop-scan'),
|
||
exportResults: document.getElementById('export-results'),
|
||
configureSettings: document.getElementById('configure-settings'),
|
||
|
||
// Status elements
|
||
scanStatus: document.getElementById('scan-status'),
|
||
targetDisplay: document.getElementById('target-display'),
|
||
depthDisplay: document.getElementById('depth-display'),
|
||
relationshipsDisplay: document.getElementById('relationships-display'),
|
||
progressCompact: document.getElementById('progress-compact'),
|
||
progressFill: document.getElementById('progress-fill'),
|
||
|
||
// Provider elements
|
||
providerList: document.getElementById('provider-list'),
|
||
|
||
// Node Modal elements
|
||
nodeModal: document.getElementById('node-modal'),
|
||
modalTitle: document.getElementById('modal-title'),
|
||
modalDetails: document.getElementById('modal-details'),
|
||
modalClose: document.getElementById('modal-close'),
|
||
|
||
// Settings Modal elements
|
||
settingsModal: document.getElementById('settings-modal'),
|
||
settingsModalClose: document.getElementById('settings-modal-close'),
|
||
apiKeyInputs: document.getElementById('api-key-inputs'),
|
||
saveApiKeys: document.getElementById('save-api-keys'),
|
||
resetApiKeys: document.getElementById('reset-api-keys'),
|
||
|
||
// Other elements
|
||
sessionId: document.getElementById('session-id'),
|
||
connectionStatus: document.getElementById('connection-status'),
|
||
};
|
||
|
||
// Verify critical elements exist
|
||
const requiredElements = ['targetInput', 'startScan', 'scanStatus'];
|
||
for (const elementName of requiredElements) {
|
||
if (!this.elements[elementName]) {
|
||
throw new Error(`Required element '${elementName}' not found in DOM`);
|
||
}
|
||
}
|
||
|
||
console.log('DOM elements initialized successfully');
|
||
this.createMessageContainer();
|
||
}
|
||
|
||
/**
|
||
* Create a message container for showing user feedback
|
||
*/
|
||
createMessageContainer() {
|
||
// Check if message container already exists
|
||
let messageContainer = document.getElementById('message-container');
|
||
if (!messageContainer) {
|
||
messageContainer = document.createElement('div');
|
||
messageContainer.id = 'message-container';
|
||
messageContainer.className = 'message-container';
|
||
messageContainer.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 1000;
|
||
max-width: 400px;
|
||
`;
|
||
document.body.appendChild(messageContainer);
|
||
console.log('Message container created');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup event handlers
|
||
*/
|
||
setupEventHandlers() {
|
||
console.log('Setting up event handlers...');
|
||
|
||
try {
|
||
// Form interactions
|
||
|
||
this.initializeModalFunctionality();
|
||
this.elements.startScan.addEventListener('click', (e) => {
|
||
console.log('Start scan button clicked');
|
||
e.preventDefault();
|
||
this.startScan();
|
||
});
|
||
|
||
this.elements.addToGraph.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
this.startScan(false);
|
||
});
|
||
|
||
this.elements.stopScan.addEventListener('click', (e) => {
|
||
console.log('Stop scan button clicked');
|
||
e.preventDefault();
|
||
this.stopScan();
|
||
});
|
||
|
||
this.elements.exportResults.addEventListener('click', (e) => {
|
||
console.log('Export results button clicked');
|
||
e.preventDefault();
|
||
this.exportResults();
|
||
});
|
||
|
||
this.elements.configureSettings.addEventListener('click', () => this.showSettingsModal());
|
||
|
||
// Enter key support for target domain input
|
||
this.elements.targetInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter' && !this.isScanning) {
|
||
console.log('Enter key pressed in domain input');
|
||
this.startScan();
|
||
}
|
||
});
|
||
|
||
// Node Modal interactions
|
||
if (this.elements.modalClose) {
|
||
this.elements.modalClose.addEventListener('click', () => this.hideModal());
|
||
}
|
||
if (this.elements.nodeModal) {
|
||
this.elements.nodeModal.addEventListener('click', (e) => {
|
||
if (e.target === this.elements.nodeModal) this.hideModal();
|
||
});
|
||
}
|
||
|
||
// Settings Modal interactions
|
||
if (this.elements.settingsModalClose) {
|
||
this.elements.settingsModalClose.addEventListener('click', () => this.hideSettingsModal());
|
||
}
|
||
if (this.elements.settingsModal) {
|
||
this.elements.settingsModal.addEventListener('click', (e) => {
|
||
if (e.target === this.elements.settingsModal) this.hideSettingsModal();
|
||
});
|
||
}
|
||
if (this.elements.saveApiKeys) {
|
||
this.elements.saveApiKeys.addEventListener('click', () => this.saveApiKeys());
|
||
}
|
||
if (this.elements.resetApiKeys) {
|
||
this.elements.resetApiKeys.addEventListener('click', () => this.resetApiKeys());
|
||
}
|
||
|
||
// Listen for the custom event from the graph
|
||
document.addEventListener('nodeSelected', (e) => {
|
||
this.showNodeModal(e.detail.node);
|
||
});
|
||
|
||
// Listen for the new iterateScan event from the graph context menu
|
||
document.addEventListener('iterateScan', (e) => {
|
||
if (this.isScanning) {
|
||
this.showWarning('A scan is already in progress.');
|
||
return;
|
||
}
|
||
const { nodeId } = e.detail;
|
||
console.log(`Received iterateScan event for node: ${nodeId}`);
|
||
this.elements.targetInput.value = nodeId;
|
||
this.startScan(false, nodeId); // Pass nodeId to force rescan
|
||
});
|
||
|
||
// Keyboard shortcuts
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
this.hideModal();
|
||
this.hideSettingsModal();
|
||
}
|
||
});
|
||
|
||
// Window events
|
||
window.addEventListener('beforeunload', () => {
|
||
if (this.isScanning) {
|
||
return 'A scan is currently in progress. Are you sure you want to leave?';
|
||
}
|
||
});
|
||
|
||
console.log('Event handlers set up successfully');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to setup event handlers:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize graph visualization
|
||
*/
|
||
initializeGraph() {
|
||
try {
|
||
console.log('Initializing graph manager...');
|
||
this.graphManager = new GraphManager('network-graph');
|
||
console.log('Graph manager initialized successfully');
|
||
} catch (error) {
|
||
console.error('Failed to initialize graph manager:', error);
|
||
this.showError('Failed to initialize graph visualization');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start scan with error handling
|
||
*/
|
||
async startScan(clearGraph = true, forceRescanTarget = null) {
|
||
console.log('=== STARTING SCAN ===');
|
||
|
||
try {
|
||
const target = this.elements.targetInput.value.trim();
|
||
const maxDepth = parseInt(this.elements.maxDepth.value);
|
||
|
||
console.log(`Target: "${target}", Max depth: ${maxDepth}`);
|
||
|
||
// Validation
|
||
if (!target) {
|
||
console.log('Validation failed: empty target');
|
||
this.showError('Please enter a target domain or IP');
|
||
this.elements.targetInput.focus();
|
||
return;
|
||
}
|
||
|
||
if (!this.isValidTarget(target)) {
|
||
console.log(`Validation failed: invalid target format for "${target}"`);
|
||
this.showError('Please enter a valid domain name (e.g., example.com) or IP address (e.g., 8.8.8.8)');
|
||
this.elements.targetInput.focus();
|
||
return;
|
||
}
|
||
|
||
console.log('Validation passed, setting UI state to scanning...');
|
||
this.setUIState('scanning');
|
||
this.showInfo('Starting reconnaissance scan...');
|
||
|
||
console.log('Making API call to start scan...');
|
||
|
||
const requestData = {
|
||
target: target,
|
||
max_depth: maxDepth,
|
||
clear_graph: clearGraph,
|
||
force_rescan_target: forceRescanTarget
|
||
};
|
||
|
||
console.log('Request data:', requestData);
|
||
|
||
const response = await this.apiCall('/api/scan/start', 'POST', requestData);
|
||
|
||
console.log('API response received:', response);
|
||
|
||
if (response.success) {
|
||
this.currentSessionId = response.scan_id;
|
||
this.showSuccess('Reconnaissance scan started successfully');
|
||
|
||
if (clearGraph) {
|
||
this.graphManager.clear();
|
||
}
|
||
|
||
console.log(`Scan started for ${target} with depth ${maxDepth}`);
|
||
|
||
// Start polling immediately with faster interval for responsiveness
|
||
this.startPolling(1000);
|
||
|
||
// Force an immediate status update
|
||
console.log('Forcing immediate status update...');
|
||
setTimeout(() => {
|
||
this.updateStatus();
|
||
this.updateGraph();
|
||
}, 100);
|
||
|
||
} else {
|
||
throw new Error(response.error || 'Failed to start scan');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to start scan:', error);
|
||
this.showError(`Failed to start scan: ${error.message}`);
|
||
this.setUIState('idle');
|
||
}
|
||
}
|
||
/**
|
||
* Scan stop with immediate UI feedback
|
||
*/
|
||
async stopScan() {
|
||
try {
|
||
console.log('Stopping scan...');
|
||
|
||
// Immediately disable stop button and show stopping state
|
||
if (this.elements.stopScan) {
|
||
this.elements.stopScan.disabled = true;
|
||
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOPPING]</span><span>Stopping...</span>';
|
||
}
|
||
|
||
// Show immediate feedback
|
||
this.showInfo('Stopping scan...');
|
||
|
||
const response = await this.apiCall('/api/scan/stop', 'POST');
|
||
|
||
if (response.success) {
|
||
this.showSuccess('Scan stop requested');
|
||
console.log('Scan stop requested successfully');
|
||
|
||
// Force immediate status update
|
||
setTimeout(() => {
|
||
this.updateStatus();
|
||
}, 100);
|
||
|
||
// Continue polling for a bit to catch the status change
|
||
this.startPolling(500); // Fast polling to catch status change
|
||
|
||
// Stop fast polling after 10 seconds
|
||
setTimeout(() => {
|
||
if (this.scanStatus === 'stopped' || this.scanStatus === 'idle') {
|
||
this.stopPolling();
|
||
}
|
||
}, 10000);
|
||
|
||
} else {
|
||
throw new Error(response.error || 'Failed to stop scan');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to stop scan:', error);
|
||
this.showError(`Failed to stop scan: ${error.message}`);
|
||
|
||
// Re-enable stop button on error
|
||
if (this.elements.stopScan) {
|
||
this.elements.stopScan.disabled = false;
|
||
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOP]</span><span>Terminate Scan</span>';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Export scan results
|
||
*/
|
||
async exportResults() {
|
||
try {
|
||
console.log('Exporting results...');
|
||
|
||
// Create a temporary link to trigger download
|
||
const link = document.createElement('a');
|
||
link.href = '/api/export';
|
||
link.download = ''; // Let server determine filename
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
this.showSuccess('Results export initiated');
|
||
console.log('Results export initiated');
|
||
|
||
} catch (error) {
|
||
console.error('Failed to export results:', error);
|
||
this.showError(`Failed to export results: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start polling for scan updates with configurable interval
|
||
*/
|
||
startPolling(interval = 2000) {
|
||
console.log('=== STARTING POLLING ===');
|
||
|
||
if (this.pollInterval) {
|
||
console.log('Clearing existing poll interval');
|
||
clearInterval(this.pollInterval);
|
||
}
|
||
|
||
this.pollInterval = setInterval(() => {
|
||
console.log('--- Polling tick ---');
|
||
this.updateStatus();
|
||
this.updateGraph();
|
||
this.loadProviders();
|
||
}, interval);
|
||
|
||
console.log(`Polling started with ${interval}ms interval`);
|
||
}
|
||
|
||
/**
|
||
* Stop polling for updates
|
||
*/
|
||
stopPolling() {
|
||
console.log('=== STOPPING POLLING ===');
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Status update with better error handling
|
||
*/
|
||
async updateStatus() {
|
||
try {
|
||
console.log('Updating status...');
|
||
const response = await this.apiCall('/api/scan/status');
|
||
|
||
console.log('Status response:', response);
|
||
|
||
if (response.success && response.status) {
|
||
const status = response.status;
|
||
console.log('Current scan status:', status.status);
|
||
console.log('Current progress:', status.progress_percentage + '%');
|
||
console.log('Graph stats:', status.graph_statistics);
|
||
|
||
this.updateStatusDisplay(status);
|
||
|
||
// Handle status changes
|
||
if (status.status !== this.scanStatus) {
|
||
console.log(`*** STATUS CHANGED: ${this.scanStatus} -> ${status.status} ***`);
|
||
this.handleStatusChange(status.status, status.task_queue_size);
|
||
}
|
||
|
||
this.scanStatus = status.status;
|
||
} else {
|
||
console.error('Status update failed:', response);
|
||
// Don't show error for status updates to avoid spam
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to update status:', error);
|
||
this.showConnectionError();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update graph from server
|
||
*/
|
||
async updateGraph() {
|
||
try {
|
||
console.log('Updating graph...');
|
||
const response = await this.apiCall('/api/graph');
|
||
|
||
console.log('Graph response:', response);
|
||
|
||
if (response.success) {
|
||
const graphData = response.graph;
|
||
|
||
console.log('Graph data received:');
|
||
console.log('- Nodes:', graphData.nodes ? graphData.nodes.length : 0);
|
||
console.log('- Edges:', graphData.edges ? graphData.edges.length : 0);
|
||
|
||
if (graphData.nodes) {
|
||
graphData.nodes.forEach(node => {
|
||
console.log(` Node: ${node.id} (${node.type})`);
|
||
});
|
||
}
|
||
|
||
if (graphData.edges) {
|
||
graphData.edges.forEach(edge => {
|
||
console.log(` Edge: ${edge.from} -> ${edge.to} (${edge.label})`);
|
||
});
|
||
}
|
||
|
||
// Only update if data has changed
|
||
if (this.hasGraphChanged(graphData)) {
|
||
console.log('*** GRAPH DATA CHANGED - UPDATING VISUALIZATION ***');
|
||
this.graphManager.updateGraph(graphData);
|
||
this.lastGraphUpdate = Date.now();
|
||
|
||
// Update relationship count in status
|
||
const edgeCount = graphData.edges ? graphData.edges.length : 0;
|
||
if (this.elements.relationshipsDisplay) {
|
||
this.elements.relationshipsDisplay.textContent = edgeCount;
|
||
}
|
||
} else {
|
||
console.log('Graph data unchanged, skipping update');
|
||
}
|
||
} else {
|
||
console.error('Graph update failed:', response);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to update graph:', error);
|
||
// Don't show error for graph updates to avoid spam
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update status display elements
|
||
* @param {Object} status - Status object from server
|
||
*/
|
||
updateStatusDisplay(status) {
|
||
try {
|
||
console.log('Updating status display...');
|
||
|
||
// Update status text with animation
|
||
if (this.elements.scanStatus) {
|
||
const formattedStatus = this.formatStatus(status.status);
|
||
if (this.elements.scanStatus.textContent !== formattedStatus) {
|
||
this.elements.scanStatus.textContent = formattedStatus;
|
||
this.elements.scanStatus.classList.add('fade-in');
|
||
setTimeout(() => this.elements.scanStatus.classList.remove('fade-in'), 300);
|
||
}
|
||
|
||
// Add status-specific classes for styling
|
||
this.elements.scanStatus.className = `status-value status-${status.status}`;
|
||
}
|
||
|
||
if (this.elements.targetDisplay) {
|
||
this.elements.targetDisplay.textContent = status.target_domain || 'None';
|
||
}
|
||
if (this.elements.depthDisplay) {
|
||
this.elements.depthDisplay.textContent = `${status.current_depth}/${status.max_depth}`;
|
||
}
|
||
|
||
// Update progress bar and compact display
|
||
if (this.elements.progressFill) {
|
||
const completed = status.indicators_completed || 0;
|
||
const totalTasks = status.total_tasks_ever_enqueued || 0;
|
||
const progressPercentage = status.progress_percentage || 0;
|
||
|
||
this.elements.progressFill.style.width = `${progressPercentage}%`;
|
||
if (this.elements.progressCompact) {
|
||
this.elements.progressCompact.textContent = `${completed}/${totalTasks} - ${Math.round(progressPercentage)}%`;
|
||
}
|
||
|
||
// Add pulsing animation for active scans
|
||
if (status.status === 'running') {
|
||
this.elements.progressFill.parentElement.classList.add('scanning');
|
||
} else {
|
||
this.elements.progressFill.parentElement.classList.remove('scanning');
|
||
}
|
||
}
|
||
|
||
// Update session ID display with user session info
|
||
if (this.elements.sessionId) {
|
||
const scanSessionId = this.currentSessionId;
|
||
const userSessionId = status.user_session_id;
|
||
|
||
if (scanSessionId && userSessionId) {
|
||
this.elements.sessionId.textContent = `Session: ${userSessionId.substring(0, 8)}... | Scan: ${scanSessionId}`;
|
||
} else if (userSessionId) {
|
||
this.elements.sessionId.textContent = `User Session: ${userSessionId.substring(0, 8)}...`;
|
||
} else {
|
||
this.elements.sessionId.textContent = 'Session: Loading...';
|
||
}
|
||
}
|
||
|
||
this.setUIState(status.status, status.task_queue_size);
|
||
|
||
console.log('Status display updated successfully');
|
||
} catch (error) {
|
||
console.error('Error updating status display:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle status changes with improved state synchronization
|
||
* @param {string} newStatus - New scan status
|
||
*/
|
||
handleStatusChange(newStatus, task_queue_size) {
|
||
console.log(`=== STATUS CHANGE: ${this.scanStatus} -> ${newStatus} ===`);
|
||
|
||
switch (newStatus) {
|
||
case 'running':
|
||
this.setUIState('scanning', task_queue_size);
|
||
this.showSuccess('Scan is running');
|
||
// Increase polling frequency for active scans
|
||
this.startPolling(1000); // Poll every 1 second for running scans
|
||
this.updateConnectionStatus('active');
|
||
break;
|
||
|
||
case 'completed':
|
||
this.setUIState('completed', task_queue_size);
|
||
this.stopPolling();
|
||
this.showSuccess('Scan completed successfully');
|
||
this.updateConnectionStatus('completed');
|
||
this.loadProviders();
|
||
// Force a final graph update
|
||
console.log('Scan completed - forcing final graph update');
|
||
setTimeout(() => this.updateGraph(), 100);
|
||
break;
|
||
|
||
case 'failed':
|
||
this.setUIState('failed', task_queue_size);
|
||
this.stopPolling();
|
||
this.showError('Scan failed');
|
||
this.updateConnectionStatus('error');
|
||
this.loadProviders();
|
||
break;
|
||
|
||
case 'stopped':
|
||
this.setUIState('stopped', task_queue_size);
|
||
this.stopPolling();
|
||
this.showSuccess('Scan stopped');
|
||
this.updateConnectionStatus('stopped');
|
||
this.loadProviders();
|
||
break;
|
||
|
||
case 'idle':
|
||
this.setUIState('idle', task_queue_size);
|
||
this.stopPolling();
|
||
this.updateConnectionStatus('idle');
|
||
break;
|
||
|
||
default:
|
||
console.warn(`Unknown status: ${newStatus}`);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update connection status indicator
|
||
* @param {string} status - Connection status
|
||
*/
|
||
updateConnectionStatus(status) {
|
||
if (!this.elements.connectionStatus) return;
|
||
|
||
const statusColors = {
|
||
'idle': '#c7c7c7',
|
||
'active': '#00ff41',
|
||
'completed': '#00aa2e',
|
||
'stopped': '#ff9900',
|
||
'error': '#ff6b6b'
|
||
};
|
||
|
||
this.elements.connectionStatus.style.backgroundColor = statusColors[status] || '#c7c7c7';
|
||
|
||
const statusText = this.elements.connectionStatus.parentElement?.querySelector('.status-text');
|
||
if (statusText) {
|
||
const statusTexts = {
|
||
'idle': 'System Ready',
|
||
'active': 'Scanning Active',
|
||
'completed': 'Scan Complete',
|
||
'stopped': 'Scan Stopped',
|
||
'error': 'Connection Error'
|
||
};
|
||
|
||
statusText.textContent = statusTexts[status] || 'System Online';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* UI state management with immediate button updates
|
||
*/
|
||
setUIState(state, task_queue_size) {
|
||
console.log(`Setting UI state to: ${state}`);
|
||
|
||
const isQueueEmpty = task_queue_size === 0;
|
||
|
||
switch (state) {
|
||
case 'scanning':
|
||
this.isScanning = true;
|
||
if (this.graphManager) {
|
||
this.graphManager.isScanning = true;
|
||
}
|
||
if (this.elements.startScan) {
|
||
this.elements.startScan.disabled = true;
|
||
this.elements.startScan.classList.add('loading');
|
||
this.elements.startScan.innerHTML = '<span class="btn-icon">[SCANNING]</span><span>Scanning...</span>';
|
||
}
|
||
if (this.elements.addToGraph) {
|
||
this.elements.addToGraph.disabled = true;
|
||
this.elements.addToGraph.classList.add('loading');
|
||
}
|
||
if (this.elements.stopScan) {
|
||
this.elements.stopScan.disabled = false;
|
||
this.elements.stopScan.classList.remove('loading');
|
||
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOP]</span><span>Terminate Scan</span>';
|
||
}
|
||
if (this.elements.targetInput) this.elements.targetInput.disabled = true;
|
||
if (this.elements.maxDepth) this.elements.maxDepth.disabled = true;
|
||
if (this.elements.configureSettings) this.elements.configureSettings.disabled = true;
|
||
break;
|
||
|
||
case 'idle':
|
||
case 'completed':
|
||
case 'failed':
|
||
case 'stopped':
|
||
this.isScanning = false;
|
||
if (this.graphManager) {
|
||
this.graphManager.isScanning = false;
|
||
}
|
||
if (this.elements.startScan) {
|
||
this.elements.startScan.disabled = !isQueueEmpty;
|
||
this.elements.startScan.classList.remove('loading');
|
||
this.elements.startScan.innerHTML = '<span class="btn-icon">[RUN]</span><span>Start Reconnaissance</span>';
|
||
}
|
||
if (this.elements.addToGraph) {
|
||
this.elements.addToGraph.disabled = !isQueueEmpty;
|
||
this.elements.addToGraph.classList.remove('loading');
|
||
}
|
||
if (this.elements.stopScan) {
|
||
this.elements.stopScan.disabled = true;
|
||
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOP]</span><span>Terminate Scan</span>';
|
||
}
|
||
if (this.elements.targetInput) this.elements.targetInput.disabled = false;
|
||
if (this.elements.maxDepth) this.elements.maxDepth.disabled = false;
|
||
if (this.elements.configureSettings) this.elements.configureSettings.disabled = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load provider information
|
||
*/
|
||
async loadProviders() {
|
||
try {
|
||
console.log('Loading providers...');
|
||
const response = await this.apiCall('/api/providers');
|
||
|
||
if (response.success) {
|
||
this.updateProviderDisplay(response.providers);
|
||
this.buildApiKeyModal(response.providers);
|
||
console.log('Providers loaded successfully');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Failed to load providers:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update provider display
|
||
* @param {Object} providers - Provider information
|
||
*/
|
||
updateProviderDisplay(providers) {
|
||
if (!this.elements.providerList) return;
|
||
|
||
this.elements.providerList.innerHTML = '';
|
||
|
||
for (const [name, info] of Object.entries(providers)) {
|
||
const providerItem = document.createElement('div');
|
||
providerItem.className = 'provider-item fade-in';
|
||
|
||
let statusClass = 'disabled';
|
||
let statusText = 'Disabled';
|
||
|
||
if (info.enabled) {
|
||
statusClass = 'enabled';
|
||
statusText = 'Enabled';
|
||
} else if (info.requires_api_key) {
|
||
statusClass = 'api-key-required';
|
||
statusText = 'API Key Required';
|
||
}
|
||
|
||
providerItem.innerHTML = `
|
||
<div class="provider-header">
|
||
<div class="provider-name">${info.display_name}</div>
|
||
<div class="provider-status ${statusClass}">${statusText}</div>
|
||
</div>
|
||
<div class="provider-stats">
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label">Requests:</span>
|
||
<span class="provider-stat-value">${info.statistics.total_requests || 0}</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label">Success Rate:</span>
|
||
<span class="provider-stat-value">${(info.statistics.success_rate || 0).toFixed(1)}%</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label">Relationships:</span>
|
||
<span class="provider-stat-value">${info.statistics.relationships_found || 0}</span>
|
||
</div>
|
||
<div class="provider-stat">
|
||
<span class="provider-stat-label">Rate Limit:</span>
|
||
<span class="provider-stat-value">${info.rate_limit}/min</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.elements.providerList.appendChild(providerItem);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enhanced node details HTML generation with better visual hierarchy
|
||
* File: static/js/main.js (replace generateNodeDetailsHtml method)
|
||
*/
|
||
|
||
generateNodeDetailsHtml(node) {
|
||
if (!node) return '<div class="detail-row"><span class="detail-value">Details not available.</span></div>';
|
||
|
||
let detailsHtml = '<div class="modal-details">';
|
||
|
||
// Node Header - compact
|
||
detailsHtml += `
|
||
<div class="node-header">
|
||
<div class="node-type-badge node-type-${node.type}">${this.formatStatus(node.type)}</div>
|
||
<div class="node-id-display">${node.id}</div>
|
||
</div>
|
||
`;
|
||
|
||
// Quick Stats Bar - compact
|
||
const incomingCount = node.incoming_edges ? node.incoming_edges.length : 0;
|
||
const outgoingCount = node.outgoing_edges ? node.outgoing_edges.length : 0;
|
||
|
||
detailsHtml += `
|
||
<div class="quick-stats">
|
||
<div class="stat-item">
|
||
<span class="stat-icon">←</span>
|
||
<span class="stat-value">${incomingCount}</span>
|
||
<span class="stat-label">In</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-icon">→</span>
|
||
<span class="stat-value">${outgoingCount}</span>
|
||
<span class="stat-label">Out</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Handle different node types with collapsible sections
|
||
if (node.type === 'correlation_object') {
|
||
detailsHtml += this.generateCorrelationDetails(node);
|
||
} else if (node.type === 'large_entity') {
|
||
detailsHtml += this.generateLargeEntityDetails(node);
|
||
} else {
|
||
detailsHtml += this.generateStandardNodeDetails(node);
|
||
}
|
||
|
||
detailsHtml += '</div>';
|
||
return detailsHtml;
|
||
}
|
||
|
||
generateStandardNodeDetails(node) {
|
||
let html = '';
|
||
|
||
// Relationships sections
|
||
html += this.generateRelationshipsSection(node);
|
||
|
||
// Enhanced attributes section with special certificate handling
|
||
if (node.attributes && Object.keys(node.attributes).length > 0) {
|
||
const { certificates, ...otherAttributes } = node.attributes;
|
||
|
||
// Handle certificates separately with enhanced display
|
||
if (certificates) {
|
||
html += this.generateCertificateSection({ certificates });
|
||
}
|
||
|
||
// Handle other attributes normally
|
||
if (Object.keys(otherAttributes).length > 0) {
|
||
html += this.generateAttributesSection(otherAttributes);
|
||
}
|
||
}
|
||
|
||
// Description section
|
||
html += this.generateDescriptionSection(node);
|
||
|
||
// Metadata section (collapsed by default)
|
||
html += this.generateMetadataSection(node);
|
||
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Enhanced certificate section generation using existing styles
|
||
*/
|
||
generateCertificateSection(attributes) {
|
||
const certificates = attributes.certificates;
|
||
if (!certificates || typeof certificates !== 'object') {
|
||
return '';
|
||
}
|
||
|
||
let html = `
|
||
<div class="modal-section">
|
||
<details>
|
||
<summary>🔒 SSL/TLS Certificates</summary>
|
||
<div class="modal-section-content">
|
||
`;
|
||
|
||
// Certificate summary using existing grid pattern
|
||
html += this.generateCertificateSummary(certificates);
|
||
|
||
// Latest certificate info using existing attribute display
|
||
if (certificates.latest_certificate) {
|
||
html += this.generateLatestCertificateInfo(certificates.latest_certificate);
|
||
}
|
||
|
||
// Detailed certificate list if available
|
||
if (certificates.certificate_details && Array.isArray(certificates.certificate_details)) {
|
||
html += this.generateCertificateList(certificates.certificate_details);
|
||
}
|
||
|
||
html += '</div></details></div>';
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Generate latest certificate info using existing attribute list
|
||
*/
|
||
generateLatestCertificateInfo(latest) {
|
||
const isValid = latest.is_currently_valid;
|
||
const statusText = isValid ? 'Valid' : 'Invalid/Expired';
|
||
const statusColor = isValid ? '#00ff41' : '#ff6b6b';
|
||
|
||
let html = `
|
||
<div style="margin-bottom: 1rem; padding: 0.75rem; background: rgba(255, 255, 255, 0.02); border-radius: 4px; border: 1px solid #333;">
|
||
<h5 style="margin: 0 0 0.5rem 0; color: #00ff41; font-size: 0.9rem;">Most Recent Certificate</h5>
|
||
<div class="attribute-list">
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Status:</span>
|
||
<span class="attribute-value-compact" style="color: ${statusColor}; font-weight: 600;">${statusText}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Issued:</span>
|
||
<span class="attribute-value-compact">${latest.not_before || 'Unknown'}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Expires:</span>
|
||
<span class="attribute-value-compact">${latest.not_after || 'Unknown'}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Issuer:</span>
|
||
<span class="attribute-value-compact">${this.escapeHtml(latest.issuer_name || 'Unknown')}</span>
|
||
</div>
|
||
${latest.certificate_id ? `
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Certificate:</span>
|
||
<span class="attribute-value-compact">
|
||
<a href="https://crt.sh/?id=${latest.certificate_id}" target="_blank" class="cert-link">
|
||
View on crt.sh ↗
|
||
</a>
|
||
</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Generate certificate list using existing collapsible structure
|
||
*/
|
||
generateCertificateList(certificateDetails) {
|
||
if (!certificateDetails || certificateDetails.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
// Limit display to prevent overwhelming the UI
|
||
const maxDisplay = 8;
|
||
const certificates = certificateDetails.slice(0, maxDisplay);
|
||
const remaining = certificateDetails.length - maxDisplay;
|
||
|
||
let html = `
|
||
<details style="margin-top: 1rem;">
|
||
<summary>📋 Certificate Details (${certificates.length}${remaining > 0 ? ` of ${certificateDetails.length}` : ''})</summary>
|
||
<div style="margin-top: 0.75rem;">
|
||
`;
|
||
|
||
certificates.forEach((cert, index) => {
|
||
const isValid = cert.is_currently_valid;
|
||
let statusText = isValid ? '✅ Valid' : '❌ Invalid/Expired';
|
||
let statusColor = isValid ? '#00ff41' : '#ff6b6b';
|
||
|
||
if (cert.expires_soon && isValid) {
|
||
statusText = '⚠️ Valid (Expiring Soon)';
|
||
statusColor = '#ff9900';
|
||
}
|
||
|
||
html += `
|
||
<div style="margin-bottom: 0.75rem; padding: 0.75rem; background: rgba(255, 255, 255, 0.02); border: 1px solid #333; border-radius: 4px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; border-bottom: 1px solid #333; padding-bottom: 0.5rem;">
|
||
<span style="font-weight: 600; color: #999;">#${index + 1}</span>
|
||
<span style="color: ${statusColor}; font-size: 0.85rem; font-weight: 500;">${statusText}</span>
|
||
${cert.certificate_id ? `
|
||
<a href="https://crt.sh/?id=${cert.certificate_id}" target="_blank" class="cert-link">crt.sh ↗</a>
|
||
` : ''}
|
||
</div>
|
||
<div class="attribute-list">
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Common Name:</span>
|
||
<span class="attribute-value-compact">${this.escapeHtml(cert.common_name || 'N/A')}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Issuer:</span>
|
||
<span class="attribute-value-compact">${this.escapeHtml(cert.issuer_name || 'Unknown')}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Valid From:</span>
|
||
<span class="attribute-value-compact">${cert.not_before || 'Unknown'}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Valid Until:</span>
|
||
<span class="attribute-value-compact">${cert.not_after || 'Unknown'}</span>
|
||
</div>
|
||
${cert.validity_period_days ? `
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Period:</span>
|
||
<span class="attribute-value-compact">${cert.validity_period_days} days</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
if (remaining > 0) {
|
||
html += `
|
||
<div style="text-align: center; padding: 1rem; color: #ff9900; background: rgba(255, 153, 0, 0.1); border: 1px solid #ff9900; border-radius: 4px;">
|
||
📋 ${remaining} additional certificate${remaining > 1 ? 's' : ''} not shown.<br>
|
||
<small style="color: #999;">Use the export function to see all certificates.</small>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '</div></details>';
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Generate certificate summary using minimal new CSS
|
||
*/
|
||
generateCertificateSummary(certificates) {
|
||
const total = certificates.total_certificates || 0;
|
||
const valid = certificates.valid_certificates || 0;
|
||
const expired = certificates.expired_certificates || 0;
|
||
const expiringSoon = certificates.expires_soon_count || 0;
|
||
const issuers = certificates.unique_issuers || [];
|
||
|
||
let html = `
|
||
<div class="cert-summary-grid">
|
||
<div class="cert-stat-item">
|
||
<div class="cert-stat-value">${total}</div>
|
||
<div class="cert-stat-label">Total</div>
|
||
</div>
|
||
<div class="cert-stat-item">
|
||
<div class="cert-stat-value" style="color: #00ff41">${valid}</div>
|
||
<div class="cert-stat-label">Valid</div>
|
||
</div>
|
||
<div class="cert-stat-item">
|
||
<div class="cert-stat-value" style="color: #ff6b6b">${expired}</div>
|
||
<div class="cert-stat-label">Expired</div>
|
||
</div>
|
||
<div class="cert-stat-item">
|
||
<div class="cert-stat-value" style="color: #ff9900">${expiringSoon}</div>
|
||
<div class="cert-stat-label">Expiring Soon</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Certificate authorities using existing array display
|
||
if (issuers.length > 0) {
|
||
html += `
|
||
<div class="attribute-item-compact" style="margin-bottom: 1rem;">
|
||
<span class="attribute-key-compact">Certificate Authorities:</span>
|
||
<span class="attribute-value-compact">
|
||
<div class="array-display">
|
||
`;
|
||
|
||
issuers.forEach(issuer => {
|
||
html += `<div class="array-display-item">${this.escapeHtml(issuer)}</div>`;
|
||
});
|
||
|
||
html += '</div></span></div>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
generateLargeEntityDetails(node) {
|
||
const attributes = node.attributes || {};
|
||
const nodes = attributes.nodes || [];
|
||
const nodeType = attributes.node_type || 'nodes';
|
||
|
||
let html = `
|
||
<div class="modal-section">
|
||
<details open>
|
||
<summary>📦 Entity Summary</summary>
|
||
<div class="modal-section-content">
|
||
<div class="attribute-list">
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Contains:</span>
|
||
<span class="attribute-value-compact">${attributes.count} ${nodeType}s</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Provider:</span>
|
||
<span class="attribute-value-compact">${attributes.source_provider || 'Unknown'}</span>
|
||
</div>
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">Depth:</span>
|
||
<span class="attribute-value-compact">${attributes.discovery_depth || 'Unknown'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="modal-section">
|
||
<details open>
|
||
<summary>📋 Contained ${nodeType}s (${nodes.length})</summary>
|
||
<div class="modal-section-content">
|
||
<div class="relationship-compact">
|
||
`;
|
||
|
||
// Use node.id for the large_entity_id
|
||
const largeEntityId = node.id;
|
||
|
||
nodes.forEach(innerNodeId => {
|
||
html += `
|
||
<div class="relationship-compact-item">
|
||
<span class="node-link-compact" data-node-id="${innerNodeId}">${innerNodeId}</span>
|
||
<button class="btn-icon-small extract-node-btn"
|
||
title="Extract to graph"
|
||
data-large-entity-id="${largeEntityId}"
|
||
data-node-id="${innerNodeId}">[+]</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></details></div>';
|
||
|
||
return html;
|
||
}
|
||
|
||
generateCorrelationDetails(node) {
|
||
const metadata = node.metadata || {};
|
||
const values = metadata.values || [];
|
||
const sources = metadata.sources || [];
|
||
const mergeCount = metadata.merge_count || 1;
|
||
|
||
let html = '';
|
||
|
||
// Correlation values section with meaningful labels - reuses existing modal structure
|
||
html += `
|
||
<div class="modal-section">
|
||
<details open>
|
||
<summary>
|
||
<span>🔗 Correlation Values</span>
|
||
<span class="merge-badge">${mergeCount} value${mergeCount > 1 ? 's' : ''}</span>
|
||
</summary>
|
||
<div class="modal-section-content">
|
||
<div class="attribute-list">
|
||
`;
|
||
|
||
// Create a map of values to their source attributes for better labeling
|
||
const valueSourceMap = this.createValueSourceMap(values, sources);
|
||
|
||
values.forEach((value, index) => {
|
||
const sourceInfo = valueSourceMap[index] || {};
|
||
const attributeName = sourceInfo.meaningfulName || `Value ${index + 1}`;
|
||
const sourceDetails = sourceInfo.details || '';
|
||
|
||
html += `
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">
|
||
<span class="correlation-attr-name">${this.escapeHtml(attributeName)}</span>
|
||
${sourceDetails ? `<span class="correlation-hint" title="${this.escapeHtml(sourceDetails)}"> ℹ️</span>` : ''}
|
||
</span>
|
||
<span class="attribute-value-compact">
|
||
<code>${this.escapeHtml(String(value))}</code>
|
||
</span>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></details></div>';
|
||
|
||
// Correlated nodes section - reuses existing relationship display
|
||
const correlatedNodes = metadata.correlated_nodes || [];
|
||
if (correlatedNodes.length > 0) {
|
||
html += `
|
||
<div class="modal-section">
|
||
<details>
|
||
<summary>🌐 Correlated Nodes (${correlatedNodes.length})</summary>
|
||
<div class="modal-section-content">
|
||
<div class="relationship-compact">
|
||
`;
|
||
|
||
correlatedNodes.forEach(nodeId => {
|
||
html += `
|
||
<div class="relationship-compact-item">
|
||
<span class="node-link-compact" data-node-id="${nodeId}">${nodeId}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></details></div>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* Create a mapping of values to their source attribute information
|
||
*/
|
||
createValueSourceMap(values, sources) {
|
||
const valueSourceMap = {};
|
||
|
||
// Group sources by their meaningful attributes
|
||
const attrGroups = {};
|
||
sources.forEach(source => {
|
||
const meaningfulAttr = source.meaningful_attr || source.parent_attr || 'correlation';
|
||
|
||
if (!attrGroups[meaningfulAttr]) {
|
||
attrGroups[meaningfulAttr] = {
|
||
nodeIds: [],
|
||
paths: []
|
||
};
|
||
}
|
||
attrGroups[meaningfulAttr].nodeIds.push(source.node_id);
|
||
attrGroups[meaningfulAttr].paths.push(source.path || '');
|
||
});
|
||
|
||
// Map values to their best attribute names
|
||
values.forEach((value, index) => {
|
||
// Find the most meaningful attribute name
|
||
const attrNames = Object.keys(attrGroups);
|
||
const bestAttr = attrNames.find(attr => attr !== 'correlation' && attr !== 'unknown') || attrNames[0] || 'correlation';
|
||
|
||
if (attrGroups[bestAttr]) {
|
||
valueSourceMap[index] = {
|
||
meaningfulName: bestAttr,
|
||
details: `Found in: ${[...new Set(attrGroups[bestAttr].nodeIds)].join(', ')}`
|
||
};
|
||
}
|
||
});
|
||
|
||
return valueSourceMap;
|
||
}
|
||
|
||
generateCorrelationObjectLayout(node) {
|
||
const metadata = node.metadata || {};
|
||
const values = metadata.values || [];
|
||
const mergeCount = metadata.merge_count || 1;
|
||
|
||
let html = '<div class="correlation-layout">';
|
||
|
||
if (mergeCount > 1) {
|
||
html += `
|
||
<div class="section-card correlation-summary">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">🔗</span>Merged Correlations</h4>
|
||
<div class="merge-badge">${mergeCount} values</div>
|
||
</div>
|
||
<div class="correlation-grid">
|
||
`;
|
||
|
||
values.forEach((value, index) => {
|
||
const displayValue = typeof value === 'string' && value.length > 50 ?
|
||
value.substring(0, 47) + '...' : value;
|
||
|
||
html += `
|
||
<div class="correlation-item" data-index="${index}">
|
||
<div class="correlation-preview">${displayValue}</div>
|
||
<button class="expand-btn" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<span class="expand-icon">▼</span>
|
||
</button>
|
||
<div class="correlation-full hidden">${value}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div>';
|
||
} else {
|
||
const singleValue = values.length > 0 ? values[0] : (metadata.value || 'Unknown');
|
||
html += `
|
||
<div class="section-card">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">🔗</span>Correlation Value</h4>
|
||
</div>
|
||
<div class="correlation-value-display">${singleValue}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Show correlated nodes
|
||
const correlatedNodes = metadata.correlated_nodes || [];
|
||
if (correlatedNodes.length > 0) {
|
||
html += `
|
||
<div class="section-card">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">🌐</span>Correlated Nodes</h4>
|
||
<div class="count-badge">${correlatedNodes.length}</div>
|
||
</div>
|
||
<div class="node-list">
|
||
`;
|
||
|
||
correlatedNodes.forEach(nodeId => {
|
||
html += `
|
||
<div class="node-link-item" data-node-id="${nodeId}">
|
||
<span class="node-icon">●</span>
|
||
<span class="node-name">${nodeId}</span>
|
||
<button class="navigate-btn" onclick="this.click()">→</button>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
generateLargeEntityLayout(node) {
|
||
const attributes = node.attributes || {};
|
||
const nodes = attributes.nodes || [];
|
||
const nodeType = attributes.node_type || 'nodes';
|
||
|
||
let html = `
|
||
<div class="large-entity-layout">
|
||
<div class="section-card entity-summary">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">📦</span>Large Entity Container</h4>
|
||
<div class="entity-badge">${attributes.count} ${nodeType}s</div>
|
||
</div>
|
||
<div class="entity-stats">
|
||
<div class="stat-row">
|
||
<span class="stat-label">Source Provider:</span>
|
||
<span class="stat-value">${attributes.source_provider || 'Unknown'}</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">Discovery Depth:</span>
|
||
<span class="stat-value">${attributes.discovery_depth || 'Unknown'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section-card entity-contents">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">📋</span>Contained ${nodeType}s</h4>
|
||
<button class="toggle-all-btn" onclick="this.toggleAllEntities()">Expand All</button>
|
||
</div>
|
||
<div class="entity-node-grid">
|
||
`;
|
||
|
||
nodes.forEach((innerNodeId, index) => {
|
||
const innerNode = this.graphManager.nodes.get(innerNodeId);
|
||
html += `
|
||
<div class="entity-node-card" data-node-id="${innerNodeId}">
|
||
<div class="entity-node-header" onclick="this.parentElement.classList.toggle('expanded')">
|
||
<span class="node-icon">●</span>
|
||
<span class="node-name">${innerNodeId}</span>
|
||
<span class="expand-indicator">▼</span>
|
||
</div>
|
||
<div class="entity-node-details">
|
||
${innerNode ? this.generateStandardNodeLayout(innerNode) : '<div class="no-details">No details available</div>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></div>';
|
||
return html;
|
||
}
|
||
|
||
generateStandardNodeLayout(node) {
|
||
let html = '<div class="standard-node-layout">';
|
||
|
||
// Relationships section
|
||
html += this.generateRelationshipsSection(node);
|
||
|
||
// Attributes section with smart categorization
|
||
html += this.generateAttributesSection(node);
|
||
|
||
// Description section
|
||
html += this.generateDescriptionSection(node);
|
||
|
||
// Metadata section (collapsed by default)
|
||
html += this.generateMetadataSection(node);
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
generateRelationshipsSection(node) {
|
||
let html = '';
|
||
|
||
if (node.incoming_edges && node.incoming_edges.length > 0) {
|
||
html += `
|
||
<div class="modal-section">
|
||
<details>
|
||
<summary>⬅️ Source Relationships (${node.incoming_edges.length})</summary>
|
||
<div class="modal-section-content">
|
||
<div class="relationship-list">
|
||
`;
|
||
|
||
node.incoming_edges.forEach(edge => {
|
||
const confidence = edge.data.confidence_score || 0;
|
||
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
|
||
|
||
html += `
|
||
<div class="relationship-item">
|
||
<div class="relationship-source node-link" data-node-id="${edge.from}">
|
||
${edge.from}
|
||
</div>
|
||
<div class="relationship-type">
|
||
<span class="relation-label">${edge.data.relationship_type}</span>
|
||
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
|
||
${'●'.repeat(Math.ceil(confidence * 3))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></details></div>';
|
||
}
|
||
|
||
if (node.outgoing_edges && node.outgoing_edges.length > 0) {
|
||
html += `
|
||
<div class="modal-section">
|
||
<details>
|
||
<summary>➡️ Target Relationships (${node.outgoing_edges.length})</summary>
|
||
<div class="modal-section-content">
|
||
<div class="relationship-list">
|
||
`;
|
||
|
||
node.outgoing_edges.forEach(edge => {
|
||
const confidence = edge.data.confidence_score || 0;
|
||
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
|
||
|
||
html += `
|
||
<div class="relationship-item">
|
||
<div class="relationship-target node-link" data-node-id="${edge.to}">
|
||
${edge.to}
|
||
</div>
|
||
<div class="relationship-type">
|
||
<span class="relation-label">${edge.data.relationship_type}</span>
|
||
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
|
||
${'●'.repeat(Math.ceil(confidence * 3))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div></div></details></div>';
|
||
}
|
||
|
||
return html;
|
||
}
|
||
|
||
generateAttributesSection(attributes) {
|
||
const categorized = this.categorizeAttributes(attributes);
|
||
let html = '';
|
||
|
||
Object.entries(categorized).forEach(([category, attrs]) => {
|
||
if (Object.keys(attrs).length === 0) return;
|
||
|
||
html += `
|
||
<div class="modal-section">
|
||
<details>
|
||
<summary>📊 ${category}</summary>
|
||
<div class="modal-section-content">
|
||
`;
|
||
|
||
if (category === 'Certificates' && attrs.certificates) {
|
||
html += this.formatCertificateData(attrs.certificates);
|
||
} else {
|
||
html += '<div class="attribute-list">';
|
||
Object.entries(attrs).forEach(([key, value]) => {
|
||
html += `
|
||
<div class="attribute-item-compact">
|
||
<span class="attribute-key-compact">${this.formatLabel(key)}</span>
|
||
<span class="attribute-value-compact">${this.formatAttributeValue(value)}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div></details></div>';
|
||
});
|
||
|
||
return html;
|
||
}
|
||
|
||
formatCertificateData(certData) {
|
||
if (!certData || typeof certData !== 'object') {
|
||
return '<p>No certificate data available</p>';
|
||
}
|
||
|
||
let html = '<div class="certificate-list">';
|
||
|
||
// Handle certificate summary
|
||
if (certData.total_certificates) {
|
||
html += `
|
||
<div class="certificate-item">
|
||
<div class="certificate-summary">
|
||
<span>Total Certificates: ${certData.total_certificates}</span>
|
||
<span class="certificate-status ${certData.has_valid_cert ? 'valid' : 'invalid'}">
|
||
${certData.has_valid_cert ? 'Valid' : 'Invalid'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Handle unique issuers
|
||
if (certData.unique_issuers && Array.isArray(certData.unique_issuers)) {
|
||
html += `
|
||
<div class="certificate-item">
|
||
<div class="certificate-summary">
|
||
<span>Issuers:</span>
|
||
</div>
|
||
<div class="array-display">
|
||
`;
|
||
certData.unique_issuers.forEach(issuer => {
|
||
html += `<div class="array-display-item">${this.escapeHtml(String(issuer))}</div>`;
|
||
});
|
||
html += '</div></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
formatAttributeValue(value) {
|
||
if (value === null || value === undefined) {
|
||
return '<em>None</em>';
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
if (value.length === 0) return '<em>None</em>';
|
||
if (value.length === 1) return this.escapeHtml(String(value[0]));
|
||
|
||
let html = '<div class="array-display">';
|
||
value.forEach((item, index) => {
|
||
html += `<div class="array-display-item">${this.escapeHtml(String(item))}</div>`;
|
||
});
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
if (typeof value === 'object' && value !== null) {
|
||
return `<div class="object-display">${this.formatObjectCompact(value)}</div>`;
|
||
}
|
||
|
||
return this.escapeHtml(String(value));
|
||
}
|
||
|
||
|
||
categorizeAttributes(attributes) {
|
||
const categories = {
|
||
'DNS Records': {},
|
||
'Certificates': {},
|
||
'Network Info': {},
|
||
'Provider Data': {},
|
||
'Other': {}
|
||
};
|
||
|
||
for (const [key, value] of Object.entries(attributes)) {
|
||
const lowerKey = key.toLowerCase();
|
||
|
||
if (lowerKey.includes('dns') || lowerKey.includes('record') || key.endsWith('_record')) {
|
||
categories['DNS Records'][key] = value;
|
||
} else if (lowerKey.includes('cert') || lowerKey.includes('ssl') || lowerKey.includes('tls')) {
|
||
categories['Certificates'][key] = value;
|
||
} else if (lowerKey.includes('ip') || lowerKey.includes('asn') || lowerKey.includes('network')) {
|
||
categories['Network Info'][key] = value;
|
||
} else if (lowerKey.includes('shodan') || lowerKey.includes('crtsh') || lowerKey.includes('provider')) {
|
||
categories['Provider Data'][key] = value;
|
||
} else {
|
||
categories['Other'][key] = value;
|
||
}
|
||
}
|
||
|
||
return categories;
|
||
}
|
||
|
||
formatObjectCompact(obj) {
|
||
if (!obj || typeof obj !== 'object') return '';
|
||
|
||
let html = '';
|
||
const entries = Object.entries(obj);
|
||
|
||
entries.forEach(([key, value]) => {
|
||
html += `<div><strong>${key}:</strong> `;
|
||
if (typeof value === 'object' && value !== null) {
|
||
if (Array.isArray(value)) {
|
||
html += `[${value.length} items]`;
|
||
} else {
|
||
html += `{${Object.keys(value).length} properties}`;
|
||
}
|
||
} else {
|
||
html += this.escapeHtml(String(value));
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
return html;
|
||
}
|
||
|
||
generateDescriptionSection(node) {
|
||
if (!node.description) return '';
|
||
|
||
return `
|
||
<div class="section-card description-section">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">📝</span>Description</h4>
|
||
</div>
|
||
<div class="description-content">
|
||
${this.escapeHtml(node.description)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
generateMetadataSection(node) {
|
||
if (!node.metadata || Object.keys(node.metadata).length === 0) return '';
|
||
|
||
return `
|
||
<div class="section-card metadata-section collapsed">
|
||
<div class="section-header">
|
||
<h4><span class="section-icon">🔧</span>Technical Metadata</h4>
|
||
<button class="toggle-section-btn" onclick="this.parentElement.parentElement.classList.toggle('collapsed')">
|
||
<span class="toggle-icon">▼</span>
|
||
</button>
|
||
</div>
|
||
<div class="metadata-content">
|
||
${this.formatObjectToHtml(node.metadata)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
/**
|
||
* Recursively formats a JavaScript object into an HTML unordered list with collapsible sections.
|
||
* @param {Object} obj - The object to format.
|
||
* @returns {string} - An HTML string representing the object.
|
||
*/
|
||
formatObjectToHtml(obj) {
|
||
if (!obj || Object.keys(obj).length === 0) {
|
||
return '<p class="no-data">No data available.</p>';
|
||
}
|
||
|
||
let html = '<ul>';
|
||
for (const key in obj) {
|
||
if (Object.hasOwnProperty.call(obj, key)) {
|
||
const value = obj[key];
|
||
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||
|
||
if (typeof value === 'object' && value !== null) {
|
||
html += `<li><details><summary><strong>${formattedKey}</strong></summary>`;
|
||
html += this.formatObjectToHtml(value);
|
||
html += `</details></li>`;
|
||
} else {
|
||
html += `<li><strong>${formattedKey}:</strong> ${this.formatValue(value)}</li>`;
|
||
}
|
||
}
|
||
}
|
||
html += '</ul>';
|
||
return html;
|
||
}
|
||
|
||
|
||
/**
|
||
* Enhanced showNodeModal with better event handling
|
||
*/
|
||
showNodeModal(node) {
|
||
if (!this.elements.nodeModal || !node) return;
|
||
|
||
if (this.elements.modalTitle) {
|
||
this.elements.modalTitle.innerHTML = `
|
||
<span class="modal-title-icon">${this.getNodeTypeIcon(node.type)}</span>
|
||
<span class="modal-title-text">${node.id}</span>
|
||
`;
|
||
}
|
||
|
||
const detailsHtml = this.generateNodeDetailsHtml(node);
|
||
|
||
if (this.elements.modalDetails) {
|
||
this.elements.modalDetails.innerHTML = detailsHtml;
|
||
|
||
// Add enhanced event handlers
|
||
this.addModalEventHandlers();
|
||
}
|
||
|
||
this.elements.nodeModal.style.display = 'block';
|
||
}
|
||
|
||
/**
|
||
* Add event handlers for enhanced modal interactions
|
||
*/
|
||
addModalEventHandlers() {
|
||
// Handle node navigation links - FIXED to work properly
|
||
this.elements.modalDetails.querySelectorAll('.node-link-compact').forEach(link => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const nodeId = e.target.dataset.nodeId || e.target.getAttribute('data-node-id');
|
||
console.log('Node link clicked:', nodeId);
|
||
|
||
if (nodeId && this.graphManager && this.graphManager.nodes) {
|
||
const nextNode = this.graphManager.nodes.get(nodeId);
|
||
if (nextNode) {
|
||
console.log('Navigating to node:', nextNode);
|
||
// Don't hide modal, just update content
|
||
this.showNodeModal(nextNode);
|
||
} else {
|
||
console.warn('Node not found:', nodeId);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Handle the new extract button
|
||
this.elements.modalDetails.querySelectorAll('.extract-node-btn').forEach(button => {
|
||
button.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const largeEntityId = e.target.dataset.largeEntityId;
|
||
const nodeId = e.target.dataset.nodeId;
|
||
|
||
console.log(`Extract button clicked for node ${nodeId} from entity ${largeEntityId}`);
|
||
this.extractNode(largeEntityId, nodeId);
|
||
});
|
||
});
|
||
|
||
// Handle legacy node links
|
||
this.elements.modalDetails.querySelectorAll('.node-link').forEach(link => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const nodeId = e.target.dataset.nodeId || e.target.getAttribute('data-node-id');
|
||
if (nodeId && this.graphManager && this.graphManager.nodes) {
|
||
const nextNode = this.graphManager.nodes.get(nodeId);
|
||
if (nextNode) {
|
||
this.showNodeModal(nextNode);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async extractNode(largeEntityId, nodeId) {
|
||
try {
|
||
this.showInfo(`Extraction initiated for ${nodeId}. It will be processed by the scanner.`);
|
||
const response = await this.apiCall('/api/graph/large-entity/extract', 'POST', {
|
||
large_entity_id: largeEntityId,
|
||
node_id: nodeId,
|
||
});
|
||
|
||
if (response.success) {
|
||
this.showSuccess(response.message);
|
||
|
||
// The node is now in the queue. We don't need to force a graph update.
|
||
// Instead, we just need to update the modal view to show one less item.
|
||
const graphResponse = await this.apiCall('/api/graph');
|
||
if (graphResponse.success) {
|
||
const updatedLargeEntity = graphResponse.graph.nodes.find(n => n.id === largeEntityId);
|
||
if (updatedLargeEntity) {
|
||
this.showNodeModal(updatedLargeEntity);
|
||
} else {
|
||
// The entity might have been dismantled completely if it was the last node
|
||
this.hideModal();
|
||
}
|
||
}
|
||
|
||
// If the scanner was idle, it's now running. Start polling.
|
||
if (this.scanStatus === 'idle') {
|
||
this.startPolling(1000);
|
||
}
|
||
|
||
} else {
|
||
throw new Error(response.error || 'Extraction failed on the server.');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to extract node:', error);
|
||
this.showError(`Extraction failed: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
initializeModalFunctionality() {
|
||
// Make sure the graph manager has node access
|
||
console.log('Initializing modal functionality...');
|
||
|
||
// Set up event delegation for dynamic content
|
||
document.addEventListener('click', (e) => {
|
||
const target = e.target.closest('.node-link-compact, .node-link');
|
||
if (target) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const nodeId = target.dataset.nodeId || target.getAttribute('data-node-id');
|
||
if (nodeId && this.graphManager && this.graphManager.nodes) {
|
||
const nextNode = this.graphManager.nodes.get(nodeId);
|
||
if (nextNode) {
|
||
this.showNodeModal(nextNode);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get icon for node type
|
||
*/
|
||
getNodeTypeIcon(nodeType) {
|
||
const icons = {
|
||
'domain': '🌐',
|
||
'ip': '📍',
|
||
'asn': '🏢',
|
||
'large_entity': '📦',
|
||
'correlation_object': '🔗'
|
||
};
|
||
return icons[nodeType] || '●';
|
||
}
|
||
|
||
/**
|
||
* Enhanced hideModal with animation
|
||
*/
|
||
hideModal() {
|
||
if (this.elements.nodeModal) {
|
||
this.elements.nodeModal.classList.add('modal-closing');
|
||
|
||
setTimeout(() => {
|
||
this.elements.nodeModal.style.display = 'none';
|
||
this.elements.nodeModal.classList.remove('modal-closing');
|
||
}, 200);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Copy text to clipboard with user feedback
|
||
*/
|
||
async copyToClipboard(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
this.showMessage('Copied to clipboard', 'success');
|
||
} catch (err) {
|
||
// Fallback for older browsers
|
||
const textArea = document.createElement('textarea');
|
||
textArea.value = text;
|
||
textArea.style.position = 'fixed';
|
||
textArea.style.opacity = '0';
|
||
document.body.appendChild(textArea);
|
||
textArea.select();
|
||
|
||
try {
|
||
document.execCommand('copy');
|
||
this.showMessage('Copied to clipboard', 'success');
|
||
} catch (fallbackErr) {
|
||
this.showMessage('Failed to copy text', 'error');
|
||
}
|
||
|
||
document.body.removeChild(textArea);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle all entity nodes in large entity view
|
||
*/
|
||
toggleAllEntities() {
|
||
const entityCards = this.elements.modalDetails.querySelectorAll('.entity-node-card');
|
||
const allExpanded = Array.from(entityCards).every(card => card.classList.contains('expanded'));
|
||
|
||
entityCards.forEach(card => {
|
||
if (allExpanded) {
|
||
card.classList.remove('expanded');
|
||
} else {
|
||
card.classList.add('expanded');
|
||
}
|
||
});
|
||
|
||
// Update button text
|
||
const toggleBtn = this.elements.modalDetails.querySelector('.toggle-all-btn');
|
||
if (toggleBtn) {
|
||
toggleBtn.textContent = allExpanded ? 'Expand All' : 'Collapse All';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enhanced keyboard navigation for modals
|
||
*/
|
||
setupModalKeyboardNavigation() {
|
||
document.addEventListener('keydown', (e) => {
|
||
if (this.elements.nodeModal && this.elements.nodeModal.style.display === 'block') {
|
||
switch (e.key) {
|
||
case 'Escape':
|
||
this.hideModal();
|
||
break;
|
||
case 'Tab':
|
||
this.handleModalTabNavigation(e);
|
||
break;
|
||
case 'Enter':
|
||
if (e.target.classList.contains('node-link') || e.target.classList.contains('node-link-item')) {
|
||
e.target.click();
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle tab navigation within modal
|
||
*/
|
||
handleModalTabNavigation(e) {
|
||
const focusableElements = this.elements.nodeModal.querySelectorAll(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
const firstElement = focusableElements[0];
|
||
const lastElement = focusableElements[focusableElements.length - 1];
|
||
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === firstElement) {
|
||
lastElement.focus();
|
||
e.preventDefault();
|
||
}
|
||
} else {
|
||
if (document.activeElement === lastElement) {
|
||
firstElement.focus();
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize enhanced modal functionality
|
||
*/
|
||
initializeEnhancedModals() {
|
||
this.setupModalKeyboardNavigation();
|
||
|
||
// Add CSS classes for animations
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
.modal-opening {
|
||
animation: modalFadeIn 0.3s ease-out;
|
||
}
|
||
|
||
.modal-closing {
|
||
animation: modalFadeOut 0.2s ease-in;
|
||
}
|
||
|
||
@keyframes modalFadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.9);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
}
|
||
|
||
@keyframes modalFadeOut {
|
||
from {
|
||
opacity: 1;
|
||
transform: scale(1);
|
||
}
|
||
to {
|
||
opacity: 0;
|
||
transform: scale(0.95);
|
||
}
|
||
}
|
||
|
||
.array-value.expanded .array-items {
|
||
max-height: none;
|
||
}
|
||
|
||
.modal-title-icon {
|
||
margin-right: 0.5rem;
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
.modal-title-text {
|
||
font-family: 'Special Elite', monospace;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
/**
|
||
* Show Settings modal
|
||
*/
|
||
showSettingsModal() {
|
||
if (this.elements.settingsModal) {
|
||
this.elements.settingsModal.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hide Settings modal
|
||
*/
|
||
hideSettingsModal() {
|
||
if (this.elements.settingsModal) {
|
||
this.elements.settingsModal.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save API Keys
|
||
*/
|
||
async saveApiKeys() {
|
||
const inputs = this.elements.apiKeyInputs.querySelectorAll('input');
|
||
const keys = {};
|
||
inputs.forEach(input => {
|
||
const provider = input.dataset.provider;
|
||
const value = input.value.trim();
|
||
if (provider && value) {
|
||
keys[provider] = value;
|
||
}
|
||
});
|
||
|
||
if (Object.keys(keys).length === 0) {
|
||
this.showWarning('No API keys were entered.');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await this.apiCall('/api/config/api-keys', 'POST', keys);
|
||
if (response.success) {
|
||
this.showSuccess(response.message);
|
||
this.hideSettingsModal();
|
||
this.loadProviders(); // Refresh provider status
|
||
} else {
|
||
throw new Error(response.error || 'Failed to save API keys');
|
||
}
|
||
} catch (error) {
|
||
this.showError(`Error saving API keys: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Reset API Key fields
|
||
*/
|
||
resetApiKeys() {
|
||
const inputs = this.elements.apiKeyInputs.querySelectorAll('input');
|
||
inputs.forEach(input => {
|
||
input.value = '';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Check if graph data has changed
|
||
* @param {Object} graphData - New graph data
|
||
* @returns {boolean} True if data has changed
|
||
*/
|
||
hasGraphChanged(graphData) {
|
||
// Simple check based on node and edge counts and timestamps
|
||
const currentStats = this.graphManager.getStatistics();
|
||
const newNodeCount = graphData.nodes ? graphData.nodes.length : 0;
|
||
const newEdgeCount = graphData.edges ? graphData.edges.length : 0;
|
||
|
||
// Check if counts changed
|
||
const countsChanged = currentStats.nodeCount !== newNodeCount || currentStats.edgeCount !== newEdgeCount;
|
||
|
||
// Also check if we have new timestamp data
|
||
const hasNewTimestamp = graphData.statistics &&
|
||
graphData.statistics.last_modified &&
|
||
graphData.statistics.last_modified !== this.lastGraphTimestamp;
|
||
|
||
if (hasNewTimestamp) {
|
||
this.lastGraphTimestamp = graphData.statistics.last_modified;
|
||
}
|
||
|
||
const changed = countsChanged || hasNewTimestamp;
|
||
|
||
console.log(`Graph change check: Current(${currentStats.nodeCount}n, ${currentStats.edgeCount}e) vs New(${newNodeCount}n, ${newEdgeCount}e) = ${changed}`);
|
||
|
||
return changed;
|
||
}
|
||
|
||
/**
|
||
* Make API call to server
|
||
* @param {string} endpoint - API endpoint
|
||
* @param {string} method - HTTP method
|
||
* @param {Object} data - Request data
|
||
* @returns {Promise<Object>} Response data
|
||
*/
|
||
async apiCall(endpoint, method = 'GET', data = null) {
|
||
console.log(`Making API call: ${method} ${endpoint}`, data ? data : '(no data)');
|
||
|
||
try {
|
||
const options = {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
};
|
||
|
||
if (data && method !== 'GET') {
|
||
options.body = JSON.stringify(data);
|
||
console.log('Request body:', options.body);
|
||
}
|
||
|
||
console.log('Fetch options:', options);
|
||
const response = await fetch(endpoint, options);
|
||
|
||
console.log('Response status:', response.status, response.statusText);
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
console.log('Response data:', result);
|
||
|
||
return result;
|
||
|
||
} catch (error) {
|
||
console.error(`API call failed for ${method} ${endpoint}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Validate target (domain or IP)
|
||
* @param {string} target - Target to validate
|
||
* @returns {boolean} True if valid
|
||
*/
|
||
isValidTarget(target) {
|
||
return this.isValidDomain(target) || this.isValidIp(target);
|
||
}
|
||
|
||
/**
|
||
* Validate domain name
|
||
* @param {string} domain - Domain to validate
|
||
* @returns {boolean} True if valid
|
||
*/
|
||
isValidDomain(domain) {
|
||
console.log(`Validating domain: "${domain}"`);
|
||
if (!domain || typeof domain !== 'string' || domain.length > 253 || /^\d{1,3}(\.\d{1,3}){3}$/.test(domain)) {
|
||
return false;
|
||
}
|
||
const parts = domain.split('.');
|
||
if (parts.length < 2 || parts.some(part => !/^[a-zA-Z0-9-]{1,63}$/.test(part) || part.startsWith('-') || part.endsWith('-'))) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Validate IP address
|
||
* @param {string} ip - IP to validate
|
||
* @returns {boolean} True if valid
|
||
*/
|
||
isValidIp(ip) {
|
||
console.log(`Validating IP: "${ip}"`);
|
||
const parts = ip.split('.');
|
||
if (parts.length !== 4) {
|
||
return false;
|
||
}
|
||
return parts.every(part => {
|
||
const num = parseInt(part, 10);
|
||
return !isNaN(num) && num >= 0 && num <= 255 && String(num) === part;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Format status text for display
|
||
* @param {string} status - Raw status
|
||
* @returns {string} Formatted status
|
||
*/
|
||
formatStatus(status) {
|
||
const statusMap = {
|
||
'idle': 'Idle',
|
||
'running': 'Running',
|
||
'completed': 'Completed',
|
||
'failed': 'Failed',
|
||
'stopped': 'Stopped'
|
||
};
|
||
return statusMap[status] || status;
|
||
}
|
||
|
||
/**
|
||
* Format label for display
|
||
* @param {string} label - Raw label
|
||
* @returns {string} Formatted label
|
||
*/
|
||
formatLabel(label) {
|
||
return label.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||
}
|
||
|
||
/**
|
||
* Format value for display
|
||
* @param {*} value - Raw value
|
||
* @returns {string} Formatted value
|
||
*/
|
||
formatValue(value) {
|
||
if (typeof value === 'object' && value !== null) {
|
||
// Use <pre> for nicely formatted JSON
|
||
return `<pre>${JSON.stringify(value, null, 2)}</pre>`;
|
||
} else {
|
||
// Escape HTML to prevent XSS issues with string values
|
||
const strValue = String(value);
|
||
return strValue.replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Show success message
|
||
* @param {string} message - Success message
|
||
*/
|
||
showSuccess(message) {
|
||
this.showMessage(message, 'success');
|
||
}
|
||
|
||
/**
|
||
* Show info message
|
||
* @param {string} message - Info message
|
||
*/
|
||
showInfo(message) {
|
||
this.showMessage(message, 'info');
|
||
}
|
||
|
||
showWarning(message) {
|
||
this.showMessage(message, 'warning');
|
||
}
|
||
|
||
/**
|
||
* Show error message
|
||
* @param {string} message - Error message
|
||
*/
|
||
showError(message) {
|
||
this.showMessage(message, 'error');
|
||
}
|
||
|
||
/**
|
||
* Show connection error
|
||
*/
|
||
showConnectionError() {
|
||
this.updateConnectionStatus('error');
|
||
}
|
||
|
||
/**
|
||
* Show message with visual feedback
|
||
* @param {string} message - Message text
|
||
* @param {string} type - Message type (success, error, warning, info)
|
||
*/
|
||
showMessage(message, type = 'info') {
|
||
console.log(`${type.toUpperCase()}: ${message}`);
|
||
|
||
// Create message element
|
||
const messageElement = document.createElement('div');
|
||
messageElement.className = `message-toast message-${type}`;
|
||
|
||
messageElement.innerHTML = `
|
||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 20px;">
|
||
<span style="flex: 1;">${message}</span>
|
||
<button onclick="this.parentElement.parentElement.remove()"
|
||
style="background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; margin-left: 10px; opacity: 0.7;">×</button>
|
||
</div>
|
||
`;
|
||
|
||
// Add to container
|
||
const container = document.getElementById('message-container');
|
||
if (container) {
|
||
container.appendChild(messageElement);
|
||
|
||
// Auto-remove after delay
|
||
setTimeout(() => {
|
||
if (messageElement.parentNode) {
|
||
messageElement.style.animation = 'slideOutRight 0.3s ease-out';
|
||
setTimeout(() => {
|
||
if (messageElement.parentNode) {
|
||
messageElement.remove();
|
||
}
|
||
}, 300);
|
||
}
|
||
}, type === 'error' ? 8000 : 5000); // Errors stay longer
|
||
}
|
||
|
||
// Update connection status to show activity
|
||
if (type === 'success' && this.consecutiveErrors === 0) {
|
||
this.updateConnectionStatus(this.isScanning ? 'active' : 'idle');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get message background color based on type
|
||
* @param {string} type - Message type
|
||
* @returns {string} CSS color
|
||
*/
|
||
getMessageColor(type) {
|
||
const colors = {
|
||
'success': '#2c5c34',
|
||
'error': '#5c2c2c',
|
||
'warning': '#5c4c2c',
|
||
'info': '#2c3e5c'
|
||
};
|
||
return colors[type] || colors.info;
|
||
}
|
||
|
||
/**
|
||
* Get message border color based on type
|
||
* @param {string} type - Message type
|
||
* @returns {string} CSS color
|
||
*/
|
||
getMessageBorderColor(type) {
|
||
const colors = {
|
||
'success': '#00ff41',
|
||
'error': '#ff6b6b',
|
||
'warning': '#ff9900',
|
||
'info': '#00aaff'
|
||
};
|
||
return colors[type] || colors.info;
|
||
}
|
||
|
||
/**
|
||
* Build the API key modal dynamically
|
||
* @param {Object} providers - Provider information
|
||
*/
|
||
buildApiKeyModal(providers) {
|
||
if (!this.elements.apiKeyInputs) return;
|
||
this.elements.apiKeyInputs.innerHTML = ''; // Clear existing inputs
|
||
let hasApiKeyProviders = false;
|
||
|
||
for (const [name, info] of Object.entries(providers)) {
|
||
if (info.requires_api_key) {
|
||
hasApiKeyProviders = true;
|
||
const inputGroup = document.createElement('div');
|
||
inputGroup.className = 'apikey-section';
|
||
|
||
if (info.enabled) {
|
||
// If the API key is set and the provider is enabled
|
||
inputGroup.innerHTML = `
|
||
<label for="${name}-api-key">${info.display_name} API Key</label>
|
||
<div class="api-key-set-message">
|
||
<span class="api-key-set-text">API Key is set</span>
|
||
<button class="clear-api-key-btn" data-provider="${name}">Clear</button>
|
||
</div>
|
||
<p class="apikey-help">Provides infrastructure context and service information.</p>
|
||
`;
|
||
} else {
|
||
// If the API key is not set
|
||
inputGroup.innerHTML = `
|
||
<label for="${name}-api-key">${info.display_name} API Key</label>
|
||
<input type="password" id="${name}-api-key" data-provider="${name}" placeholder="Enter ${info.display_name} API Key">
|
||
<p class="apikey-help">Provides infrastructure context and service information.</p>
|
||
`;
|
||
}
|
||
this.elements.apiKeyInputs.appendChild(inputGroup);
|
||
}
|
||
}
|
||
|
||
// Add event listeners for the new clear buttons
|
||
this.elements.apiKeyInputs.querySelectorAll('.clear-api-key-btn').forEach(button => {
|
||
button.addEventListener('click', (e) => {
|
||
const provider = e.target.dataset.provider;
|
||
this.clearApiKey(provider);
|
||
});
|
||
});
|
||
|
||
if (!hasApiKeyProviders) {
|
||
this.elements.apiKeyInputs.innerHTML = '<p>No providers require API keys.</p>';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear an API key for a specific provider
|
||
* @param {string} provider The name of the provider to clear the API key for
|
||
*/
|
||
async clearApiKey(provider) {
|
||
try {
|
||
const response = await this.apiCall('/api/config/api-keys', 'POST', { [provider]: '' });
|
||
if (response.success) {
|
||
this.showSuccess(`API key for ${provider} has been cleared.`);
|
||
this.loadProviders(); // This will rebuild the modal with the updated state
|
||
} else {
|
||
throw new Error(response.error || 'Failed to clear API key');
|
||
}
|
||
} catch (error) {
|
||
this.showError(`Error clearing API key: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add CSS animations for message toasts
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes slideInRight {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOutRight {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.message-container {
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.message-toast {
|
||
pointer-events: auto;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
// Initialize application when page loads
|
||
console.log('Creating DNSReconApp instance...');
|
||
const app = new DNSReconApp(); |