/** * Main application logic for DNScope web interface * Handles UI interactions, API communication, and data flow * UPDATED: Now compatible with a strictly flat, unified data model for attributes. */ class DNScopeApp { constructor() { console.log('DNScopeApp constructor called'); this.graphManager = null; this.scanStatus = 'idle'; this.statusPollInterval = null; // Separate status polling this.graphPollInterval = null; // Separate graph polling this.currentSessionId = null; this.elements = {}; this.isScanning = false; this.lastGraphUpdate = null; // Graph polling optimization this.graphPollingNodeThreshold = 500; // Default, will be loaded from config this.graphPollingEnabled = true; this.init(); } /** * Initialize the application */ init() { console.log('DNScopeApp init called'); document.addEventListener('DOMContentLoaded', () => { console.log('DOM loaded, initializing application...'); try { this.initializeElements(); this.setupEventHandlers(); this.initializeGraph(); this.updateStatus(); this.loadProviders(); this.initializeEnhancedModals(); this.addCheckboxStyling(); this.loadConfig(); // Load configuration including threshold this.updateGraph(); console.log('DNScope application initialized successfully'); } catch (error) { console.error('Failed to initialize DNScope application:', error); this.showError(`Initialization failed: ${error.message}`); } }); } /** * Load configuration from backend */ async loadConfig() { try { const response = await this.apiCall('/api/config'); if (response.success) { this.graphPollingNodeThreshold = response.config.graph_polling_node_threshold; console.log(`Graph polling threshold set to: ${this.graphPollingNodeThreshold} nodes`); } } catch (error) { console.warn('Failed to load config, using defaults:', error); } } /** * 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'), exportOptions: document.getElementById('export-options'), exportModal: document.getElementById('export-modal'), exportModalClose: document.getElementById('export-modal-close'), exportGraphJson: document.getElementById('export-graph-json'), exportTargetsTxt: document.getElementById('export-targets-txt'), exportExecutiveSummary: document.getElementById('export-executive-summary'), 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'), // 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); } } /** * Setup event handlers */ setupEventHandlers() { console.log('Setting up event handlers...'); try { // Form interactions this.initializeModalFunctionality(); this.elements.startScan.addEventListener('click', (e) => { e.preventDefault(); this.startScan(); }); this.elements.addToGraph.addEventListener('click', (e) => { e.preventDefault(); this.startScan(false); }); this.elements.stopScan.addEventListener('click', (e) => { e.preventDefault(); this.stopScan(); }); this.elements.exportOptions.addEventListener('click', (e) => { e.preventDefault(); this.showExportModal(); }); if (this.elements.exportModalClose) { this.elements.exportModalClose.addEventListener('click', () => this.hideExportModal()); } if (this.elements.exportModal) { this.elements.exportModal.addEventListener('click', (e) => { if (e.target === this.elements.exportModal) this.hideExportModal(); }); } if (this.elements.exportGraphJson) { this.elements.exportGraphJson.addEventListener('click', () => this.exportGraphJson()); } if (this.elements.exportTargetsTxt) { this.elements.exportTargetsTxt.addEventListener('click', () => this.exportTargetsTxt()); } if (this.elements.exportExecutiveSummary) { this.elements.exportExecutiveSummary.addEventListener('click', () => this.exportExecutiveSummary()); } 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) { 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.removeEventListener('click', this.saveApiKeys); } if (this.elements.resetApiKeys) { this.elements.resetApiKeys.removeEventListener('click', this.resetApiKeys); } // Setup new handlers const saveSettingsBtn = document.getElementById('save-settings'); const resetSettingsBtn = document.getElementById('reset-settings'); if (saveSettingsBtn) { saveSettingsBtn.addEventListener('click', () => this.saveSettings()); } if (resetSettingsBtn) { resetSettingsBtn.addEventListener('click', () => this.resetSettings()); } // 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(); this.hideExportModal(); // Add this line } }); // 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 with manual refresh button */ initializeGraph() { try { console.log('Initializing graph manager...'); this.graphManager = new GraphManager('network-graph'); // Set up manual refresh handler this.graphManager.setManualRefreshHandler(() => { console.log('Manual graph refresh requested'); this.updateGraph(); }); console.log('Graph manager initialized successfully'); } catch (error) { console.error('Failed to initialize graph manager:', error); this.showError('Failed to initialize graph visualization'); } } /** * Check if graph polling should be enabled based on node count */ shouldEnableGraphPolling() { if (!this.graphManager || !this.graphManager.nodes) { return true; } const nodeCount = this.graphManager.nodes.length; return nodeCount <= this.graphPollingNodeThreshold; } /** * Update manual refresh button visibility and state. * The button will be visible whenever auto-polling is disabled, * and enabled only when a scan is in progress. */ updateManualRefreshButton() { if (!this.graphManager || !this.graphManager.manualRefreshButton) return; const shouldShow = !this.graphPollingEnabled; this.graphManager.showManualRefreshButton(shouldShow); if (shouldShow) { this.graphManager.manualRefreshButton.disabled = false; } } /** * 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 }; const response = await this.apiCall('/api/scan/start', 'POST', requestData); if (response.success) { this.currentSessionId = response.scan_id; this.showSuccess('Reconnaissance scan started successfully'); if (clearGraph) { this.graphManager.clear(); this.graphPollingEnabled = true; // Reset polling when starting fresh } console.log(`Scan started for ${target} with depth ${maxDepth}`); // Start optimized polling this.startOptimizedPolling(); // Force an immediate status update console.log('Forcing immediate status update...'); setTimeout(() => { this.updateStatus(); if (this.graphPollingEnabled) { 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'); } } /** * Start optimized polling with separate status and graph intervals */ startOptimizedPolling() { console.log('=== STARTING OPTIMIZED POLLING ==='); this.stopPolling(); // Stop any existing polling // Always poll status for progress bar this.statusPollInterval = setInterval(() => { this.updateStatus(); this.loadProviders(); }, 2000); // Only poll graph if enabled if (this.graphPollingEnabled) { this.graphPollInterval = setInterval(() => { this.updateGraph(); }, 2000); console.log('Graph polling started'); } else { console.log('Graph polling disabled due to node count'); } this.updateManualRefreshButton(); console.log(`Optimized polling started - Status: enabled, Graph: ${this.graphPollingEnabled ? 'enabled' : 'disabled'}`); } /** * 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 = '[STOPPING]Stopping...'; } // Show immediate feedback this.showInfo('Stopping scan...'); const response = await this.apiCall('/api/scan/stop', 'POST'); if (response.success) { this.showSuccess('Scan stop requested'); // Force immediate status update setTimeout(() => { this.updateStatus(); }, 100); // Continue status polling for a bit to catch the status change // No need to resume graph polling } 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 = '[STOP]Terminate Scan'; } } } /** * Show Export modal */ showExportModal() { if (this.elements.exportModal) { this.elements.exportModal.style.display = 'block'; } } /** * Hide Export modal */ hideExportModal() { if (this.elements.exportModal) { this.elements.exportModal.style.display = 'none'; } } /** * Export graph data as JSON with proper error handling */ async exportGraphJson() { try { console.log('Exporting graph data as JSON...'); // Show loading state if (this.elements.exportGraphJson) { const originalContent = this.elements.exportGraphJson.innerHTML; this.elements.exportGraphJson.innerHTML = '[...]Exporting...'; this.elements.exportGraphJson.disabled = true; // Store original content for restoration this.elements.exportGraphJson._originalContent = originalContent; } // Make API call to get export data const response = await fetch('/api/export', { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`); } // Check if response is JSON or file download const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json') && !response.headers.get('content-disposition')) { // This is an error response in JSON format const errorData = await response.json(); throw new Error(errorData.error || 'Export failed'); } // Get the filename from headers or create one const contentDisposition = response.headers.get('content-disposition'); let filename = 'DNScope_export.json'; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (filenameMatch) { filename = filenameMatch[1].replace(/['"]/g, ''); } } // Create blob and download const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); this.showSuccess('Graph data exported successfully'); this.hideExportModal(); } catch (error) { console.error('Failed to export graph data:', error); this.showError(`Export failed: ${error.message}`); } finally { // Restore button state if (this.elements.exportGraphJson) { const originalContent = this.elements.exportGraphJson._originalContent || '[JSON]Export Graph Data'; this.elements.exportGraphJson.innerHTML = originalContent; this.elements.exportGraphJson.disabled = false; } } } async exportTargetsTxt() { await this.exportFile('/api/export/targets', this.elements.exportTargetsTxt, 'Exporting Targets...'); } async exportExecutiveSummary() { await this.exportFile('/api/export/summary', this.elements.exportExecutiveSummary, 'Generating Summary...'); } async exportFile(endpoint, buttonElement, loadingMessage) { try { console.log(`Exporting from ${endpoint}...`); const originalContent = buttonElement.innerHTML; buttonElement.innerHTML = `[...]${loadingMessage}`; buttonElement.disabled = true; const response = await fetch(endpoint, { method: 'GET' }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`); } const contentDisposition = response.headers.get('content-disposition'); let filename = 'export.txt'; if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); if (filenameMatch) { filename = filenameMatch[1].replace(/['"]/g, ''); } } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); this.showSuccess('File exported successfully'); this.hideExportModal(); } catch (error) { console.error(`Failed to export from ${endpoint}:`, error); this.showError(`Export failed: ${error.message}`); } finally { const originalContent = buttonElement._originalContent || buttonElement.innerHTML; buttonElement.innerHTML = originalContent; buttonElement.disabled = false; } } /** * 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(() => { 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.statusPollInterval) { clearInterval(this.statusPollInterval); this.statusPollInterval = null; } if (this.graphPollInterval) { clearInterval(this.graphPollInterval); this.graphPollInterval = null; } this.updateManualRefreshButton(); } /** * Status update with better error handling */ async updateStatus() { try { const response = await this.apiCall('/api/scan/status'); if (response.success && response.status) { const status = response.status; 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 with polling optimization */ async updateGraph() { try { console.log('Updating graph...'); const response = await this.apiCall('/api/graph'); 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); // Always update graph, even if empty - let GraphManager handle placeholder if (this.graphManager) { this.graphManager.updateGraph(graphData); this.lastGraphUpdate = Date.now(); // Check if we should disable graph polling after this update const nodeCount = graphData.nodes ? graphData.nodes.length : 0; const shouldEnablePolling = nodeCount <= this.graphPollingNodeThreshold; if (this.graphPollingEnabled && !shouldEnablePolling) { console.log(`Graph polling disabled: ${nodeCount} nodes exceeds threshold of ${this.graphPollingNodeThreshold}`); this.graphPollingEnabled = false; this.showWarning(`Graph auto-refresh disabled: ${nodeCount} nodes exceed threshold of ${this.graphPollingNodeThreshold}. Use manual refresh button.`); // Stop graph polling but keep status polling if (this.graphPollInterval) { clearInterval(this.graphPollInterval); this.graphPollInterval = null; } this.updateManualRefreshButton(); } // Update relationship count in status const edgeCount = graphData.edges ? graphData.edges.length : 0; if (this.elements.relationshipsDisplay) { this.elements.relationshipsDisplay.textContent = edgeCount; } } } else { console.error('Graph update failed:', response); // Show placeholder when graph update fails if (this.graphManager && this.graphManager.container) { const placeholder = this.graphManager.container.querySelector('.graph-placeholder'); if (placeholder) { placeholder.style.display = 'flex'; } } } } catch (error) { console.error('Failed to update graph:', error); // Show placeholder on error if (this.graphManager && this.graphManager.container) { const placeholder = this.graphManager.container.querySelector('.graph-placeholder'); if (placeholder) { placeholder.style.display = 'flex'; } } } } /** * Update status display elements * @param {Object} status - Status object from server */ updateStatusDisplay(status) { try { // 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); } 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'); this.updateConnectionStatus('active'); break; case 'completed': this.setUIState('completed', task_queue_size); this.stopPolling(); this.showSuccess('Scan completed successfully'); this.updateConnectionStatus('completed'); this.loadProviders(); // Do final graph update when scan completes console.log('Scan completed - performing 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) { const isQueueEmpty = task_queue_size === 0; switch (state) { case 'scanning': case 'running': 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 = '[SCANNING]Scanning...'; } 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 = '[STOP]Terminate Scan'; } 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 = false; this.elements.startScan.classList.remove('loading'); this.elements.startScan.innerHTML = '[RUN]Start Reconnaissance'; } if (this.elements.addToGraph) { this.elements.addToGraph.disabled = false; this.elements.addToGraph.classList.remove('loading'); } if (this.elements.stopScan) { this.elements.stopScan.disabled = true; this.elements.stopScan.innerHTML = '[STOP]Terminate Scan'; } 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; // Update manual refresh button visibility this.updateManualRefreshButton(); break; } } /** * Load provider information */ async loadProviders() { try { const response = await this.apiCall('/api/providers'); if (response.success) { this.updateProviderDisplay(response.providers); this.buildSettingsModal(response.providers); // Updated to use new function console.log('Providers loaded successfully'); } } catch (error) { console.error('Failed to load providers:', error); } } /** * Build the enhanced settings modal with provider configuration and API keys * @param {Object} providers - Provider information from backend */ buildSettingsModal(providers) { this.buildProviderConfigSection(providers); this.buildApiKeySection(providers); this.updateSettingsCounts(providers); } /** * Build the provider configuration section with enable/disable checkboxes * @param {Object} providers - Provider information */ buildProviderConfigSection(providers) { const providerConfigList = document.getElementById('provider-config-list'); if (!providerConfigList) return; providerConfigList.innerHTML = ''; for (const [name, info] of Object.entries(providers)) { const providerConfig = document.createElement('div'); providerConfig.className = 'provider-item'; const statusClass = info.enabled ? 'enabled' : 'disabled'; const statusIcon = info.enabled ? '✓' : '✗'; providerConfig.innerHTML = `
${info.display_name}
${statusIcon} ${info.enabled ? 'Enabled' : 'Disabled'}
`; providerConfigList.appendChild(providerConfig); } // Add checkbox styling and event handlers this.setupProviderCheckboxes(); } /** * Setup provider checkbox styling and event handlers */ setupProviderCheckboxes() { const checkboxes = document.querySelectorAll('.provider-toggle'); checkboxes.forEach(checkbox => { // Apply existing checkbox styling checkbox.style.cssText = ` appearance: none; width: 16px; height: 16px; border: 2px solid #555; background: #1a1a1a; cursor: pointer; position: relative; border-radius: 3px; transition: all 0.3s ease; `; // Update visual state this.updateCheckboxAppearance(checkbox); // Add change event handler checkbox.addEventListener('change', (e) => { this.updateCheckboxAppearance(e.target); }); }); } /** * Add CSS for checkbox styling since we're using existing styles */ addCheckboxStyling() { // Add CSS for the checkboxes to work with existing styles const style = document.createElement('style'); style.textContent = ` .provider-toggle[data-checked="true"]::after { content: '✓'; position: absolute; top: -2px; left: 2px; color: #1a1a1a; font-size: 12px; font-weight: bold; } .provider-toggle:hover { border-color: #00ff41; } .api-key-status-row { transition: all 0.3s ease; } .provider-item { margin-bottom: 1rem; } .provider-item:last-child { margin-bottom: 0; } `; document.head.appendChild(style); } /** * Update checkbox appearance based on checked state */ updateCheckboxAppearance(checkbox) { if (checkbox.checked) { checkbox.style.background = '#00ff41'; checkbox.style.borderColor = '#00ff41'; checkbox.style.setProperty('content', '"✓"', 'important'); // Add checkmark via pseudo-element simulation checkbox.setAttribute('data-checked', 'true'); } else { checkbox.style.background = '#1a1a1a'; checkbox.style.borderColor = '#555'; checkbox.removeAttribute('data-checked'); } } /** * Enhanced API key section builder - FIXED to always allow API key input * @param {Object} providers - Provider information */ buildApiKeySection(providers) { const apiKeyInputs = document.getElementById('api-key-inputs'); if (!apiKeyInputs) return; apiKeyInputs.innerHTML = ''; 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 = 'provider-item'; // Check if API key is set via backend (not clearable) or frontend (clearable) const isBackendConfigured = info.api_key_source === 'backend'; if (info.api_key_configured && isBackendConfigured) { // API key is configured via backend - show status only inputGroup.innerHTML = `
${info.display_name}
✓ Backend Configured
API Key Active
Configured via environment variable
`; } else if (info.api_key_configured && !isBackendConfigured) { // API key is configured via frontend - show status with clear option inputGroup.innerHTML = `
${info.display_name}
✓ Web Configured
API Key Active
Set via web interface (session-only)
`; } else { // API key not configured - ALWAYS show input field const statusClass = info.enabled ? 'enabled' : 'api-key-required'; const statusText = info.enabled ? '○ Ready for API Key' : '⚠️ API Key Required'; inputGroup.innerHTML = `
${info.display_name}
${statusText}
${info.api_key_help || `Provides enhanced ${info.display_name.toLowerCase()} data and context.`} ${!info.enabled ? ' Enable the provider above to use this API key.' : ''}
`; } apiKeyInputs.appendChild(inputGroup); } } if (!hasApiKeyProviders) { apiKeyInputs.innerHTML = `
No providers require API keys
All Active
`; } // Setup clear button event handlers this.setupApiKeyClearHandlers(); } /** * Setup API key clear button handlers */ setupApiKeyClearHandlers() { document.querySelectorAll('.clear-api-key-btn').forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); const provider = e.currentTarget.dataset.provider; this.clearSingleApiKey(provider, e.currentTarget); }); }); } /** * Clear a single API key with immediate feedback */ async clearSingleApiKey(provider, buttonElement) { try { // Show immediate feedback const originalContent = buttonElement.innerHTML; buttonElement.innerHTML = '[...]Clearing...'; buttonElement.disabled = true; const response = await this.apiCall('/api/config/api-keys', 'POST', { [provider]: '' }); if (response.success) { // Find the parent container and update it const providerContainer = buttonElement.closest('.provider-item'); const statusRow = providerContainer.querySelector('.api-key-status-row'); // Animate out the current status statusRow.style.transition = 'all 0.3s ease'; statusRow.style.opacity = '0'; statusRow.style.transform = 'translateX(-10px)'; setTimeout(() => { // Replace with input field const providerName = buttonElement.dataset.provider; const apiKeySection = this.elements.apiKeyInputs; // Rebuild the API key section to reflect changes this.loadProviders(); this.showSuccess(`API key for ${provider} has been cleared.`); }, 300); } else { throw new Error(response.error || 'Failed to clear API key'); } } catch (error) { // Restore button on error buttonElement.innerHTML = originalContent; buttonElement.disabled = false; this.showError(`Error clearing API key: ${error.message}`); } } /** * Update settings modal counts */ updateSettingsCounts(providers) { const providerCount = Object.keys(providers).length; const apiKeyCount = Object.values(providers).filter(p => p.requires_api_key).length; const providerCountElement = document.getElementById('provider-count'); const apiKeyCountElement = document.getElementById('api-key-count'); if (providerCountElement) providerCountElement.textContent = providerCount; if (apiKeyCountElement) apiKeyCountElement.textContent = apiKeyCount; } /** * Enhanced save settings function */ async saveSettings() { try { const settings = { apiKeys: {}, providerSettings: {} }; // Collect API key inputs const apiKeyInputs = document.querySelectorAll('#api-key-inputs input[type="password"]'); apiKeyInputs.forEach(input => { const provider = input.dataset.provider; const value = input.value.trim(); if (provider && value) { settings.apiKeys[provider] = value; } }); // Collect provider enable/disable settings const providerCheckboxes = document.querySelectorAll('.provider-toggle'); providerCheckboxes.forEach(checkbox => { const provider = checkbox.dataset.provider; if (provider) { settings.providerSettings[provider] = { enabled: checkbox.checked }; } }); // Save API keys if any if (Object.keys(settings.apiKeys).length > 0) { const apiKeyResponse = await this.apiCall('/api/config/api-keys', 'POST', settings.apiKeys); if (!apiKeyResponse.success) { throw new Error(apiKeyResponse.error || 'Failed to save API keys'); } } // Save provider settings if any if (Object.keys(settings.providerSettings).length > 0) { const providerResponse = await this.apiCall('/api/config/providers', 'POST', settings.providerSettings); if (!providerResponse.success) { throw new Error(providerResponse.error || 'Failed to save provider settings'); } } this.showSuccess('Settings saved successfully'); this.hideSettingsModal(); // Reload providers to reflect changes this.loadProviders(); } catch (error) { this.showError(`Error saving settings: ${error.message}`); } } /** * Reset settings to defaults */ async resetSettings() { try { // Clear all API key inputs const apiKeyInputs = document.querySelectorAll('#api-key-inputs input[type="password"]'); apiKeyInputs.forEach(input => { input.value = ''; }); // Reset all provider checkboxes to enabled (default) const providerCheckboxes = document.querySelectorAll('.provider-toggle'); providerCheckboxes.forEach(checkbox => { checkbox.checked = true; this.updateCheckboxAppearance(checkbox); }); // Reset recursion depth to default const depthSelect = document.getElementById('max-depth'); if (depthSelect) { depthSelect.value = '2'; } this.showInfo('Settings reset to defaults'); } catch (error) { this.showError(`Error resetting settings: ${error.message}`); } } /** * 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 = `
${info.display_name}
${statusText}
Requests: ${info.statistics.total_requests || 0}
Success Rate: ${(info.statistics.success_rate || 0).toFixed(1)}%
Relationships: ${info.statistics.relationships_found || 0}
Rate Limit: ${info.rate_limit}/min
`; this.elements.providerList.appendChild(providerItem); } } /** * UPDATED: Enhanced node details HTML generation for unified data model * Now properly groups attributes by provider/type with organized sections */ generateNodeDetailsHtml(node) { if (!node) return '
Details not available.
'; let detailsHtml = ''; return detailsHtml; } /** * UPDATED: Generate details for standard nodes with organized attribute grouping and data warnings */ generateStandardNodeDetails(node) { let html = ''; // Check for and display a crt.sh data warning if it exists const crtshWarningAttr = this.findAttributeByName(node.attributes, 'crtsh_data_warning'); if (crtshWarningAttr) { html += ` `; } // Relationships sections html += this.generateRelationshipsSection(node); // UPDATED: Enhanced attributes section with intelligent grouping (no formatting) if (node.attributes && Array.isArray(node.attributes) && node.attributes.length > 0) { html += this.generateOrganizedAttributesSection(node.attributes, node.type); } // Description section html += this.generateDescriptionSection(node); // Metadata section (collapsed by default) html += this.generateMetadataSection(node); return html; } /** * Helper method to find an attribute by name in the standardized attributes list * @param {Array} attributes - List of StandardAttribute objects * @param {string} name - Attribute name to find * @returns {Object|null} The attribute object if found, null otherwise */ findAttributeByName(attributes, name) { if (!Array.isArray(attributes)) { return null; } return attributes.find(attr => attr.name === name) || null; } generateOrganizedAttributesSection(attributes, nodeType) { if (!Array.isArray(attributes) || attributes.length === 0) { return ''; } const groups = this.groupAttributesByProviderAndType(attributes, nodeType); let html = ''; const sortedGroups = Object.entries(groups).sort((a, b) => { const priorityOrder = { 'high': 0, 'medium': 1, 'low': 2 }; return priorityOrder[a[1].priority] - priorityOrder[b[1].priority]; }); for (const [groupName, groupData] of sortedGroups) { if (groupData.attributes.length === 0) continue; const isOpen = groupData.priority === 'high'; html += ` '; } return html; } formatAttributeValue(attr) { const value = attr.value; const name = attr.name || ''; if (value === null || value === undefined) { return 'N/A'; } if (Array.isArray(value)) { if (value.length === 0) { return 'Empty Array'; } // ENHANCED: Special handling for specific DNS record types if (name.endsWith('_records') || name.includes('record')) { const recordType = name.replace('_records', '').toUpperCase(); // Format nicely for DNS records if (value.length <= 5) { const formattedRecords = value.map(record => { // Add record type prefix if not already present if (recordType !== 'DNS' && !record.includes(':')) { return `${recordType}: ${record}`; } return record; }); return this.escapeHtml(formattedRecords.join('\n')); } else { const preview = value.slice(0, 3).map(record => { if (recordType !== 'DNS' && !record.includes(':')) { return `${recordType}: ${record}`; } return record; }).join('\n'); return this.escapeHtml(`${preview}\n... (+${value.length - 3} more ${recordType} records)`); } } // For other arrays (existing logic) if (value.length <= 3) { return this.escapeHtml(value.join(', ')); } else { const preview = value.slice(0, 2).join(', '); return this.escapeHtml(`${preview} ... (${value.length} total)`); } } if (typeof value === 'object') { return 'Object'; } return this.escapeHtml(String(value)); } groupAttributesByProviderAndType(attributes, nodeType) { if (!Array.isArray(attributes) || attributes.length === 0) { return {}; } const groups = { 'DNS Records': { icon: '📋', priority: 'high', attributes: [] }, 'Certificate Information': { icon: '🔒', priority: 'high', attributes: [] }, 'Network Information': { icon: '🌐', priority: 'high', attributes: [] }, 'Provider Data': { icon: '📊', priority: 'medium', attributes: [] }, 'Technical Details': { icon: '⚙️', priority: 'low', attributes: [] } }; for (const attr of attributes) { const provider = (attr.provider || '').toLowerCase(); const name = (attr.name || '').toLowerCase(); const type = (attr.type || '').toLowerCase(); let assigned = false; // ENHANCED: Better DNS record detection for specific record types if (provider === 'dns' || name.endsWith('_records') || // Catches a_records, mx_records, txt_records, etc. name.includes('record') || ['ptr', 'mx', 'cname', 'ns', 'txt', 'soa', 'srv', 'caa', 'a_records', 'aaaa_records'].some(keyword => name.includes(keyword))) { groups['DNS Records'].attributes.push(attr); assigned = true; } // Certificate-related attributes else if (provider === 'crtsh' || name.startsWith('cert_') || ['certificate', 'ssl', 'tls', 'issuer', 'validity', 'san'].some(keyword => name.includes(keyword))) { groups['Certificate Information'].attributes.push(attr); assigned = true; } // Network/Shodan attributes else if (provider === 'shodan' || ['port', 'service', 'banner', 'asn', 'organization', 'country', 'city', 'network'].some(keyword => name.includes(keyword))) { groups['Network Information'].attributes.push(attr); assigned = true; } // Provider-specific data else if (provider && ['shodan_', 'crtsh_', 'dns_'].some(prefix => name.startsWith(prefix))) { groups['Provider Data'].attributes.push(attr); assigned = true; } // If not assigned to any specific group, put in technical details if (!assigned) { groups['Technical Details'].attributes.push(attr); } } // Remove empty groups Object.keys(groups).forEach(groupName => { if (groups[groupName].attributes.length === 0) { delete groups[groupName]; } }); return groups; } formatEdgeLabel(relationshipType, confidence) { if (!relationshipType) return ''; const confidenceText = confidence >= 0.8 ? '●' : confidence >= 0.6 ? '◐' : '○'; return `${relationshipType} ${confidenceText}`; } createEdgeTooltip(edge) { let tooltip = `
`; tooltip += `
${edge.label || 'Relationship'}
`; tooltip += `
Confidence: ${(edge.confidence_score * 100).toFixed(1)}%
`; // UPDATED: Use raw provider name (no formatting) if (edge.source_provider) { tooltip += `
Provider: ${edge.source_provider}
`; } if (edge.discovery_timestamp) { const date = new Date(edge.discovery_timestamp); tooltip += `
Discovered: ${date.toLocaleString()}
`; } tooltip += `
`; return tooltip; } /** * UPDATED: Enhanced correlation details showing the correlated attribute clearly (no formatting) */ generateCorrelationDetails(node) { const attributes = node.attributes || []; const correlationValueAttr = attributes.find(attr => attr.name === 'correlation_value'); const value = correlationValueAttr ? correlationValueAttr.value : 'Unknown'; const metadataAttr = attributes.find(attr => attr.name === 'correlation_value'); const metadata = metadataAttr ? metadataAttr.metadata : {}; const correlatedNodes = metadata.correlated_nodes || []; const sources = metadata.sources || []; let html = ''; // Show what attribute is being correlated (raw names) const primarySource = sources.length > 0 ? sources[0].attribute : 'unknown'; html += ` `; // Show the correlated nodes if (correlatedNodes.length > 0) { html += ` '; } return html; } /** * UPDATED: Generate large entity details using unified data model */ generateLargeEntityDetails(node) { // Look for attributes in the unified model structure const attributes = node.attributes || []; const nodesAttribute = attributes.find(attr => attr.name === 'nodes'); const countAttribute = attributes.find(attr => attr.name === 'count'); const nodeTypeAttribute = attributes.find(attr => attr.name === 'node_type'); const sourceProviderAttribute = attributes.find(attr => attr.name === 'source_provider'); const discoveryDepthAttribute = attributes.find(attr => attr.name === 'discovery_depth'); const nodes = nodesAttribute ? nodesAttribute.value : []; const count = countAttribute ? countAttribute.value : 0; const nodeType = nodeTypeAttribute ? nodeTypeAttribute.value : 'nodes'; const sourceProvider = sourceProviderAttribute ? sourceProviderAttribute.value : 'Unknown'; const discoveryDepth = discoveryDepthAttribute ? discoveryDepthAttribute.value : 'Unknown'; let html = ` '; return html; } generateRelationshipsSection(node) { let html = ''; if (node.incoming_edges && node.incoming_edges.length > 0) { html += ` '; } if (node.outgoing_edges && node.outgoing_edges.length > 0) { html += ` '; } return html; } formatObjectCompact(obj) { if (!obj || typeof obj !== 'object') return ''; const entries = Object.entries(obj); if (entries.length <= 2) { let html = ''; entries.forEach(([key, value]) => { html += `
${key}: ${this.escapeHtml(String(value))}
`; }); return html; } // For complex objects, show first entry with expansion return `
${entries[0][0]}: ${this.escapeHtml(String(entries[0][1]))}
+${entries.length - 1} more properties...
${entries.slice(1).map(([key, value]) => `
${key}: ${this.escapeHtml(String(value))}
` ).join('')}
`; } generateDescriptionSection(node) { if (!node.description) return ''; return `

📄Description

${this.escapeHtml(node.description)}
`; } generateMetadataSection(node) { if (!node.metadata || Object.keys(node.metadata).length === 0) return ''; return ` `; } 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 '

No data available.

'; } let html = ''; return html; } /** * Enhanced showNodeModal with better event handling */ showNodeModal(node) { if (!this.elements.nodeModal || !node) return; if (this.elements.modalTitle) { this.elements.modalTitle.innerHTML = ` ${this.getNodeTypeIcon(node.type)} ${node.id} `; } 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'); 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 { console.log(`Extracting node ${nodeId} from large entity ${largeEntityId}`); // Show immediate feedback const button = document.querySelector(`[data-node-id="${nodeId}"][data-large-entity-id="${largeEntityId}"]`); if (button) { const originalContent = button.innerHTML; button.innerHTML = '[...]'; button.disabled = true; } 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); // FIXED: Don't update local modal data - let backend be source of truth // Force immediate graph update to get fresh backend data console.log('Extraction successful, updating graph with fresh backend data'); await this.updateGraph(); // FIXED: Re-fetch graph data instead of manipulating local state setTimeout(async () => { try { const graphResponse = await this.apiCall('/api/graph'); if (graphResponse.success) { this.graphManager.updateGraph(graphResponse.graph); // Update modal with fresh data if still open if (this.elements.nodeModal && this.elements.nodeModal.style.display === 'block') { if (this.graphManager.nodes) { const updatedLargeEntity = this.graphManager.nodes.get(largeEntityId); if (updatedLargeEntity) { this.showNodeModal(updatedLargeEntity); } } } } } catch (error) { console.error('Error refreshing graph after extraction:', error); } }, 100); } 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}`); // Restore button state on error const button = document.querySelector(`[data-node-id="${nodeId}"][data-large-entity-id="${largeEntityId}"]`); if (button) { button.innerHTML = '[+]'; button.disabled = false; } } } 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); } } /** * 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 = ''; }); } /** * Make API call to server * @param {string} endpoint - API endpoint * @param {string} method - HTTP method * @param {Object} data - Request data * @returns {Promise} Response data */ async apiCall(endpoint, method = 'GET', data = null) { 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); } const response = await fetch(endpoint, options); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); return result; } catch (error) { console.error(`API call failed for ${method} ${endpoint}:`, error); throw error; } } /** * 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 target (domain or IP) - UPDATED for IPv6 support * @param {string} target - Target to validate * @returns {boolean} True if valid */ isValidTarget(target) { return this.isValidDomain(target) || this.isValidIp(target); } /** * Validate IP address (IPv4 or IPv6) * @param {string} ip - IP to validate * @returns {boolean} True if valid */ isValidIp(ip) { console.log(`Validating IP: "${ip}"`); if (!ip || typeof ip !== 'string') { return false; } ip = ip.trim(); // IPv4 validation if (this.isValidIPv4(ip)) { return true; } // IPv6 validation if (this.isValidIPv6(ip)) { return true; } return false; } /** * Validate IPv4 address * @param {string} ip - IP to validate * @returns {boolean} True if valid IPv4 */ isValidIPv4(ip) { const ipv4Pattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; const match = ip.match(ipv4Pattern); if (!match) { return false; } // Check each octet is between 0-255 for (let i = 1; i <= 4; i++) { const octet = parseInt(match[i], 10); if (octet < 0 || octet > 255) { return false; } // Check for leading zeros (except for '0' itself) if (match[i].length > 1 && match[i][0] === '0') { return false; } } return true; } /** * Validate IPv6 address * @param {string} ip - IP to validate * @returns {boolean} True if valid IPv6 */ isValidIPv6(ip) { // Handle IPv6 with embedded IPv4 (e.g., ::ffff:192.168.1.1) if (ip.includes('.')) { const lastColon = ip.lastIndexOf(':'); if (lastColon !== -1) { const ipv6Part = ip.substring(0, lastColon + 1); const ipv4Part = ip.substring(lastColon + 1); if (this.isValidIPv4(ipv4Part)) { // Validate the IPv6 part (should end with ::) return this.isValidIPv6Pure(ipv6Part + '0:0'); } } } return this.isValidIPv6Pure(ip); } /** * Validate pure IPv6 address (no embedded IPv4) * @param {string} ip - IPv6 address to validate * @returns {boolean} True if valid IPv6 */ isValidIPv6Pure(ip) { // Basic format check if (!ip || ip.length < 2 || ip.length > 39) { return false; } // Check for invalid characters if (!/^[0-9a-fA-F:]+$/.test(ip)) { return false; } // Handle double colon (::) for zero compression const doubleColonCount = (ip.match(/::/g) || []).length; if (doubleColonCount > 1) { return false; // Only one :: allowed } let parts; if (doubleColonCount === 1) { // Expand the :: notation const [before, after] = ip.split('::'); const beforeParts = before ? before.split(':') : []; const afterParts = after ? after.split(':') : []; // Calculate how many zero groups the :: represents const totalParts = beforeParts.length + afterParts.length; const zeroGroups = 8 - totalParts; if (zeroGroups < 1) { return false; // :: must represent at least one zero group } // Build the full address parts = beforeParts.concat(Array(zeroGroups).fill('0')).concat(afterParts); } else { // No :: notation, split normally parts = ip.split(':'); } // IPv6 should have exactly 8 groups if (parts.length !== 8) { return false; } // Validate each group (1-4 hex digits) for (const part of parts) { if (!part || part.length > 4 || !/^[0-9a-fA-F]+$/.test(part)) { return false; } } 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 value for display * @param {*} value - Raw value * @returns {string} Formatted value */ formatValue(value) { if (typeof value === 'object' && value !== null) { // Use
 for nicely formatted JSON
            return `
${JSON.stringify(value, null, 2)}
`; } else { // Escape HTML to prevent XSS issues with string values const strValue = String(value); return strValue.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') { // Create message element const messageElement = document.createElement('div'); messageElement.className = `message-toast message-${type}`; messageElement.innerHTML = `
${message}
`; // 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; } } // 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 DNScopeApp instance...'); const app = new DNScopeApp();