dnscope/static/js/main.js
2025-09-17 23:55:41 +02:00

2550 lines
95 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
* UPDATED: Now compatible with a strictly flat, unified data model for attributes.
*/
class DNSReconApp {
constructor() {
console.log('DNSReconApp constructor called');
this.graphManager = null;
this.scanStatus = 'idle';
this.pollInterval = null;
this.currentSessionId = null;
this.elements = {};
this.isScanning = false;
this.lastGraphUpdate = null;
this.init();
}
/**
* Initialize the application
*/
init() {
console.log('DNSReconApp init called');
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing application...');
try {
this.initializeElements();
this.setupEventHandlers();
this.initializeGraph();
this.updateStatus();
this.loadProviders();
this.initializeEnhancedModals();
this.addCheckboxStyling();
this.updateGraph();
console.log('DNSRecon application initialized successfully');
} catch (error) {
console.error('Failed to initialize DNSRecon application:', error);
this.showError(`Initialization failed: ${error.message}`);
}
});
}
/**
* Initialize DOM element references
*/
initializeElements() {
console.log('Initializing DOM elements...');
this.elements = {
// Form elements
targetInput: document.getElementById('target-input'),
maxDepth: document.getElementById('max-depth'),
startScan: document.getElementById('start-scan'),
addToGraph: document.getElementById('add-to-graph'),
stopScan: document.getElementById('stop-scan'),
exportOptions: document.getElementById('export-options'),
exportModal: document.getElementById('export-modal'),
exportModalClose: document.getElementById('export-modal-close'),
exportGraphJson: document.getElementById('export-graph-json'),
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());
}
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
*/
initializeGraph() {
try {
console.log('Initializing graph manager...');
this.graphManager = new GraphManager('network-graph');
console.log('Graph manager initialized successfully');
} catch (error) {
console.error('Failed to initialize graph manager:', error);
this.showError('Failed to initialize graph visualization');
}
}
/**
* Start scan with error handling
*/
async startScan(clearGraph = true, forceRescanTarget = null) {
console.log('=== STARTING SCAN ===');
try {
const target = this.elements.targetInput.value.trim();
const maxDepth = parseInt(this.elements.maxDepth.value);
console.log(`Target: "${target}", Max depth: ${maxDepth}`);
// Validation
if (!target) {
console.log('Validation failed: empty target');
this.showError('Please enter a target domain or IP');
this.elements.targetInput.focus();
return;
}
if (!this.isValidTarget(target)) {
console.log(`Validation failed: invalid target format for "${target}"`);
this.showError('Please enter a valid domain name (e.g., example.com) or IP address (e.g., 8.8.8.8)');
this.elements.targetInput.focus();
return;
}
console.log('Validation passed, setting UI state to scanning...');
this.setUIState('scanning');
this.showInfo('Starting reconnaissance scan...');
console.log('Making API call to start scan...');
const requestData = {
target: target,
max_depth: maxDepth,
clear_graph: clearGraph,
force_rescan_target: forceRescanTarget
};
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();
}
console.log(`Scan started for ${target} with depth ${maxDepth}`);
// Start polling immediately with faster interval for responsiveness
this.startPolling(1000);
// Force an immediate status update
console.log('Forcing immediate status update...');
setTimeout(() => {
this.updateStatus();
this.updateGraph();
}, 100);
} else {
throw new Error(response.error || 'Failed to start scan');
}
} catch (error) {
console.error('Failed to start scan:', error);
this.showError(`Failed to start scan: ${error.message}`);
this.setUIState('idle');
}
}
/**
* Scan stop with immediate UI feedback
*/
async stopScan() {
try {
console.log('Stopping scan...');
// Immediately disable stop button and show stopping state
if (this.elements.stopScan) {
this.elements.stopScan.disabled = true;
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOPPING]</span><span>Stopping...</span>';
}
// Show immediate feedback
this.showInfo('Stopping scan...');
const response = await this.apiCall('/api/scan/stop', 'POST');
if (response.success) {
this.showSuccess('Scan stop requested');
// 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>';
}
}
}
/**
* 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 = '<span class="btn-icon">[...]</span><span>Exporting...</span>';
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 = 'dnsrecon_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 ||
'<span class="btn-icon">[JSON]</span><span>Export Graph Data</span>';
this.elements.exportGraphJson.innerHTML = originalContent;
this.elements.exportGraphJson.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.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
/**
* 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
*/
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);
// FIXED: Always update graph, even if empty - let GraphManager handle placeholder
if (this.graphManager) {
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.error('Graph update failed:', response);
// FIXED: 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);
// FIXED: 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');
// 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) {
const isQueueEmpty = task_queue_size === 0;
switch (state) {
case 'scanning':
this.isScanning = true;
if (this.graphManager) {
this.graphManager.isScanning = true;
}
if (this.elements.startScan) {
this.elements.startScan.disabled = true;
this.elements.startScan.classList.add('loading');
this.elements.startScan.innerHTML = '<span class="btn-icon">[SCANNING]</span><span>Scanning...</span>';
}
if (this.elements.addToGraph) {
this.elements.addToGraph.disabled = true;
this.elements.addToGraph.classList.add('loading');
}
if (this.elements.stopScan) {
this.elements.stopScan.disabled = false;
this.elements.stopScan.classList.remove('loading');
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOP]</span><span>Terminate Scan</span>';
}
if (this.elements.targetInput) this.elements.targetInput.disabled = true;
if (this.elements.maxDepth) this.elements.maxDepth.disabled = true;
if (this.elements.configureSettings) this.elements.configureSettings.disabled = true;
break;
case 'idle':
case 'completed':
case 'failed':
case 'stopped':
this.isScanning = false;
if (this.graphManager) {
this.graphManager.isScanning = false;
}
if (this.elements.startScan) {
this.elements.startScan.disabled = !isQueueEmpty;
this.elements.startScan.classList.remove('loading');
this.elements.startScan.innerHTML = '<span class="btn-icon">[RUN]</span><span>Start Reconnaissance</span>';
}
if (this.elements.addToGraph) {
this.elements.addToGraph.disabled = !isQueueEmpty;
this.elements.addToGraph.classList.remove('loading');
}
if (this.elements.stopScan) {
this.elements.stopScan.disabled = true;
this.elements.stopScan.innerHTML = '<span class="btn-icon">[STOP]</span><span>Terminate Scan</span>';
}
if (this.elements.targetInput) this.elements.targetInput.disabled = false;
if (this.elements.maxDepth) this.elements.maxDepth.disabled = false;
if (this.elements.configureSettings) this.elements.configureSettings.disabled = false;
break;
}
}
/**
* Load provider information
*/
async loadProviders() {
try {
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 = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status ${statusClass}">
${statusIcon} ${info.enabled ? 'Enabled' : 'Disabled'}
</div>
</div>
<div class="status-row">
<div class="status-label">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<input type="checkbox"
data-provider="${name}"
class="provider-toggle"
${info.enabled ? 'checked' : ''}
style="appearance: none; width: 16px; height: 16px; border: 2px solid #555; background: #1a1a1a; cursor: pointer; position: relative;">
<span>Auto-process with this provider</span>
</label>
</div>
</div>
`;
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 = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status enabled">✓ Backend Configured</div>
</div>
<div class="api-key-status-row" style="padding: 0.75rem; background: rgba(0, 255, 65, 0.1); border-radius: 4px; border: 1px solid rgba(0, 255, 65, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="status-value">API Key Active</div>
<div class="status-label" style="font-size: 0.8rem;">
Configured via environment variable
</div>
</div>
</div>
</div>
`;
} else if (info.api_key_configured && !isBackendConfigured) {
// API key is configured via frontend - show status with clear option
inputGroup.innerHTML = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status enabled">✓ Web Configured</div>
</div>
<div class="api-key-status-row" style="padding: 0.75rem; background: rgba(0, 255, 65, 0.1); border-radius: 4px; border: 1px solid rgba(0, 255, 65, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<div class="status-value">API Key Active</div>
<div class="status-label" style="font-size: 0.8rem;">
Set via web interface (session-only)
</div>
</div>
<button class="clear-api-key-btn btn-secondary" data-provider="${name}" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;">
<span class="btn-icon">[×]</span>
<span>Clear</span>
</button>
</div>
</div>
`;
} 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 = `
<div class="provider-header">
<div class="provider-name">${info.display_name}</div>
<div class="provider-status ${statusClass}">
${statusText}
</div>
</div>
<div class="input-group">
<label for="${name}-api-key">API Key</label>
<input type="password"
id="${name}-api-key"
data-provider="${name}"
placeholder="Enter ${info.display_name} API Key"
autocomplete="off">
<div class="apikey-help">
${info.api_key_help || `Provides enhanced ${info.display_name.toLowerCase()} data and context.`}
${!info.enabled ? ' Enable the provider above to use this API key.' : ''}
</div>
</div>
`;
}
apiKeyInputs.appendChild(inputGroup);
}
}
if (!hasApiKeyProviders) {
apiKeyInputs.innerHTML = `
<div class="status-row">
<div class="status-label">No providers require API keys</div>
<div class="status-value">All Active</div>
</div>
`;
}
// 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 = '<span class="btn-icon">[...]</span><span>Clearing...</span>';
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 = `
<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);
}
}
/**
* 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 '<div class="detail-row"><span class="detail-value">Details not available.</span></div>';
let detailsHtml = '<div class="modal-details">';
// Node Header - compact
detailsHtml += `
<div class="node-header">
<div class="node-type-badge node-type-${node.type}">${this.formatStatus(node.type)}</div>
<div class="node-id-display">${node.id}</div>
</div>
`;
// Quick Stats Bar - compact
const incomingCount = node.incoming_edges ? node.incoming_edges.length : 0;
const outgoingCount = node.outgoing_edges ? node.outgoing_edges.length : 0;
detailsHtml += `
<div class="quick-stats">
<div class="stat-item">
<span class="stat-icon">←</span>
<span class="stat-value">${incomingCount}</span>
<span class="stat-label">In</span>
</div>
<div class="stat-item">
<span class="stat-icon">→</span>
<span class="stat-value">${outgoingCount}</span>
<span class="stat-label">Out</span>
</div>
</div>
`;
// Handle different node types
if (node.type === 'correlation_object') {
detailsHtml += this.generateCorrelationDetails(node);
} else if (node.type === 'large_entity') {
detailsHtml += this.generateLargeEntityDetails(node);
} else {
detailsHtml += this.generateStandardNodeDetails(node);
}
detailsHtml += '</div>';
return detailsHtml;
}
/**
* UPDATED: Generate details for standard nodes with organized attribute grouping
*/
generateStandardNodeDetails(node) {
let 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;
}
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 += `
<div class="modal-section">
<details ${isOpen ? 'open' : ''}>
<summary>
<span>${groupData.icon} ${groupName}</span>
<span class="merge-badge">${groupData.attributes.length}</span>
</summary>
<div class="modal-section-content">
<div class="attribute-list">
`;
groupData.attributes.forEach(attr => {
html += `
<div class="attribute-item-compact">
<span class="attribute-key-compact">${this.escapeHtml(attr.name || 'Unknown')}</span>
<span class="attribute-value-compact">${this.formatAttributeValue(attr)}</span>
</div>
`;
});
html += '</div></div></details></div>';
}
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 = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`;
tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`;
tooltip += `<div style="color: #999; margin-bottom: 2px;">Confidence: ${(edge.confidence_score * 100).toFixed(1)}%</div>`;
// UPDATED: Use raw provider name (no formatting)
if (edge.source_provider) {
tooltip += `<div style="color: #999; margin-bottom: 2px;">Provider: ${edge.source_provider}</div>`;
}
if (edge.discovery_timestamp) {
const date = new Date(edge.discovery_timestamp);
tooltip += `<div style="color: #666; font-size: 10px;">Discovered: ${date.toLocaleString()}</div>`;
}
tooltip += `</div>`;
return tooltip;
}
/**
* UPDATED: Enhanced correlation details showing the correlated attribute clearly (no formatting)
*/
generateCorrelationDetails(node) {
const metadata = node.metadata || {};
const value = metadata.value;
const correlatedNodes = metadata.correlated_nodes || [];
const sources = metadata.sources || [];
let html = '';
// Show what attribute is being correlated (raw names)
const primarySource = metadata.primary_source || 'unknown';
html += `
<div class="modal-section">
<details open>
<summary>
<span>🔗 Correlation: ${primarySource}</span>
<span class="merge-badge">${correlatedNodes.length}</span>
</summary>
<div class="modal-section-content">
<div class="attribute-list">
<div class="attribute-item-compact">
<span class="attribute-key-compact">Shared Value</span>
<span class="attribute-value-compact"><code>${this.escapeHtml(String(value))}</code></span>
</div>
<div class="attribute-item-compact">
<span class="attribute-key-compact">Attribute Source</span>
<span class="attribute-value-compact">${primarySource}</span>
</div>
<div class="attribute-item-compact">
<span class="attribute-key-compact">Correlated Nodes</span>
<span class="attribute-value-compact">${correlatedNodes.length} nodes</span>
</div>
</div>
</div>
</details>
</div>
`;
// Show the correlated nodes
if (correlatedNodes.length > 0) {
html += `
<div class="modal-section">
<details>
<summary>🌐 Correlated Nodes (${correlatedNodes.length})</summary>
<div class="modal-section-content">
<div class="relationship-compact">
`;
correlatedNodes.forEach(nodeId => {
html += `
<div class="relationship-compact-item">
<span class="node-link-compact" data-node-id="${nodeId}">${nodeId}</span>
</div>
`;
});
html += '</div></div></details></div>';
}
return html;
}
/**
* 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 = `
<div class="modal-section">
<details open>
<summary>📦 Entity Summary</summary>
<div class="modal-section-content">
<div class="attribute-list">
<div class="attribute-item-compact">
<span class="attribute-key-compact">Contains</span>
<span class="attribute-value-compact">${count} ${nodeType}s</span>
</div>
<div class="attribute-item-compact">
<span class="attribute-key-compact">Provider</span>
<span class="attribute-value-compact">${sourceProvider}</span>
</div>
<div class="attribute-item-compact">
<span class="attribute-key-compact">Depth</span>
<span class="attribute-value-compact">${discoveryDepth}</span>
</div>
</div>
</div>
</details>
</div>
<div class="modal-section">
<details open>
<summary>📋 Contained ${nodeType}s (${Array.isArray(nodes) ? nodes.length : 0})</summary>
<div class="modal-section-content">
<div class="relationship-compact">
`;
const largeEntityId = node.id;
if (Array.isArray(nodes)) {
nodes.forEach(innerNodeId => {
html += `
<div class="relationship-compact-item">
<span class="node-link-compact" data-node-id="${innerNodeId}">${innerNodeId}</span>
<button class="btn-icon-small extract-node-btn"
title="Extract to graph"
data-large-entity-id="${largeEntityId}"
data-node-id="${innerNodeId}">[+]</button>
</div>
`;
});
}
html += '</div></div></details></div>';
return html;
}
generateRelationshipsSection(node) {
let html = '';
if (node.incoming_edges && node.incoming_edges.length > 0) {
html += `
<div class="modal-section">
<details>
<summary>⬅️ Source Relationships (${node.incoming_edges.length})</summary>
<div class="modal-section-content">
<div class="relationship-list">
`;
node.incoming_edges.forEach(edge => {
const confidence = edge.data.confidence_score || 0;
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
html += `
<div class="relationship-item">
<div class="relationship-source node-link" data-node-id="${edge.from}">
${edge.from}
</div>
<div class="relationship-type">
<span class="relation-label">${edge.data.relationship_type}</span>
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
${'●'.repeat(Math.ceil(confidence * 3))}
</span>
</div>
</div>
`;
});
html += '</div></div></details></div>';
}
if (node.outgoing_edges && node.outgoing_edges.length > 0) {
html += `
<div class="modal-section">
<details>
<summary>➡️ Target Relationships (${node.outgoing_edges.length})</summary>
<div class="modal-section-content">
<div class="relationship-list">
`;
node.outgoing_edges.forEach(edge => {
const confidence = edge.data.confidence_score || 0;
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
html += `
<div class="relationship-item">
<div class="relationship-target node-link" data-node-id="${edge.to}">
${edge.to}
</div>
<div class="relationship-type">
<span class="relation-label">${edge.data.relationship_type}</span>
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
${'●'.repeat(Math.ceil(confidence * 3))}
</span>
</div>
</div>
`;
});
html += '</div></div></details></div>';
}
return html;
}
formatObjectCompact(obj) {
if (!obj || typeof obj !== 'object') return '';
const entries = Object.entries(obj);
if (entries.length <= 2) {
let html = '';
entries.forEach(([key, value]) => {
html += `<div><strong>${key}:</strong> ${this.escapeHtml(String(value))}</div>`;
});
return html;
}
// For complex objects, show first entry with expansion
return `
<div><strong>${entries[0][0]}:</strong> ${this.escapeHtml(String(entries[0][1]))}</div>
<details class="object-more">
<summary>+${entries.length - 1} more properties...</summary>
<div class="object-display">
${entries.slice(1).map(([key, value]) =>
`<div><strong>${key}:</strong> ${this.escapeHtml(String(value))}</div>`
).join('')}
</div>
</details>
`;
}
generateDescriptionSection(node) {
if (!node.description) return '';
return `
<div class="section-card description-section">
<div class="section-header">
<h4><span class="section-icon">📄</span>Description</h4>
</div>
<div class="description-content">
${this.escapeHtml(node.description)}
</div>
</div>
`;
}
generateMetadataSection(node) {
if (!node.metadata || Object.keys(node.metadata).length === 0) return '';
return `
<div class="section-card metadata-section collapsed">
<div class="section-header">
<h4><span class="section-icon">🔧</span>Technical Metadata</h4>
<button class="toggle-section-btn" onclick="this.parentElement.parentElement.classList.toggle('collapsed')">
<span class="toggle-icon">▼</span>
</button>
</div>
<div class="metadata-content">
${this.formatObjectToHtml(node.metadata)}
</div>
</div>
`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Recursively formats a JavaScript object into an HTML unordered list with collapsible sections.
* @param {Object} obj - The object to format.
* @returns {string} - An HTML string representing the object.
*/
formatObjectToHtml(obj) {
if (!obj || Object.keys(obj).length === 0) {
return '<p class="no-data">No data available.</p>';
}
let html = '<ul>';
for (const key in obj) {
if (Object.hasOwnProperty.call(obj, key)) {
const value = obj[key];
const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
if (typeof value === 'object' && value !== null) {
html += `<li><details><summary><strong>${formattedKey}</strong></summary>`;
html += this.formatObjectToHtml(value);
html += `</details></li>`;
} else {
html += `<li><strong>${formattedKey}:</strong> ${this.formatValue(value)}</li>`;
}
}
}
html += '</ul>';
return html;
}
/**
* Enhanced showNodeModal with better event handling
*/
showNodeModal(node) {
if (!this.elements.nodeModal || !node) return;
if (this.elements.modalTitle) {
this.elements.modalTitle.innerHTML = `
<span class="modal-title-icon">${this.getNodeTypeIcon(node.type)}</span>
<span class="modal-title-text">${node.id}</span>
`;
}
const detailsHtml = this.generateNodeDetailsHtml(node);
if (this.elements.modalDetails) {
this.elements.modalDetails.innerHTML = detailsHtml;
// Add enhanced event handlers
this.addModalEventHandlers();
}
this.elements.nodeModal.style.display = 'block';
}
/**
* Add event handlers for enhanced modal interactions
*/
addModalEventHandlers() {
// Handle node navigation links - FIXED to work properly
this.elements.modalDetails.querySelectorAll('.node-link-compact').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const nodeId = e.target.dataset.nodeId || e.target.getAttribute('data-node-id');
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 {
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);
this.hideModal();
// If the scanner was idle, it's now running. Start polling to see the new node appear.
if (this.scanStatus === 'idle') {
this.startPolling(1000);
} else {
// If already scanning, force a quick graph update to see the change sooner.
setTimeout(() => this.updateGraph(), 500);
}
} else {
throw new Error(response.error || 'Extraction failed on the server.');
}
} catch (error) {
console.error('Failed to extract node:', error);
this.showError(`Extraction failed: ${error.message}`);
}
}
initializeModalFunctionality() {
// Make sure the graph manager has node access
console.log('Initializing modal functionality...');
// Set up event delegation for dynamic content
document.addEventListener('click', (e) => {
const target = e.target.closest('.node-link-compact, .node-link');
if (target) {
e.preventDefault();
e.stopPropagation();
const nodeId = target.dataset.nodeId || target.getAttribute('data-node-id');
if (nodeId && this.graphManager && this.graphManager.nodes) {
const nextNode = this.graphManager.nodes.get(nodeId);
if (nextNode) {
this.showNodeModal(nextNode);
}
}
}
});
}
/**
* Get icon for node type
*/
getNodeTypeIcon(nodeType) {
const icons = {
'domain': '🌍',
'ip': '📍',
'asn': '🏢',
'large_entity': '📦',
'correlation_object': '🔗'
};
return icons[nodeType] || '●';
}
/**
* Enhanced hideModal with animation
*/
hideModal() {
if (this.elements.nodeModal) {
this.elements.nodeModal.classList.add('modal-closing');
setTimeout(() => {
this.elements.nodeModal.style.display = 'none';
this.elements.nodeModal.classList.remove('modal-closing');
}, 200);
}
}
/**
* 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<Object>} 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 <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') {
// 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;
}
}
// 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();