dnsrecon/static/js/main.js
2025-09-14 23:54:27 +02:00

1423 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 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();
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
targetDomain: document.getElementById('target-domain'),
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'),
configureApiKeys: document.getElementById('configure-api-keys'),
// 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'),
// API Key Modal elements
apiKeyModal: document.getElementById('api-key-modal'),
apiKeyModalClose: document.getElementById('api-key-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 = ['targetDomain', '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.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.configureApiKeys.addEventListener('click', () => this.showApiKeyModal());
// Enter key support for target domain input
this.elements.targetDomain.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();
});
}
// API Key Modal interactions
if (this.elements.apiKeyModalClose) {
this.elements.apiKeyModalClose.addEventListener('click', () => this.hideApiKeyModal());
}
if (this.elements.apiKeyModal) {
this.elements.apiKeyModal.addEventListener('click', (e) => {
if (e.target === this.elements.apiKeyModal) this.hideApiKeyModal();
});
}
if (this.elements.saveApiKeys) {
this.elements.saveApiKeys.addEventListener('click', () => this.saveApiKeys());
}
if (this.elements.resetApiKeys) {
this.elements.resetApiKeys.addEventListener('click', () => this.resetApiKeys());
}
// ** FIX: Listen for the custom event from the graph **
document.addEventListener('nodeSelected', (e) => {
this.showNodeModal(e.detail.node);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hideModal();
this.hideApiKeyModal();
}
});
// 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) {
console.log('=== STARTING SCAN ===');
try {
const targetDomain = this.elements.targetDomain.value.trim();
const maxDepth = parseInt(this.elements.maxDepth.value);
console.log(`Target domain: "${targetDomain}", Max depth: ${maxDepth}`);
// Validation
if (!targetDomain) {
console.log('Validation failed: empty domain');
this.showError('Please enter a target domain');
this.elements.targetDomain.focus();
return;
}
if (!this.isValidDomain(targetDomain)) {
console.log(`Validation failed: invalid domain format for "${targetDomain}"`);
this.showError('Please enter a valid domain name (e.g., example.com)');
this.elements.targetDomain.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_domain: targetDomain,
max_depth: maxDepth,
clear_graph: clearGraph
};
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 ${targetDomain} 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 enqueued = status.task_queue_size || 0;
const totalTasks = completed + enqueued;
const progressPercentage = totalTasks > 0 ? (completed / totalTasks) * 100 : 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.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.targetDomain) this.elements.targetDomain.disabled = true;
if (this.elements.maxDepth) this.elements.maxDepth.disabled = true;
if (this.elements.configureApiKeys) this.elements.configureApiKeys.disabled = true;
break;
case 'idle':
case 'completed':
case 'failed':
case 'stopped':
this.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.targetDomain) this.elements.targetDomain.disabled = false;
if (this.elements.maxDepth) this.elements.maxDepth.disabled = false;
if (this.elements.configureApiKeys) this.elements.configureApiKeys.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);
}
}
/**
* Generates the HTML for the node details view using the new data model.
* @param {Object} node - The node object.
* @returns {string} The HTML string for the node details.
*/
generateNodeDetailsHtml(node) {
if (!node) return '<div class="detail-row"><span class="detail-value">Details not available.</span></div>';
let detailsHtml = '<div class="modal-details-grid">';
// Handle merged correlation objects similar to large entities
if (node.type === 'correlation_object') {
const metadata = node.metadata || {};
const values = metadata.values || [];
const mergeCount = metadata.merge_count || 1;
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Correlation Details</h4>';
if (mergeCount > 1) {
detailsHtml += `<p><strong>Merged Correlations:</strong> ${mergeCount} values</p>`;
detailsHtml += '<div class="correlation-values-list">';
values.forEach((value, index) => {
detailsHtml += `<details class="correlation-value-details">`;
detailsHtml += `<summary>Value ${index + 1}: ${typeof value === 'string' && value.length > 50 ? value.substring(0, 47) + '...' : value}</summary>`;
detailsHtml += `<div class="detail-row"><span class="detail-label">Full Value:</span><span class="detail-value">${value}</span></div>`;
detailsHtml += `</details>`;
});
detailsHtml += '</div>';
} else {
const singleValue = values.length > 0 ? values[0] : (metadata.value || 'Unknown');
detailsHtml += `<div class="detail-row"><span class="detail-label">Correlation Value:</span><span class="detail-value">${singleValue}</span></div>`;
}
// Show correlated nodes
const correlatedNodes = metadata.correlated_nodes || [];
if (correlatedNodes.length > 0) {
detailsHtml += `<div class="detail-row"><span class="detail-label">Correlated Nodes:</span><span class="detail-value">${correlatedNodes.length}</span></div>`;
detailsHtml += '<ul>';
correlatedNodes.forEach(nodeId => {
detailsHtml += `<li><a href="#" class="node-link" data-node-id="${nodeId}">${nodeId}</a></li>`;
});
detailsHtml += '</ul>';
}
detailsHtml += '</div>';
}
// Continue with standard node details for all node types
// Section for Incoming Edges (Source Nodes)
if (node.incoming_edges && node.incoming_edges.length > 0) {
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Source Nodes (Incoming)</h4>';
detailsHtml += '<ul>';
node.incoming_edges.forEach(edge => {
detailsHtml += `<li><a href="#" class="node-link" data-node-id="${edge.from}">${edge.from}</a> (${edge.data.relationship_type})</li>`;
});
detailsHtml += '</ul></div>';
}
// Section for Outgoing Edges (Destination Nodes)
if (node.outgoing_edges && node.outgoing_edges.length > 0) {
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Destination Nodes (Outgoing)</h4>';
detailsHtml += '<ul>';
node.outgoing_edges.forEach(edge => {
detailsHtml += `<li><a href="#" class="node-link" data-node-id="${edge.to}">${edge.to}</a> (${edge.data.relationship_type})</li>`;
});
detailsHtml += '</ul></div>';
}
// Section for Attributes (skip for correlation objects - already handled above)
if (node.type !== 'correlation_object') {
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Attributes</h4>';
detailsHtml += this.formatObjectToHtml(node.attributes);
detailsHtml += '</div>';
}
// Section for Description
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Description</h4>';
detailsHtml += `<p class="description-text">${node.description || 'No description available.'}</p>`;
detailsHtml += '</div>';
// Section for Metadata (skip detailed metadata for correlation objects - already handled above)
if (node.type !== 'correlation_object') {
detailsHtml += '<div class="modal-section">';
detailsHtml += '<h4>Metadata</h4>';
detailsHtml += this.formatObjectToHtml(node.metadata);
detailsHtml += '</div>';
}
detailsHtml += '</div>';
return detailsHtml;
}
/**
* 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;
}
/**
* Show node details modal
* @param {Object} node - Node data
*/
showNodeModal(node) {
if (!this.elements.nodeModal || !node) return;
if (this.elements.modalTitle) {
this.elements.modalTitle.textContent = `${this.formatStatus(node.type)} Node: ${node.id}`;
}
let detailsHtml = '';
if (node.type === 'large_entity') {
const attributes = node.attributes || {};
const nodes = attributes.nodes || [];
const node_type = attributes.node_type || 'nodes';
detailsHtml += `<div class="detail-section-header">Contains ${attributes.count} ${node_type}s</div>`;
detailsHtml += '<div class="large-entity-nodes-list">';
for(const innerNodeId of nodes) {
const innerNode = this.graphManager.nodes.get(innerNodeId);
detailsHtml += `<details class="large-entity-node-details">`;
detailsHtml += `<summary>${innerNodeId}</summary>`;
detailsHtml += this.generateNodeDetailsHtml(innerNode);
detailsHtml += `</details>`;
}
detailsHtml += '</div>';
} else {
detailsHtml = this.generateNodeDetailsHtml(node);
}
if (this.elements.modalDetails) {
this.elements.modalDetails.innerHTML = detailsHtml;
this.elements.modalDetails.querySelectorAll('.node-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const nodeId = e.target.dataset.nodeId;
const nextNode = this.graphManager.nodes.get(nodeId);
if (nextNode) {
this.hideModal();
this.showNodeModal(nextNode);
}
});
});
}
this.elements.nodeModal.style.display = 'block';
}
/**
* Hide the modal
*/
hideModal() {
if (this.elements.nodeModal) {
this.elements.nodeModal.style.display = 'none';
}
}
/**
* Show API Key modal
*/
showApiKeyModal() {
if (this.elements.apiKeyModal) {
this.elements.apiKeyModal.style.display = 'block';
}
}
/**
* Hide API Key modal
*/
hideApiKeyModal() {
if (this.elements.apiKeyModal) {
this.elements.apiKeyModal.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.hideApiKeyModal();
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 domain name - improved validation
* @param {string} domain - Domain to validate
* @returns {boolean} True if valid
*/
isValidDomain(domain) {
console.log(`Validating domain: "${domain}"`);
// Basic checks
if (!domain || typeof domain !== 'string') {
console.log('Validation failed: empty or non-string domain');
return false;
}
if (domain.length > 253) {
console.log('Validation failed: domain too long');
return false;
}
if (domain.startsWith('.') || domain.endsWith('.')) {
console.log('Validation failed: domain starts or ends with dot');
return false;
}
if (domain.includes('..')) {
console.log('Validation failed: domain contains double dots');
return false;
}
// Split into parts and validate each
const parts = domain.split('.');
if (parts.length < 2) {
console.log('Validation failed: domain has less than 2 parts');
return false;
}
// Check each part
for (const part of parts) {
if (!part || part.length > 63) {
console.log(`Validation failed: invalid part "${part}"`);
return false;
}
if (part.startsWith('-') || part.endsWith('-')) {
console.log(`Validation failed: part "${part}" starts or ends with hyphen`);
return false;
}
if (!/^[a-zA-Z0-9-]+$/.test(part)) {
console.log(`Validation failed: part "${part}" contains invalid characters`);
return false;
}
}
// Check TLD (last part) is alphabetic
const tld = parts[parts.length - 1];
if (!/^[a-zA-Z]{2,}$/.test(tld)) {
console.log(`Validation failed: invalid TLD "${tld}"`);
return false;
}
console.log('Domain validation passed');
return true;
}
/**
* 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, "&lt;").replace(/>/g, "&gt;");
}
}
/**
* 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();