// Global error handler to prevent page refreshes window.addEventListener('error', function (e) { console.error('Global error caught:', e.error); console.error('Error message:', e.message); console.error('Error filename:', e.filename); console.error('Error line:', e.lineno); e.preventDefault(); // Prevent default browser error handling return true; // Prevent page refresh }); window.addEventListener('unhandledrejection', function (e) { console.error('Unhandled promise rejection:', e.reason); e.preventDefault(); // Prevent default browser error handling return true; // Prevent page refresh }); // Global state let nlLite = null; let userPubkey = null; let isLoggedIn = false; let currentConfig = null; // Relay connection state let relayInfo = null; let isRelayConnected = false; let relayPubkey = null; // Database statistics auto-refresh let statsAutoRefreshInterval = null; let countdownInterval = null; let countdownSeconds = 10; // Side navigation state let currentPage = 'statistics'; // Default page let sideNavOpen = false; // SQL Query state let pendingSqlQueries = new Map(); // Real-time event rate chart let eventRateChart = null; let previousTotalBlobs = 0; // Track previous total for rate calculation // Relay Events state - now handled by main subscription // DOM elements const loginModal = document.getElementById('login-modal'); const loginModalContainer = document.getElementById('login-modal-container'); const profileArea = document.getElementById('profile-area'); const headerUserImage = document.getElementById('header-user-image'); const headerUserName = document.getElementById('header-user-name'); // Legacy elements (kept for backward compatibility) const persistentUserName = document.getElementById('persistent-user-name'); const persistentUserPubkey = document.getElementById('persistent-user-pubkey'); const persistentUserAbout = document.getElementById('persistent-user-about'); const persistentUserDetails = document.getElementById('persistent-user-details'); const fetchConfigBtn = document.getElementById('fetch-config-btn'); const configDisplay = document.getElementById('config-display'); const configTableBody = document.getElementById('config-table-body'); // NIP-17 DM elements const dmOutbox = document.getElementById('dm-outbox'); const dmInbox = document.getElementById('dm-inbox'); const sendDmBtn = document.getElementById('send-dm-btn'); // Utility functions function log(message, type = 'INFO') { const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logMessage = `${timestamp} [${type}]: ${message}`; // Always log to browser console so we don't lose logs on refresh console.log(logMessage); // UI logging removed - using console only } // Utility functions function log(message, type = 'INFO') { const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logMessage = `${timestamp} [${type}]: ${message}`; // Always log to browser console so we don't lose logs on refresh console.log(logMessage); // UI logging removed - using console only } // NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days) // TEMPORARILY DISABLED: Using current timestamp for debugging function randomNow() { // const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds const now = Math.round(Date.now() / 1000); return now; // Math.round(now - Math.random() * TWO_DAYS); } // Safe JSON parse with error handling function safeJsonParse(jsonString) { try { return JSON.parse(jsonString); } catch (error) { console.error('JSON parse error:', error); return null; } } // ================================ // NIP-11 RELAY CONNECTION FUNCTIONS // ================================ // Fetch relay information using NIP-11 async function fetchRelayInfo(relayUrl) { try { log(`Fetching NIP-11 relay info from: ${relayUrl}`, 'INFO'); // Make HTTP request with NIP-11 headers const response = await fetch(relayUrl, { method: 'GET', headers: { 'Accept': 'application/nostr+json', 'User-Agent': 'Blossom-Admin-API/1.0' }, timeout: 10000 // 10 second timeout }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/nostr+json')) { throw new Error(`Invalid content type: ${contentType}. Expected application/nostr+json`); } const relayInfo = await response.json(); // Log if relay info is empty (not configured yet) but don't throw error if (!relayInfo || Object.keys(relayInfo).length === 0) { log('Relay returned empty NIP-11 info - relay not configured yet, will use manual pubkey if provided', 'INFO'); // Return empty object - this is valid, caller will handle manual pubkey fallback return {}; } // Validate pubkey if present if (relayInfo.pubkey && !/^[0-9a-fA-F]{64}$/.test(relayInfo.pubkey)) { throw new Error(`Invalid relay pubkey format: ${relayInfo.pubkey}`); } log(`Successfully fetched relay info. Pubkey: ${relayInfo.pubkey ? relayInfo.pubkey.substring(0, 16) + '...' : 'not set'}`, 'INFO'); return relayInfo; } catch (error) { log(`Failed to fetch relay info: ${error.message}`, 'ERROR'); throw error; } } // Check for existing authentication state with multiple API methods and retry logic async function checkExistingAuthWithRetries() { console.log('Starting authentication state detection with retry logic...'); const maxAttempts = 3; const delay = 200; // ms between attempts (reduced from 500ms) for (let attempt = 1; attempt <= maxAttempts; attempt++) { console.log(`Authentication detection attempt ${attempt}/${maxAttempts}`); try { // Method 1: Try window.NOSTR_LOGIN_LITE.getAuthState() if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.getAuthState === 'function') { console.log('Trying window.NOSTR_LOGIN_LITE.getAuthState()...'); const authState = window.NOSTR_LOGIN_LITE.getAuthState(); if (authState && authState.pubkey) { console.log('✅ Auth state found via NOSTR_LOGIN_LITE.getAuthState():', authState.pubkey); await restoreAuthenticationState(authState.pubkey); return true; } } // Method 2: Try nlLite.getPublicKey() if (nlLite && typeof nlLite.getPublicKey === 'function') { console.log('Trying nlLite.getPublicKey()...'); const pubkey = await nlLite.getPublicKey(); if (pubkey && pubkey.length === 64) { console.log('✅ Pubkey found via nlLite.getPublicKey():', pubkey); await restoreAuthenticationState(pubkey); return true; } } // Method 3: Try window.nostr.getPublicKey() (NIP-07) if (window.nostr && typeof window.nostr.getPublicKey === 'function') { console.log('Trying window.nostr.getPublicKey()...'); const pubkey = await window.nostr.getPublicKey(); if (pubkey && pubkey.length === 64) { console.log('✅ Pubkey found via window.nostr.getPublicKey():', pubkey); await restoreAuthenticationState(pubkey); return true; } } // Method 4: Check localStorage directly for NOSTR_LOGIN_LITE data const localStorageData = localStorage.getItem('NOSTR_LOGIN_LITE_DATA'); if (localStorageData) { try { const parsedData = JSON.parse(localStorageData); if (parsedData.pubkey) { console.log('✅ Pubkey found in localStorage:', parsedData.pubkey); await restoreAuthenticationState(parsedData.pubkey); return true; } } catch (parseError) { console.log('Failed to parse localStorage data:', parseError.message); } } console.log(`❌ Attempt ${attempt}: No authentication found via any method`); // Wait before next attempt (except for last attempt) if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)); } } catch (error) { console.log(`❌ Attempt ${attempt} failed:`, error.message); if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delay)); } } } console.log('🔍 Authentication detection completed - no existing auth found after all attempts'); return false; } // Helper function to restore authentication state async function restoreAuthenticationState(pubkey) { console.log('🔄 Restoring authentication state for pubkey:', pubkey); userPubkey = pubkey; isLoggedIn = true; // Show main interface and profile in header showProfileInHeader(); loadUserProfile(); // Automatically set up relay connection (but don't show admin sections yet) await setupAutomaticRelayConnection(); console.log('✅ Authentication state restored successfully'); } // Automatically set up relay connection based on current page URL async function setupAutomaticRelayConnection(showSections = false) { console.log('=== SETUP AUTOMATIC RELAY CONNECTION (HTTP MODE) ==='); console.log('showSections:', showSections); try { // Get the current page URL and convert to HTTP URL for NIP-11 const currentUrl = window.location.href; let httpUrl = ''; if (currentUrl.startsWith('https://')) { // Extract protocol and host only (remove /api or any path) const url = new URL(currentUrl); httpUrl = `${url.protocol}//${url.host}`; } else if (currentUrl.startsWith('http://')) { // Extract protocol and host only (remove /api or any path) const url = new URL(currentUrl); httpUrl = `${url.protocol}//${url.host}`; } else { // Fallback for development httpUrl = 'http://localhost:9443'; } console.log('🔗 Using base URL for NIP-11:', httpUrl); // Fetch relay info to get pubkey via NIP-11 (from root, not /api) try { const relayInfo = await fetchRelayInfo(httpUrl); if (relayInfo && relayInfo.pubkey) { relayPubkey = relayInfo.pubkey; console.log('✅ Fetched relay pubkey from NIP-11:', relayPubkey.substring(0, 16) + '...'); } else { throw new Error('NIP-11 response missing pubkey field'); } } catch (error) { console.error('❌ Failed to fetch relay pubkey from NIP-11:', error.message); throw new Error(`Cannot connect to server: ${error.message}`); } // Mark as connected (no actual relay connection needed for HTTP mode) isRelayConnected = true; // Update relay info in header updateRelayInfoInHeader(); // Only show admin sections if explicitly requested if (showSections) { updateAdminSectionsVisibility(); } console.log('✅ Automatic relay connection setup complete (HTTP mode)'); } catch (error) { console.error('❌ Failed to setup automatic relay connection:', error); // Still mark as connected to allow basic functionality isRelayConnected = true; if (showSections) { updateAdminSectionsVisibility(); } } } // Legacy function for backward compatibility async function checkExistingAuth() { return await checkExistingAuthWithRetries(); } // Initialize NOSTR_LOGIN_LITE async function initializeApp() { try { await window.NOSTR_LOGIN_LITE.init({ theme: 'default', methods: { extension: true, local: true, seedphrase: true, readonly: true, connect: true, remote: true, otp: false }, floatingTab: { enabled: false } }); nlLite = window.NOSTR_LOGIN_LITE; console.log('Nostr login system initialized'); // Check for existing authentication state after initialization const wasAlreadyLoggedIn = await checkExistingAuth(); if (wasAlreadyLoggedIn) { console.log('User was already logged in, showing profile in header'); showProfileInHeader(); // Show admin sections since user is already authenticated and relay is connected updateAdminSectionsVisibility(); } else { console.log('No existing authentication found, showing login modal'); showLoginModal(); } // Listen for authentication events window.addEventListener('nlMethodSelected', handleAuthEvent); window.addEventListener('nlLogout', handleLogoutEvent); } catch (error) { console.log('Failed to initialize Nostr login: ' + error.message); } } // Handle authentication events function handleAuthEvent(event) { const { pubkey, method, error } = event.detail; if (method && pubkey) { userPubkey = pubkey; isLoggedIn = true; console.log(`Login successful! Method: ${method}`); console.log(`Public key: ${pubkey}`); // Hide login modal and show profile in header hideLoginModal(); showProfileInHeader(); loadUserProfile(); // Automatically set up relay connection and show admin sections setupAutomaticRelayConnection(true); // Auto-enable monitoring when admin logs in autoEnableMonitoring(); } else if (error) { console.log(`Authentication error: ${error}`); } } // Handle logout events function handleLogoutEvent() { console.log('Logout event received'); userPubkey = null; isLoggedIn = false; currentConfig = null; // Reset relay connection state isRelayConnected = false; relayPubkey = null; // Reset UI - hide profile and show login modal hideProfileFromHeader(); showLoginModal(); updateConfigStatus(false); updateAdminSectionsVisibility(); console.log('Logout event handled successfully'); } // Update visibility of admin sections based on login and relay connection status function updateAdminSectionsVisibility() { const shouldShow = isLoggedIn && isRelayConnected; // If logged in and connected, show the current page, otherwise hide all sections if (shouldShow) { // Show the current page switchPage(currentPage); // Load data for the current page loadCurrentPageData(); } else { // Hide all sections when not logged in or not connected const sections = [ 'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config', 'authRulesSection', 'nip17DMSection', 'sqlQuerySection' ]; sections.forEach(sectionId => { const section = document.getElementById(sectionId); if (section) { section.style.display = 'none'; } }); stopStatsAutoRefresh(); } // Update countdown display when visibility changes updateCountdownDisplay(); } // Load data for the current page function loadCurrentPageData() { switch (currentPage) { case 'statistics': // Start HTTP polling for statistics (polls every 10 seconds) startStatsPolling(); break; case 'configuration': // Load configuration fetchConfiguration().catch(error => { console.log('Auto-fetch configuration failed: ' + error.message); }); break; case 'authorization': // Load auth rules loadAuthRules().catch(error => { console.log('Auto-load auth rules failed: ' + error.message); }); break; // Other pages don't need initial data loading } } // Show login modal function showLoginModal() { if (loginModal && loginModalContainer) { // Initialize the login UI in the modal if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') { window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true }); } loginModal.style.display = 'flex'; } } // Hide login modal function hideLoginModal() { if (loginModal) { loginModal.style.display = 'none'; } } // Show profile in header function showProfileInHeader() { if (profileArea) { profileArea.style.display = 'flex'; } } // Hide profile from header function hideProfileFromHeader() { if (profileArea) { profileArea.style.display = 'none'; } } // Update login/logout UI visibility (legacy function - kept for backward compatibility) function updateLoginLogoutUI() { // This function is now handled by showProfileInHeader() and hideProfileFromHeader() // Kept for backward compatibility with any existing code that might call it } // Show main interface after login (legacy function - kept for backward compatibility) function showMainInterface() { // This function is now handled by showProfileInHeader() and updateAdminSectionsVisibility() // Kept for backward compatibility with any existing code that might call it updateAdminSectionsVisibility(); } // Load user profile using nostr-tools pool async function loadUserProfile() { if (!userPubkey) return; console.log('Loading user profile...'); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Loading...'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Loading...'; } if (persistentUserAbout) { persistentUserAbout.textContent = 'Loading...'; } // Convert hex pubkey to npub for initial display let displayPubkey = userPubkey; let npubLink = ''; try { if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) { const npub = window.NostrTools.nip19.npubEncode(userPubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode user pubkey to npub:', error.message); } if (persistentUserPubkey) { if (npubLink) { persistentUserPubkey.innerHTML = npubLink; } else { persistentUserPubkey.textContent = displayPubkey; } } try { // Create a SimplePool instance for profile loading const profilePool = new window.NostrTools.SimplePool(); const relays = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.snort.social' ]; // Get profile event (kind 0) for the user with timeout const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Profile query timeout')), 5000) ); const queryPromise = profilePool.querySync(relays, { kinds: [0], authors: [userPubkey], limit: 1 }); const events = await Promise.race([queryPromise, timeoutPromise]); if (events.length > 0) { console.log('Profile event found:', events[0]); const profile = JSON.parse(events[0].content); console.log('Parsed profile:', profile); displayProfile(profile); } else { console.log('No profile events found for pubkey:', userPubkey); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Anonymous User'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Anonymous User'; } if (persistentUserAbout) { persistentUserAbout.textContent = 'No profile found'; } // Keep the npub display } // Properly close the profile pool with error handling try { await profilePool.close(relays); // Give time for cleanup await new Promise(resolve => setTimeout(resolve, 100)); } catch (closeError) { console.log('Profile pool close error (non-critical):', closeError.message); } } catch (error) { console.log('Profile loading failed: ' + error.message); // Update header display (new system) if (headerUserName) { headerUserName.textContent = 'Error loading profile'; } // Update legacy elements if they exist (backward compatibility) if (persistentUserName) { persistentUserName.textContent = 'Error loading profile'; } if (persistentUserAbout) { persistentUserAbout.textContent = error.message; } // Keep the npub display } } // Display profile data function displayProfile(profile) { const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User'; const about = profile.about || 'No description provided'; const picture = profile.picture || profile.image || null; // Convert hex pubkey to npub for display let displayPubkey = userPubkey; let npubLink = ''; try { if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) { const npub = window.NostrTools.nip19.npubEncode(userPubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode user pubkey to npub:', error.message); } // Update header profile display if (headerUserName) { headerUserName.textContent = name; } // Handle header profile picture if (headerUserImage) { if (picture && typeof picture === 'string' && (picture.startsWith('http') || picture.startsWith('https'))) { headerUserImage.src = picture; headerUserImage.style.display = 'block'; headerUserImage.onerror = function() { // Hide image on error this.style.display = 'none'; console.log('Profile image failed to load:', picture); }; } else { headerUserImage.style.display = 'none'; } } // Update legacy persistent user details (kept for backward compatibility) if (persistentUserName) persistentUserName.textContent = name; if (persistentUserPubkey && npubLink) { persistentUserPubkey.innerHTML = npubLink; } else if (persistentUserPubkey) { persistentUserPubkey.textContent = displayPubkey; } if (persistentUserAbout) persistentUserAbout.textContent = about; // Handle legacy profile picture const userImageContainer = document.getElementById('persistent-user-image'); if (userImageContainer) { if (picture && typeof picture === 'string' && picture.startsWith('http')) { // Create or update image element let img = userImageContainer.querySelector('img'); if (!img) { img = document.createElement('img'); img.className = 'user-profile-image'; img.alt = `${name}'s profile picture`; img.onerror = function() { // Hide image on error this.style.display = 'none'; }; userImageContainer.appendChild(img); } img.src = picture; img.style.display = 'block'; } else { // Hide image if no valid picture const img = userImageContainer.querySelector('img'); if (img) { img.style.display = 'none'; } } } console.log(`Profile loaded for: ${name} with pubkey: ${userPubkey}`); } // Logout function async function logout() { log('Logging out...', 'INFO'); try { // Stop auto-refresh before disconnecting stopStatsAutoRefresh(); await nlLite.logout(); userPubkey = null; isLoggedIn = false; currentConfig = null; // Reset relay connection state isRelayConnected = false; relayPubkey = null; // Reset UI - hide profile and show login modal hideProfileFromHeader(); updateConfigStatus(false); updateAdminSectionsVisibility(); log('Logged out successfully', 'INFO'); } catch (error) { log('Logout failed: ' + error.message, 'ERROR'); } } function updateConfigStatus(loaded) { if (loaded) { configDisplay.classList.remove('hidden'); } else { configDisplay.classList.add('hidden'); } } // Initialize real-time event rate chart function initializeEventRateChart() { try { console.log('=== INITIALIZING EVENT RATE CHART ==='); const chartContainer = document.getElementById('event-rate-chart'); console.log('Chart container found:', chartContainer); if (!chartContainer) { console.log('Event rate chart container not found'); return; } // Show immediate placeholder content chartContainer.textContent = 'Initializing event rate chart...'; console.log('Set placeholder content'); // Check if ASCIIBarChart is available console.log('Checking ASCIIBarChart availability...'); console.log('typeof ASCIIBarChart:', typeof ASCIIBarChart); console.log('window.ASCIIBarChart:', window.ASCIIBarChart); if (typeof ASCIIBarChart === 'undefined') { console.log('ASCIIBarChart not available - text_graph.js may not be loaded'); // Show a more detailed error message chartContainer.innerHTML = `
⚠️ Chart library not loaded
Check: /text_graph/text_graph.js
Real-time event visualization unavailable
`; return; } // Create stub elements that the chart expects for info display createChartStubElements(); console.log('Creating ASCIIBarChart instance...'); // Initialize the chart with correct parameters based on text_graph.js API eventRateChart = new ASCIIBarChart('event-rate-chart', { maxHeight: 11, // Chart height in lines maxDataPoints: 76, // Show last 76 bins (5+ minutes of history) title: 'New Blobs', // Chart title xAxisLabel: '', // No X-axis label yAxisLabel: '', // No Y-axis label autoFitWidth: true, // Enable responsive font sizing useBinMode: true, // Enable time bin aggregation binDuration: 4000, // 4-second time bins xAxisLabelFormat: 'elapsed', // Show elapsed time labels debug: false // Disable debug logging }); console.log('ASCIIBarChart instance created:', eventRateChart); console.log('Chart container content after init:', chartContainer.textContent); console.log('Chart container innerHTML after init:', chartContainer.innerHTML); // Force an initial render if (eventRateChart && typeof eventRateChart.render === 'function') { console.log('Forcing initial render...'); eventRateChart.render(); console.log('Chart container content after render:', chartContainer.textContent); } console.log('Event rate chart initialized successfully'); log('Real-time event rate chart initialized', 'INFO'); } catch (error) { console.error('Failed to initialize event rate chart:', error); console.error('Error stack:', error.stack); log(`Failed to initialize event rate chart: ${error.message}`, 'ERROR'); // Show detailed error message in the container const chartContainer = document.getElementById('event-rate-chart'); if (chartContainer) { chartContainer.innerHTML = `
❌ Chart initialization failed
${error.message}
Check browser console for details
`; } } } // Create stub elements that the ASCIIBarChart expects for info display function createChartStubElements() { const stubIds = ['values', 'max-value', 'scale', 'count']; stubIds.forEach(id => { if (!document.getElementById(id)) { const stubElement = document.createElement('div'); stubElement.id = id; stubElement.style.display = 'none'; // Hide stub elements document.body.appendChild(stubElement); } }); console.log('Chart stub elements created'); } // Handle monitoring events (kind 24567 - ephemeral) async function processMonitoringEvent(event) { try { // Verify this is a kind 24567 ephemeral monitoring event if (event.kind !== 24567) { return; } // Verify the event is from the relay const expectedRelayPubkey = getRelayPubkey(); if (event.pubkey !== expectedRelayPubkey) { return; } // Check the d-tag to determine which type of monitoring event this is const dTag = event.tags.find(tag => tag[0] === 'd'); if (!dTag) { return; } // Parse the monitoring data (content is JSON, not encrypted for monitoring events) const monitoringData = JSON.parse(event.content); // Route to appropriate handler based on d-tag (no verbose logging) switch (dTag[1]) { case 'event_kinds': updateStatsFromMonitoringEvent(monitoringData); break; case 'time_stats': updateStatsFromTimeMonitoringEvent(monitoringData); break; case 'top_pubkeys': updateStatsFromTopPubkeysMonitoringEvent(monitoringData); break; case 'subscription_details': // Only process subscription details if user is authenticated if (isLoggedIn) { updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData); // Also update the active subscriptions count from this data if (monitoringData.data && monitoringData.data.subscriptions) { updateStatsCell('active-subscriptions', monitoringData.data.subscriptions.length.toString()); } } break; case 'cpu_metrics': updateStatsFromCpuMonitoringEvent(monitoringData); break; default: return; } } catch (error) { console.error('Error processing monitoring event:', error); log(`Failed to process monitoring event: ${error.message}`, 'ERROR'); } } // Handle different types of admin response data function handleAdminResponseData(responseData) { try { console.log('=== HANDLING ADMIN RESPONSE DATA ==='); console.log('Response data:', responseData); console.log('Response query_type:', responseData.query_type); // Handle plain text responses (from create_relay_event and other commands) if (responseData.plain_text) { console.log('Handling plain text response'); log(responseData.message, 'INFO'); // Show the message in relay events status if we're on that page if (currentPage === 'relay-events') { // Try to determine which kind based on message content if (responseData.message.includes('Kind: 0')) { showStatus('kind0-status', responseData.message, 'success'); } else if (responseData.message.includes('Kind: 10050')) { showStatus('kind10050-status', responseData.message, 'success'); } else if (responseData.message.includes('Kind: 10002')) { showStatus('kind10002-status', responseData.message, 'success'); } } return; } // Handle auth query responses - updated to match backend response types if (responseData.query_type && (responseData.query_type.includes('auth_rules') || responseData.query_type.includes('auth'))) { console.log('Routing to auth query handler'); handleAuthQueryResponse(responseData); return; } // Handle config update responses specifically if (responseData.query_type === 'config_update') { console.log('Routing to config update handler'); handleConfigUpdateResponse(responseData); return; } // Handle config query responses - updated to match backend response types if (responseData.query_type && (responseData.query_type.includes('config') || responseData.query_type.startsWith('config_'))) { console.log('Routing to config query handler'); handleConfigQueryResponse(responseData); return; } // Handle system command responses if (responseData.command) { console.log('Routing to system command handler'); handleSystemCommandResponse(responseData); return; } // Handle auth rule modification responses if (responseData.operation || responseData.rules_processed !== undefined) { console.log('Routing to auth rule modification handler'); handleAuthRuleResponse(responseData); return; } // Handle stats query responses if (responseData.query_type === 'stats_query') { console.log('Routing to stats query handler'); handleStatsQueryResponse(responseData); return; } // Handle SQL query responses if (responseData.query_type === 'sql_query') { console.log('Routing to SQL query handler'); handleSqlQueryResponse(responseData); return; } // Generic response handling console.log('Using generic response handler'); if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE'); } } catch (error) { console.error('Error handling admin response data:', error); if (typeof logTestEvent === 'function') { logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR'); } } } // Handle config query responses function handleConfigQueryResponse(responseData) { console.log('=== CONFIG QUERY RESPONSE ==='); console.log('Query type:', responseData.query_type); console.log('Count:', responseData.count); console.log('Config:', responseData.config); console.log('Config type:', typeof responseData.config); // Backend returns config as an object with nested objects: {key1: {value: 'x', description: 'y'}, ...} const hasData = responseData.config && Object.keys(responseData.config).length > 0; if (hasData) { console.log('Converting config response to display format...'); // Create a synthetic event structure for displayConfiguration const syntheticEvent = { id: 'config_response_' + Date.now(), pubkey: getRelayPubkey(), created_at: Math.floor(Date.now() / 1000), kind: 'config_response', content: 'Configuration from admin API', tags: [] }; // Convert config object to tags format // Backend format: {key1: {value: 'x', description: 'y'}, key2: {value: 'z'}, ...} Object.entries(responseData.config).forEach(([key, configEntry]) => { if (key && configEntry && configEntry.value !== undefined) { syntheticEvent.tags.push([key, configEntry.value]); } }); console.log('Synthetic event created:', syntheticEvent); console.log('Calling displayConfiguration with synthetic event...'); // Display the configuration using the original display function displayConfiguration(syntheticEvent); // Update relay info in header with config data updateStoredRelayInfo(responseData); // Initialize toggle buttons with config data initializeToggleButtonsFromConfig(responseData); const configCount = responseData.count || Object.keys(responseData.config).length; log(`Configuration loaded: ${configCount} parameters`, 'INFO'); } else { console.log('No configuration data received'); updateConfigStatus(false); } // Also log to test interface for debugging if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Config query response: ${responseData.query_type}, ${responseData.count} results`, 'CONFIG_QUERY'); if (responseData.config && Object.keys(responseData.config).length > 0) { logTestEvent('RECV', '=== CONFIGURATION VALUES ===', 'CONFIG'); Object.entries(responseData.config).forEach(([key, configEntry]) => { const value = configEntry.value || 'undefined'; const description = configEntry.description || ''; logTestEvent('RECV', `${key}: ${value} ${description ? '(' + description + ')' : ''}`, 'CONFIG'); }); logTestEvent('RECV', '=== END CONFIGURATION VALUES ===', 'CONFIG'); } else { logTestEvent('RECV', 'No configuration values found', 'CONFIG_QUERY'); } } } // Handle config update responses function handleConfigUpdateResponse(responseData) { console.log('=== CONFIG UPDATE RESPONSE ==='); console.log('Query type:', responseData.query_type); console.log('Status:', responseData.status); console.log('Data:', responseData.data); if (responseData.status === 'success') { const updatesApplied = responseData.updates_applied || 0; log(`Configuration updated successfully: ${updatesApplied} parameters changed`, 'INFO'); // Show success message with details if (responseData.data && Array.isArray(responseData.data)) { responseData.data.forEach((config, index) => { if (config.status === 'success') { log(`✓ ${config.key}: ${config.value} (${config.data_type})`, 'INFO'); } else { log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR'); } }); } // Configuration updated successfully - user can manually refresh using Fetch Config button log('Configuration updated successfully. Click "Fetch Config" to refresh the display.', 'INFO'); } else { const errorMessage = responseData.message || responseData.error || 'Unknown error'; log(`Configuration update failed: ${errorMessage}`, 'ERROR'); // Show detailed error information if available if (responseData.data && Array.isArray(responseData.data)) { responseData.data.forEach((config, index) => { if (config.status === 'error') { log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR'); } }); } } // Log to test interface for debugging if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Config update response: ${responseData.status}`, 'CONFIG_UPDATE'); if (responseData.data && responseData.data.length > 0) { responseData.data.forEach((config, index) => { const status = config.status === 'success' ? '✓' : '✗'; const message = config.status === 'success' ? `${config.key} = ${config.value}` : `${config.key}: ${config.error || 'Failed'}`; logTestEvent('RECV', `${status} ${message}`, 'CONFIG_UPDATE'); }); } else { logTestEvent('RECV', 'No configuration update details received', 'CONFIG_UPDATE'); } } } // Handle auth query responses function handleAuthQueryResponse(responseData) { console.log('=== AUTH QUERY RESPONSE ==='); console.log('Full response:', responseData); console.log('Query type:', responseData.query_type); console.log('Count:', responseData.count); console.log('Rules:', responseData.rules); console.log('Data:', responseData.data); // Backend can return rules in either 'rules' or 'data' field const rulesArray = responseData.rules || responseData.data; const rulesCount = responseData.count || responseData.total_results || 0; // Update the current auth rules with the response data if (rulesArray && Array.isArray(rulesArray) && rulesArray.length > 0) { currentAuthRules = rulesArray; console.log('Updated currentAuthRules with', currentAuthRules.length, 'rules'); // Always show the auth rules table when we receive data console.log('Auto-showing auth rules table since we received data...'); showAuthRulesTable(); updateAuthRulesStatus('loaded'); log(`Loaded ${rulesCount} auth rules from relay`, 'INFO'); } else { currentAuthRules = []; console.log('No auth rules data received, cleared currentAuthRules'); // Show empty table console.log('Auto-showing auth rules table with empty data...'); showAuthRulesTable(); updateAuthRulesStatus('loaded'); log('No auth rules found on relay', 'INFO'); } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Auth query response: ${responseData.query_type}, ${rulesCount} results`, 'AUTH_QUERY'); if (rulesArray && rulesArray.length > 0) { rulesArray.forEach((rule, index) => { logTestEvent('RECV', `Rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE'); }); } else { logTestEvent('RECV', 'No auth rules found', 'AUTH_QUERY'); } } } // Handle system command responses function handleSystemCommandResponse(responseData) { console.log('=== SYSTEM COMMAND RESPONSE ==='); console.log('Command:', responseData.command); console.log('Status:', responseData.status); // Handle delete auth rule responses if (responseData.command === 'delete_auth_rule') { if (responseData.status === 'success') { log('Auth rule deleted successfully', 'INFO'); // Refresh the auth rules display loadAuthRules(); } else { log(`Failed to delete auth rule: ${responseData.message || 'Unknown error'}`, 'ERROR'); } } // Handle clear all auth rules responses if (responseData.command === 'clear_all_auth_rules') { if (responseData.status === 'success') { const rulesCleared = responseData.rules_cleared || 0; log(`Successfully cleared ${rulesCleared} auth rules`, 'INFO'); // Clear local auth rules and refresh display currentAuthRules = []; displayAuthRules(currentAuthRules); } else { log(`Failed to clear auth rules: ${responseData.message || 'Unknown error'}`, 'ERROR'); } } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD'); } } // Handle auth rule modification responses function handleAuthRuleResponse(responseData) { console.log('=== AUTH RULE MODIFICATION RESPONSE ==='); console.log('Operation:', responseData.operation); console.log('Status:', responseData.status); console.log('Full response:', responseData); // Handle auth rule addition/modification responses if (responseData.status === 'success') { const rulesProcessed = responseData.rules_processed || 0; log(`Successfully processed ${rulesProcessed} auth rule modifications`, 'INFO'); // Always refresh the auth rules display to show the new rules console.log('Refreshing auth rules after successful modification...'); loadAuthRules(); } else { log(`Failed to process auth rule modifications: ${responseData.message || 'Unknown error'}`, 'ERROR'); } if (typeof logTestEvent === 'function') { logTestEvent('RECV', `Auth rule response: ${responseData.operation} - ${responseData.status}`, 'AUTH_RULE'); if (responseData.processed_rules) { responseData.processed_rules.forEach((rule, index) => { logTestEvent('RECV', `Processed rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE'); }); } } } // Helper function to decrypt content from relay using NIP-44 async function decryptFromRelay(encryptedContent) { try { console.log('Decrypting content from relay...'); // Get the relay public key for decryption const relayPubkey = getRelayPubkey(); // Use NIP-07 extension's NIP-44 decrypt method if (!window.nostr || !window.nostr.nip44) { throw new Error('NIP-44 decryption not available via NIP-07 extension'); } const decryptedContent = await window.nostr.nip44.decrypt(relayPubkey, encryptedContent); if (!decryptedContent) { throw new Error('NIP-44 decryption returned empty result'); } console.log('Successfully decrypted content from relay'); return decryptedContent; } catch (error) { console.error('NIP-44 decryption failed:', error); throw error; } } // Fetch configuration using admin API via HTTP POST async function fetchConfiguration() { try { console.log('=== FETCHING CONFIGURATION VIA HTTP POST ==='); // Require login if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to fetch configuration'); } // Ensure relay pubkey is available if (!relayPubkey) { throw new Error('Relay pubkey not available'); } console.log('Sending config query command via HTTP POST...'); // Send config_query command via HTTP POST const responseData = await sendAdminCommandHTTP(['config_query', 'all']); // Process the response handleConfigQueryResponse(responseData); console.log('Configuration fetched successfully'); return true; } catch (error) { console.error('Failed to fetch configuration:', error); log(`Failed to fetch configuration: ${error.message}`, 'ERROR'); return false; } } function displayConfiguration(event) { try { console.log('=== DISPLAYING CONFIGURATION EVENT ==='); console.log('Event received for display:', event); currentConfig = event; // Clear existing table configTableBody.innerHTML = ''; // Display tags (editable configuration parameters only) console.log(`Processing ${event.tags.length} configuration parameters`); event.tags.forEach((tag, index) => { if (tag.length >= 2) { const row = document.createElement('tr'); const key = tag[0]; const value = tag[1]; // Create editable input for value const valueInput = document.createElement('input'); valueInput.type = 'text'; valueInput.value = value; valueInput.className = 'config-value-input'; valueInput.dataset.key = key; valueInput.dataset.originalValue = value; valueInput.dataset.rowIndex = index; // Create clickable Actions cell const actionsCell = document.createElement('td'); actionsCell.className = 'config-actions-cell'; actionsCell.textContent = 'SAVE'; actionsCell.dataset.key = key; actionsCell.dataset.originalValue = value; actionsCell.dataset.rowIndex = index; // Initially hide the SAVE text actionsCell.style.color = 'transparent'; // Show SAVE text and make clickable when value changes valueInput.addEventListener('input', function () { if (this.value !== this.dataset.originalValue) { actionsCell.style.color = 'var(--primary-color)'; actionsCell.style.cursor = 'pointer'; actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell); } else { actionsCell.style.color = 'transparent'; actionsCell.style.cursor = 'default'; actionsCell.onclick = null; } }); row.innerHTML = `${key}`; row.cells[1].appendChild(valueInput); row.appendChild(actionsCell); configTableBody.appendChild(row); } }); // Show message if no configuration parameters found if (event.tags.length === 0) { const row = document.createElement('tr'); row.innerHTML = `No configuration parameters found`; configTableBody.appendChild(row); } console.log('Configuration display completed successfully'); updateConfigStatus(true); } catch (error) { console.error('Error in displayConfiguration:', error.message); console.error('Display configuration error:', error); } } // Save individual configuration parameter async function saveIndividualConfig(key, newValue, originalValue, actionsCell) { if (!isLoggedIn || !userPubkey) { log('Must be logged in to save configuration', 'ERROR'); return; } if (!currentConfig) { log('No current configuration to update', 'ERROR'); return; } // Don't save if value hasn't changed if (newValue === originalValue) { return; } try { log(`Saving individual config: ${key} = ${newValue}`, 'INFO'); // Determine data type based on key name let dataType = 'string'; if (['max_connections', 'pow_min_difficulty', 'nip42_challenge_timeout', 'max_subscriptions_per_client', 'max_event_tags', 'max_content_length'].includes(key)) { dataType = 'integer'; } else if (['auth_enabled', 'nip42_auth_required', 'nip40_expiration_enabled'].includes(key)) { dataType = 'boolean'; } // Determine category based on key name let category = 'general'; if (key.startsWith('relay_')) { category = 'relay'; } else if (key.startsWith('nip40_')) { category = 'expiration'; } else if (key.startsWith('nip42_') || key.startsWith('auth_')) { category = 'authentication'; } else if (key.startsWith('pow_')) { category = 'proof_of_work'; } else if (key.startsWith('max_')) { category = 'limits'; } const configObj = { key: key, value: newValue, data_type: dataType, category: category }; // Update cell during save actionsCell.textContent = 'SAVING...'; actionsCell.style.color = 'var(--accent-color)'; actionsCell.style.cursor = 'not-allowed'; actionsCell.onclick = null; // Send single config update via HTTP POST const responseData = await sendAdminCommandHTTP(['config_update', [configObj]]); handleConfigUpdateResponse(responseData); // Update the original value on success const input = actionsCell.parentElement.cells[1].querySelector('input'); if (input) { input.dataset.originalValue = newValue; // Hide SAVE text since value now matches original actionsCell.style.color = 'transparent'; actionsCell.style.cursor = 'default'; actionsCell.onclick = null; } actionsCell.textContent = 'SAVED'; actionsCell.style.color = 'var(--accent-color)'; setTimeout(() => { actionsCell.textContent = 'SAVE'; // Keep transparent if value matches original if (input && input.value === input.dataset.originalValue) { actionsCell.style.color = 'transparent'; } }, 2000); log(`Successfully saved config: ${key} = ${newValue}`, 'INFO'); } catch (error) { log(`Failed to save individual config ${key}: ${error.message}`, 'ERROR'); actionsCell.textContent = 'SAVE'; actionsCell.style.color = 'var(--primary-color)'; actionsCell.style.cursor = 'pointer'; actionsCell.onclick = () => saveIndividualConfig(key, actionsCell.parentElement.cells[1].querySelector('input').value, originalValue, actionsCell); } } // Profile area click handler removed - dropdown moved to sidebar // Logout and dark mode buttons are now in the sidebar footer // Initialize relay pubkey container click handler for clipboard copy const relayPubkeyContainer = document.getElementById('relay-pubkey-container'); if (relayPubkeyContainer) { relayPubkeyContainer.addEventListener('click', async function() { const relayPubkeyElement = document.getElementById('relay-pubkey'); if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') { try { // Get the full npub (remove all whitespace for continuous string) const fullNpub = relayPubkeyElement.textContent.replace(/\s/g, ''); await navigator.clipboard.writeText(fullNpub); // Add copied class for visual feedback relayPubkeyContainer.classList.add('copied'); // Remove the class after animation completes setTimeout(() => { relayPubkeyContainer.classList.remove('copied'); }, 500); log('Relay npub copied to clipboard', 'INFO'); } catch (error) { log('Failed to copy relay npub to clipboard', 'ERROR'); } } }); } // Event handlers fetchConfigBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); fetchConfiguration().catch(error => { console.log('Manual fetch configuration failed: ' + error.message); }); }); // ================================ // AUTH RULES MANAGEMENT FUNCTIONS // ================================ // Global auth rules state let currentAuthRules = []; let editingAuthRule = null; // DOM elements for auth rules const authRulesSection = document.getElementById('authRulesSection'); const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn'); const authRulesTableContainer = document.getElementById('authRulesTableContainer'); const authRulesTableBody = document.getElementById('authRulesTableBody'); const authRuleFormContainer = document.getElementById('authRuleFormContainer'); const authRuleForm = document.getElementById('authRuleForm'); const authRuleFormTitle = document.getElementById('authRuleFormTitle'); const saveAuthRuleBtn = document.getElementById('saveAuthRuleBtn'); const cancelAuthRuleBtn = document.getElementById('cancelAuthRuleBtn'); // Show auth rules section after login function showAuthRulesSection() { if (authRulesSection) { authRulesSection.style.display = 'block'; updateAuthRulesStatus('ready'); log('Auth rules section is now available', 'INFO'); } } // Hide auth rules section on logout function hideAuthRulesSection() { if (authRulesSection) { authRulesSection.style.display = 'none'; // Add null checks for all elements if (authRulesTableContainer) { authRulesTableContainer.style.display = 'none'; } if (authRuleFormContainer) { authRuleFormContainer.style.display = 'none'; } currentAuthRules = []; editingAuthRule = null; log('Auth rules section hidden', 'INFO'); } } // Update auth rules status indicator (removed - no status element) function updateAuthRulesStatus(status) { // Status element removed - no-op } // Load auth rules from relay using admin API via HTTP POST async function loadAuthRules() { try { log('Loading auth rules via HTTP POST...', 'INFO'); updateAuthRulesStatus('loading'); if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to load auth rules'); } if (!relayPubkey) { throw new Error('Relay pubkey not available'); } log('Sending auth rules query via HTTP POST...', 'INFO'); // Send auth_query command via HTTP POST const responseData = await sendAdminCommandHTTP(['auth_query', 'all']); // Process the response handleAuthQueryResponse(responseData); log('Auth rules loaded successfully', 'INFO'); updateAuthRulesStatus('loaded'); } catch (error) { log(`Failed to load auth rules: ${error.message}`, 'ERROR'); updateAuthRulesStatus('error'); currentAuthRules = []; displayAuthRules(currentAuthRules); } } // Display auth rules in the table function displayAuthRules(rules) { console.log('=== DISPLAY AUTH RULES DEBUG ==='); console.log('authRulesTableBody element:', authRulesTableBody); console.log('Rules to display:', rules); console.log('Rules length:', rules ? rules.length : 'undefined'); console.log('authRulesTableContainer display:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found'); if (!authRulesTableBody) { console.log('ERROR: authRulesTableBody element not found'); return; } authRulesTableBody.innerHTML = ''; console.log('Cleared existing table content'); if (!rules || rules.length === 0) { console.log('No rules to display, showing empty message'); const row = document.createElement('tr'); row.innerHTML = `No auth rules configured`; authRulesTableBody.appendChild(row); console.log('Added empty rules message row'); return; } console.log(`Displaying ${rules.length} auth rules`); rules.forEach((rule, index) => { console.log(`Adding rule ${index + 1}:`, rule); const row = document.createElement('tr'); // Convert hex pubkey to npub for display in pattern_value let displayPatternValue = rule.pattern_value || rule.rule_target || '-'; let patternValueLink = displayPatternValue; try { if (rule.pattern_value && rule.pattern_value.length === 64 && /^[0-9a-fA-F]+$/.test(rule.pattern_value)) { const npub = window.NostrTools.nip19.npubEncode(rule.pattern_value); displayPatternValue = npub; patternValueLink = `${npub}`; } } catch (error) { console.log('Failed to encode pattern_value to npub:', error.message); } row.innerHTML = ` ${rule.rule_type} ${rule.pattern_type || rule.operation || '-'} ${patternValueLink} ${rule.enabled !== false ? 'Active' : 'Inactive'}
`; authRulesTableBody.appendChild(row); }); // Update status display console.log(`Total Rules: ${rules.length}, Active Rules: ${rules.filter(r => r.enabled !== false).length}`); console.log('=== END DISPLAY AUTH RULES DEBUG ==='); } // Show auth rules table (automatically called when auth rules are loaded) function showAuthRulesTable() { console.log('=== SHOW AUTH RULES TABLE DEBUG ==='); console.log('authRulesTableContainer element:', authRulesTableContainer); if (authRulesTableContainer) { // Table is always visible now, just ensure we display the rules if (currentAuthRules && currentAuthRules.length >= 0) { console.log('Displaying cached auth rules:', currentAuthRules.length, 'rules'); displayAuthRules(currentAuthRules); updateAuthRulesStatus('loaded'); log(`Auth rules table displayed with ${currentAuthRules.length} cached rules`, 'INFO'); } else { // No cached rules, load from relay console.log('No cached auth rules, loading from relay...'); loadAuthRules(); log('Auth rules table displayed - loading from relay', 'INFO'); } } else { console.log('ERROR: authRulesTableContainer element not found'); } console.log('=== END SHOW AUTH RULES TABLE DEBUG ==='); } // Show add auth rule form function showAddAuthRuleForm() { if (authRuleFormContainer && authRuleFormTitle) { editingAuthRule = null; authRuleFormTitle.textContent = 'Add Auth Rule'; authRuleForm.reset(); authRuleFormContainer.style.display = 'block'; log('Opened add auth rule form', 'INFO'); } } // Show edit auth rule form function editAuthRule(index) { if (index < 0 || index >= currentAuthRules.length) return; const rule = currentAuthRules[index]; editingAuthRule = { ...rule, index: index }; if (authRuleFormTitle && authRuleForm) { authRuleFormTitle.textContent = 'Edit Auth Rule'; // Populate form fields document.getElementById('authRuleType').value = rule.rule_type || ''; document.getElementById('authPatternType').value = rule.pattern_type || rule.operation || ''; document.getElementById('authPatternValue').value = rule.pattern_value || rule.rule_target || ''; document.getElementById('authRuleAction').value = rule.action || 'allow'; document.getElementById('authRuleDescription').value = rule.description || ''; authRuleFormContainer.style.display = 'block'; log(`Editing auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO'); } } // Delete auth rule using HTTP POST async function deleteAuthRule(index) { if (index < 0 || index >= currentAuthRules.length) return; const rule = currentAuthRules[index]; const confirmMsg = `Delete auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}?`; if (!confirm(confirmMsg)) return; try { log(`Deleting auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO'); if (!isLoggedIn || !userPubkey) { throw new Error('Must be logged in to delete auth rules'); } // Create command array for deleting auth rule const rule_type = rule.rule_type; const pattern_type = rule.pattern_type || 'pubkey'; const pattern_value = rule.pattern_value || rule.rule_target; // Send delete command via HTTP POST await sendAdminCommandHTTP(["system_command", "delete_auth_rule", rule_type, pattern_type, pattern_value]); log('Delete auth rule command sent successfully', 'INFO'); // Remove from local array immediately for UI responsiveness currentAuthRules.splice(index, 1); displayAuthRules(currentAuthRules); } catch (error) { log(`Failed to delete auth rule: ${error.message}`, 'ERROR'); } } // Hide auth rule form function hideAuthRuleForm() { if (authRuleFormContainer) { authRuleFormContainer.style.display = 'none'; editingAuthRule = null; log('Auth rule form hidden', 'INFO'); } } // Validate auth rule form function validateAuthRuleForm() { const ruleType = document.getElementById('authRuleType').value; const patternType = document.getElementById('authPatternType').value; const patternValue = document.getElementById('authPatternValue').value.trim(); const action = document.getElementById('authRuleAction').value; if (!ruleType) { alert('Please select a rule type'); return false; } if (!patternType) { alert('Please select a pattern type'); return false; } if (!patternValue) { alert('Please enter a pattern value'); return false; } if (!action) { alert('Please select an action'); return false; } // Validate pubkey format for pubkey rules if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist') && patternValue.length !== 64) { alert('Pubkey must be exactly 64 hex characters'); return false; } // Validate hex format for pubkey rules if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist')) { const hexPattern = /^[0-9a-fA-F]+$/; if (!hexPattern.test(patternValue)) { alert('Pubkey must contain only hex characters (0-9, a-f, A-F)'); return false; } } return true; } // Save auth rule (add or update) async function saveAuthRule(event) { event.preventDefault(); if (!validateAuthRuleForm()) return; try { const ruleData = { rule_type: document.getElementById('authRuleType').value, pattern_type: document.getElementById('authPatternType').value, pattern_value: document.getElementById('authPatternValue').value.trim(), action: document.getElementById('authRuleAction').value, description: document.getElementById('authRuleDescription').value.trim() || null, enabled: true }; if (editingAuthRule) { log(`Updating auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO'); // TODO: Implement actual rule update via HTTP POST // For now, just update local array currentAuthRules[editingAuthRule.index] = { ...ruleData, id: editingAuthRule.id || Date.now() }; log('Auth rule updated (placeholder implementation)', 'INFO'); } else { log(`Adding new auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO'); // TODO: Implement actual rule creation via HTTP POST // For now, just add to local array currentAuthRules.push({ ...ruleData, id: Date.now() }); log('Auth rule added (placeholder implementation)', 'INFO'); } displayAuthRules(currentAuthRules); hideAuthRuleForm(); } catch (error) { log(`Failed to save auth rule: ${error.message}`, 'ERROR'); } } // Monitoring is now subscription-based - no auto-enable needed // Monitoring automatically activates when someone subscribes to kind 24567 events async function autoEnableMonitoring() { log('Monitoring system is subscription-based - no manual enable needed', 'INFO'); log('Subscribe to kind 24567 events to receive real-time monitoring data', 'INFO'); } // Update existing logout and showMainInterface functions to handle auth rules and NIP-17 DMs const originalLogout = logout; logout = async function () { hideAuthRulesSection(); // Clear DM inbox and outbox on logout if (dmInbox) { dmInbox.innerHTML = '
No messages received yet.
'; } if (dmOutbox) { dmOutbox.value = ''; } await originalLogout(); }; const originalShowMainInterface = showMainInterface; showMainInterface = function () { originalShowMainInterface(); // Removed showAuthRulesSection() call - visibility now handled by updateAdminSectionsVisibility() }; // Auth rules event handlers if (refreshAuthRulesBtn) { refreshAuthRulesBtn.addEventListener('click', function (e) { e.preventDefault(); loadAuthRules(); }); } if (authRuleForm) { authRuleForm.addEventListener('submit', saveAuthRule); } if (cancelAuthRuleBtn) { cancelAuthRuleBtn.addEventListener('click', function (e) { e.preventDefault(); hideAuthRuleForm(); }); } // ================================ // STREAMLINED AUTH RULE FUNCTIONS // ================================ // Utility function to convert nsec to hex pubkey or npub to hex pubkey function nsecToHex(input) { if (!input || input.trim().length === 0) { return null; } const trimmed = input.trim(); // If it's already 64-char hex, return as-is if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { return trimmed; } // If it starts with nsec1, try to decode if (trimmed.startsWith('nsec1')) { try { if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) { const decoded = window.NostrTools.nip19.decode(trimmed); if (decoded.type === 'nsec') { // Handle different versions of nostr-tools if (typeof decoded.data === 'string') { // v1 style - data is already hex return decoded.data; } else { // v2 style - data is Uint8Array const hexPubkey = Array.from(decoded.data) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hexPubkey; } } } } catch (error) { console.error('Failed to decode nsec:', error); return null; } } // If it starts with npub1, try to decode to hex if (trimmed.startsWith('npub1')) { try { if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) { const decoded = window.NostrTools.nip19.decode(trimmed); if (decoded.type === 'npub') { // Handle different versions of nostr-tools if (typeof decoded.data === 'string') { // v1 style - data is already hex return decoded.data; } else { // v2 style - data is Uint8Array const hexPubkey = Array.from(decoded.data) .map(b => b.toString(16).padStart(2, '0')) .join(''); return hexPubkey; } } } } catch (error) { console.error('Failed to decode npub:', error); return null; } } return null; // Invalid format } // Add blacklist rule (updated to use combined input) function addBlacklistRule() { const input = document.getElementById('authRulePubkey'); if (!input) return; const inputValue = input.value.trim(); if (!inputValue) { log('Please enter a pubkey or npub', 'ERROR'); return; } // Convert npub to hex if needed const hexPubkey = nsecToHex(inputValue); if (!hexPubkey) { log('Invalid pubkey format. Please enter npub1... or 64-character hex', 'ERROR'); return; } // Validate hex length if (hexPubkey.length !== 64) { log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR'); return; } log('Adding to blacklist...', 'INFO'); // Create auth rule data const ruleData = { rule_type: 'pubkey_blacklist', pattern_type: 'Global', pattern_value: hexPubkey, action: 'deny' }; // Add auth rule via HTTP POST sendAdminCommandHTTP(['blacklist', 'pubkey', hexPubkey]) .then(() => { log(`Pubkey ${hexPubkey.substring(0, 16)}... added to blacklist`, 'INFO'); input.value = ''; // Always refresh auth rules display after adding loadAuthRules(); }) .catch(error => { log(`Failed to add rule: ${error.message}`, 'ERROR'); }); } // Add whitelist rule (updated to use combined input) function addWhitelistRule() { const input = document.getElementById('authRulePubkey'); const warningDiv = document.getElementById('whitelistWarning'); if (!input) return; const inputValue = input.value.trim(); if (!inputValue) { log('Please enter a pubkey or npub', 'ERROR'); return; } // Convert npub to hex if needed const hexPubkey = nsecToHex(inputValue); if (!hexPubkey) { log('Invalid pubkey format. Please enter npub1... or 64-character hex', 'ERROR'); return; } // Validate hex length if (hexPubkey.length !== 64) { log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR'); return; } // Show whitelist warning if (warningDiv) { warningDiv.style.display = 'block'; } log('Adding to whitelist...', 'INFO'); // Create auth rule data const ruleData = { rule_type: 'pubkey_whitelist', pattern_type: 'Global', pattern_value: hexPubkey, action: 'allow' }; // Add auth rule via HTTP POST sendAdminCommandHTTP(['whitelist', 'pubkey', hexPubkey]) .then(() => { log(`Pubkey ${hexPubkey.substring(0, 16)}... added to whitelist`, 'INFO'); input.value = ''; // Always refresh auth rules display after adding loadAuthRules(); }) .catch(error => { log(`Failed to add rule: ${error.message}`, 'ERROR'); }); } // ================================ // TEST FUNCTIONS FOR ADMIN API // ================================ // Test event logging function function logTestEvent(direction, message, type = 'INFO') { const testLog = document.getElementById('test-event-log'); if (!testLog) return; const timestamp = new Date().toISOString().split('T')[1].split('.')[0]; const logEntry = document.createElement('div'); logEntry.className = 'log-entry'; const directionColor = direction === 'SENT' ? '#007bff' : '#28a745'; logEntry.innerHTML = ` ${timestamp} [${direction}] [${type}] ${message} `; testLog.appendChild(logEntry); testLog.scrollTop = testLog.scrollHeight; } // Test: Get Auth Rules via HTTP POST async function testGetAuthRules() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Get Auth Rules command via HTTP POST...', 'TEST'); // Send auth_query command via HTTP POST const responseData = await sendAdminCommandHTTP(['auth_query', 'all']); logTestEvent('RECV', `Auth query response: ${JSON.stringify(responseData)}`, 'RESPONSE'); logTestEvent('INFO', 'Get Auth Rules command completed successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Get Auth Rules test failed: ${error.message}`, 'ERROR'); } } // Test: Clear All Auth Rules via HTTP POST async function testClearAuthRules() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Clear All Auth Rules command via HTTP POST...', 'TEST'); // Send clear auth rules command via HTTP POST const responseData = await sendAdminCommandHTTP(['system_command', 'clear_all_auth_rules']); logTestEvent('RECV', `Clear auth rules response: ${JSON.stringify(responseData)}`, 'RESPONSE'); logTestEvent('INFO', 'Clear Auth Rules command completed successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Clear Auth Rules test failed: ${error.message}`, 'ERROR'); } } // Test: Add Blacklist via HTTP POST async function testAddBlacklist() { const testPubkeyInput = document.getElementById('test-pubkey-input'); let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : ''; // Use a default test pubkey if none provided if (!testPubkey) { testPubkey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO'); } if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } try { logTestEvent('INFO', `Testing Add Blacklist for pubkey: ${testPubkey.substring(0, 16)}... via HTTP POST`, 'TEST'); // Send blacklist command via HTTP POST const responseData = await sendAdminCommandHTTP(['blacklist', 'pubkey', testPubkey]); logTestEvent('RECV', `Add blacklist response: ${JSON.stringify(responseData)}`, 'RESPONSE'); logTestEvent('INFO', 'Add Blacklist command completed successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Add Blacklist test failed: ${error.message}`, 'ERROR'); } } // Test: Add Whitelist via HTTP POST async function testAddWhitelist() { const testPubkeyInput = document.getElementById('test-pubkey-input'); let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : ''; // Use a default test pubkey if none provided if (!testPubkey) { testPubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890'; logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO'); } if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } try { logTestEvent('INFO', `Testing Add Whitelist for pubkey: ${testPubkey.substring(0, 16)}... via HTTP POST`, 'TEST'); // Send whitelist command via HTTP POST const responseData = await sendAdminCommandHTTP(['whitelist', 'pubkey', testPubkey]); logTestEvent('RECV', `Add whitelist response: ${JSON.stringify(responseData)}`, 'RESPONSE'); logTestEvent('INFO', 'Add Whitelist command completed successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Add Whitelist test failed: ${error.message}`, 'ERROR'); } } // Test: Config Query via HTTP POST async function testConfigQuery() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing Config Query command via HTTP POST...', 'TEST'); // Send config_query command via HTTP POST const responseData = await sendAdminCommandHTTP(['config_query', 'all']); logTestEvent('RECV', `Config query response: ${JSON.stringify(responseData)}`, 'RESPONSE'); logTestEvent('INFO', 'Config Query command completed successfully', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Config Query test failed: ${error.message}`, 'ERROR'); } } // Test: Post Basic Event async function testPostEvent() { if (!isLoggedIn || !userPubkey) { logTestEvent('ERROR', 'Must be logged in to test event posting', 'ERROR'); return; } try { logTestEvent('INFO', 'Testing basic event posting via HTTP POST...', 'TEST'); // Create a simple kind 1 text note event const testEvent = { kind: 1, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [ ["t", "test"], ["client", "blossom-admin-api"] ], content: `Test event from Blossom Admin API at ${new Date().toISOString()}` }; logTestEvent('SENT', `Test event (before signing): ${JSON.stringify(testEvent)}`, 'EVENT'); // Sign the event using NIP-07 const signedEvent = await window.nostr.signEvent(testEvent); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } logTestEvent('SENT', `Signed test event: ${JSON.stringify(signedEvent)}`, 'EVENT'); // POST to /api/events endpoint const response = await fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedEvent) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); logTestEvent('RECV', `Event post response: ${JSON.stringify(result)}`, 'RESPONSE'); logTestEvent('INFO', 'Test event posted successfully via HTTP!', 'SUCCESS'); } catch (error) { logTestEvent('ERROR', `Post Event test failed: ${error.message}`, 'ERROR'); console.error('Post Event test error:', error); } } // Helper function to encrypt content for relay using NIP-44 async function encryptForRelay(content) { try { console.log('Encrypting content for relay:', content.substring(0, 50) + '...'); // Get the relay public key for encryption const relayPubkey = getRelayPubkey(); // Use NIP-07 extension's NIP-44 encrypt method if (!window.nostr || !window.nostr.nip44) { throw new Error('NIP-44 encryption not available via NIP-07 extension'); } const encrypted_content = await window.nostr.nip44.encrypt(relayPubkey, content); if (!encrypted_content) { throw new Error('NIP-44 encryption returned empty result'); } console.log('Successfully encrypted content using NIP-44'); return encrypted_content; } catch (error) { console.error('NIP-44 encryption failed:', error); throw error; } } // Create Kind 23458 admin command event async function createAdminCommandEvent(commandArray) { try { console.log('Creating Kind 23458 event for command:', commandArray); // Encrypt command array as JSON const content = JSON.stringify(commandArray); const encryptedContent = await encryptForRelay(content); // Create Kind 23458 event const event = { kind: 23458, created_at: Math.floor(Date.now() / 1000), tags: [['p', getRelayPubkey()]], content: encryptedContent }; // Sign event using NIP-07 if (!window.nostr || !window.nostr.signEvent) { throw new Error('NIP-07 signing not available'); } const signedEvent = await window.nostr.signEvent(event); if (!signedEvent || !signedEvent.sig) { throw new Error('Event signing failed'); } console.log('Kind 23458 event created and signed'); return signedEvent; } catch (error) { console.error('Failed to create admin command event:', error); throw error; } } // Send admin command via HTTP POST to /api/admin async function sendAdminCommandHTTP(commandArray) { try { console.log('Sending admin command via HTTP POST:', commandArray); // Create and sign Kind 23458 event const event = await createAdminCommandEvent(commandArray); // Send via HTTP POST to /api/admin const response = await fetch('/api/admin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(event) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } // Parse Kind 23459 response event const responseEvent = await response.json(); // Verify it's a Kind 23459 response if (responseEvent.kind !== 23459) { throw new Error(`Expected Kind 23459 response, got Kind ${responseEvent.kind}`); } // Decrypt response content if (!window.nostr || !window.nostr.nip44) { throw new Error('NIP-44 decryption not available'); } const decryptedContent = await window.nostr.nip44.decrypt( responseEvent.pubkey, responseEvent.content ); // Parse decrypted JSON response const responseData = JSON.parse(decryptedContent); console.log('Received and decrypted response:', responseData); return responseData; } catch (error) { console.error('Failed to send admin command via HTTP:', error); throw error; } } // Send NIP-17 Direct Message to relay using NIP-59 layering via HTTP POST async function sendNIP17DM() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to send DM', 'ERROR'); return; } if (!isRelayConnected || !relayPubkey) { log('Must be connected to relay to send DM', 'ERROR'); return; } const message = dmOutbox.value.trim(); if (!message) { log('Please enter a message to send', 'ERROR'); return; } // Capability checks if (!window.nostr || !window.nostr.nip44 || !window.nostr.signEvent) { log('NIP-17 DMs require a NIP-07 extension with NIP-44 support', 'ERROR'); alert('NIP-17 DMs require a NIP-07 extension with NIP-44 support. Please install and configure a compatible extension.'); return; } if (!window.NostrTools || !window.NostrTools.generateSecretKey || !window.NostrTools.getPublicKey || !window.NostrTools.finalizeEvent) { log('NostrTools library not available for ephemeral key operations', 'ERROR'); alert('NostrTools library not available. Please ensure nostr.bundle.js is loaded.'); return; } try { log(`Sending NIP-17 DM to relay: ${message.substring(0, 50)}...`, 'INFO'); // Step 1: Build unsigned rumor (kind 14) const rumor = { kind: 14, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), tags: [["p", relayPubkey]], content: message }; log('Rumor built (unsigned), creating seal...', 'INFO'); // Step 2: Create seal (kind 13) const seal = { kind: 13, pubkey: userPubkey, created_at: randomNow(), tags: [], content: await window.nostr.nip44.encrypt(relayPubkey, JSON.stringify(rumor)) }; const signedSeal = await window.nostr.signEvent(seal); if (!signedSeal || !signedSeal.sig) { throw new Error('Failed to sign seal event'); } log('Seal created and signed, creating gift wrap...', 'INFO'); // Step 3: Create gift wrap (kind 1059) with ephemeral key const ephemeralPriv = window.NostrTools.generateSecretKey(); const ephemeralPub = window.NostrTools.getPublicKey(ephemeralPriv); const giftWrap = { kind: 1059, pubkey: ephemeralPub, created_at: randomNow(), tags: [["p", relayPubkey]], content: await window.NostrTools.nip44.encrypt( JSON.stringify(signedSeal), window.NostrTools.nip44.getConversationKey(ephemeralPriv, relayPubkey) ) }; const signedGiftWrap = window.NostrTools.finalizeEvent(giftWrap, ephemeralPriv); if (!signedGiftWrap || !signedGiftWrap.sig) { throw new Error('Failed to sign gift wrap event'); } log('NIP-17 DM event created and signed with ephemeral key, posting...', 'INFO'); // POST to /api/events endpoint const response = await fetch('/api/events', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedGiftWrap) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); log('NIP-17 DM sent successfully via HTTP', 'INFO'); // Clear the outbox and show success dmOutbox.value = ''; // Add to inbox for display addMessageToInbox('sent', message, new Date().toLocaleString()); } catch (error) { log(`Failed to send NIP-17 DM: ${error.message}`, 'ERROR'); } } // Add message to inbox display function addMessageToInbox(direction, message, timestamp, pubkey = null) { if (!dmInbox) return; const messageDiv = document.createElement('div'); messageDiv.className = 'log-entry'; const directionColor = direction === 'sent' ? '#007bff' : '#28a745'; // Convert newlines to
tags for proper HTML display const formattedMessage = message.replace(/\n/g, '
'); // Add pubkey display for received messages let pubkeyDisplay = ''; if (pubkey && direction === 'received') { try { const npub = window.NostrTools.nip19.npubEncode(pubkey); pubkeyDisplay = ` (${npub})`; } catch (error) { console.error('Failed to encode pubkey to npub:', error); } } messageDiv.innerHTML = ` ${timestamp} [${direction.toUpperCase()}] ${formattedMessage}${pubkeyDisplay} `; // Remove the "No messages received yet" placeholder if it exists const placeholder = dmInbox.querySelector('.log-entry'); if (placeholder && placeholder.textContent === 'No messages received yet.') { dmInbox.innerHTML = ''; } // Add new message at the top dmInbox.insertBefore(messageDiv, dmInbox.firstChild); // Limit to last 50 messages while (dmInbox.children.length > 50) { dmInbox.removeChild(dmInbox.lastChild); } } // Update relay info in header function updateRelayInfoInHeader() { const relayNameElement = document.getElementById('relay-name'); const relayPubkeyElement = document.getElementById('relay-pubkey'); const relayDescriptionElement = document.getElementById('relay-description'); if (!relayNameElement || !relayPubkeyElement || !relayDescriptionElement) { return; } // Get relay info from NIP-11 data or use defaults const relayInfo = getRelayInfo(); const relayName = relayInfo.name || 'Blossom'; const relayDescription = relayInfo.description || 'Blob Storage Server'; // Convert relay pubkey to npub let relayNpub = 'Loading...'; if (relayPubkey) { try { relayNpub = window.NostrTools.nip19.npubEncode(relayPubkey); } catch (error) { console.log('Failed to encode relay pubkey to npub:', error.message); relayNpub = relayPubkey.substring(0, 16) + '...'; } } // Format npub into 3 lines of 21 characters each, with spaces dividing each line into 3 groups of 7 characters let formattedNpub = relayNpub; if (relayNpub.length === 63) { const line1 = relayNpub.substring(0, 7) + ' ' + relayNpub.substring(7, 14) + ' ' + relayNpub.substring(14, 21); const line2 = relayNpub.substring(21, 28) + ' ' + relayNpub.substring(28, 35) + ' ' + relayNpub.substring(35, 42); const line3 = relayNpub.substring(42, 49) + ' ' + relayNpub.substring(49, 56) + ' ' + relayNpub.substring(56, 63); formattedNpub = line1 + '\n' + line2 + '\n' + line3; } relayNameElement.textContent = relayName; relayPubkeyElement.textContent = formattedNpub; relayDescriptionElement.textContent = relayDescription; } // Global variable to store relay info from NIP-11 or config let relayInfoData = null; // Helper function to get relay info from stored data function getRelayInfo() { // Return stored relay info if available, otherwise defaults if (relayInfoData) { return relayInfoData; } // Default values return { name: 'Blossom', description: 'Blob Storage Server', pubkey: relayPubkey }; } // Update stored relay info when config is loaded function updateStoredRelayInfo(configData) { if (configData && configData.config) { // Extract relay info from config data // Backend format: {key1: {value: 'x', description: 'y'}, ...} let relayName = 'Blossom'; let relayDescription = 'Blob Storage Server'; if (configData.config.relay_name) { relayName = configData.config.relay_name.value || 'Blossom'; } if (configData.config.relay_description) { relayDescription = configData.config.relay_description.value || 'Blob Storage Server'; } relayInfoData = { name: relayName, description: relayDescription, pubkey: relayPubkey }; // Update header immediately updateRelayInfoInHeader(); } } // Helper function to get relay pubkey function getRelayPubkey() { // Use the dynamically fetched relay pubkey if available if (relayPubkey) { return relayPubkey; } // No fallback - throw error if relay pubkey not available throw new Error('Relay pubkey not available. Please connect to relay first.'); } // Generate random test pubkey function function generateRandomTestKey() { // Generate 32 random bytes (64 hex characters) for a valid pubkey const randomBytes = new Uint8Array(32); crypto.getRandomValues(randomBytes); // Convert to hex string const hexPubkey = Array.from(randomBytes) .map(b => b.toString(16).padStart(2, '0')) .join(''); // Set the generated key in the input field const testPubkeyInput = document.getElementById('test-pubkey-input'); if (testPubkeyInput) { testPubkeyInput.value = hexPubkey; logTestEvent('INFO', `Generated random test pubkey: ${hexPubkey.substring(0, 16)}...`, 'KEYGEN'); } return hexPubkey; } // ================================ // DATABASE STATISTICS FUNCTIONS // ================================ // Send restart command to restart the relay via HTTP POST async function sendRestartCommand() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to restart relay', 'ERROR'); return; } try { log('Sending restart command to relay via HTTP POST...', 'INFO'); // Send restart command via HTTP POST const responseData = await sendAdminCommandHTTP(["system_command", "restart"]); log('Restart command sent successfully - relay should restart shortly...', 'INFO'); } catch (error) { log(`Failed to send restart command: ${error.message}`, 'ERROR'); } } // Send query_view commands to get database statistics via HTTP POST async function sendStatsQuery() { if (!isLoggedIn || !userPubkey) { log('Must be logged in to query database statistics', 'ERROR'); updateStatsStatus('error', 'Not logged in'); return; } try { updateStatsStatus('loading', 'Querying database...'); // Query blob_overview view for basic stats const overviewData = await sendAdminCommandHTTP(['query_view', 'blob_overview']); handleViewQueryResponse('blob_overview', overviewData); // Query blob_type_distribution view const typeData = await sendAdminCommandHTTP(['query_view', 'blob_type_distribution']); handleViewQueryResponse('blob_type_distribution', typeData); // Query blob_time_stats view const timeData = await sendAdminCommandHTTP(['query_view', 'blob_time_stats']); handleViewQueryResponse('blob_time_stats', timeData); // Query top_uploaders view const uploadersData = await sendAdminCommandHTTP(['query_view', 'top_uploaders']); handleViewQueryResponse('top_uploaders', uploadersData); log('All view queries completed successfully', 'INFO'); updateStatsStatus('loaded'); } catch (error) { log(`Failed to send view queries: ${error.message}`, 'ERROR'); updateStatsStatus('error', error.message); } } // Handle query_view response and populate appropriate table function handleViewQueryResponse(viewName, responseData) { try { console.log(`Processing view query response: ${viewName}`, responseData); if (responseData.query_type !== 'query_view') { log('Ignoring non-view-query response', 'WARNING'); return; } if (responseData.status !== 'success') { log(`View query failed: ${responseData.error || 'Unknown error'}`, 'ERROR'); return; } // Route to appropriate handler based on view name switch (viewName) { case 'blob_overview': if (responseData.rows && Array.isArray(responseData.rows) && responseData.rows.length > 0) { // Convert row array to object using column names const overviewData = {}; responseData.columns.forEach((col, idx) => { overviewData[col] = responseData.rows[0][idx]; }); populateStatsOverview(overviewData); // Update chart with total blobs count const currentTotal = overviewData.total_blobs; if (currentTotal !== undefined) { // Calculate new blobs since last update for chart const newBlobs = currentTotal - previousTotalBlobs; if (newBlobs > 0 && eventRateChart) { console.log(`Adding ${newBlobs} new blobs to rate chart (${currentTotal} - ${previousTotalBlobs})`); eventRateChart.addValue(newBlobs); } // Update previous total for next calculation previousTotalBlobs = currentTotal; } } break; case 'blob_type_distribution': if (responseData.rows && Array.isArray(responseData.rows)) { // Convert rows to array of objects const typeData = responseData.rows.map(row => { const obj = {}; responseData.columns.forEach((col, idx) => { obj[col] = row[idx]; }); return obj; }); populateStatsKinds(typeData); } break; case 'blob_time_stats': if (responseData.rows && Array.isArray(responseData.rows) && responseData.rows.length > 0) { // Convert row array to object using column names const timeData = {}; responseData.columns.forEach((col, idx) => { timeData[col] = responseData.rows[0][idx]; }); populateStatsTime(timeData); } break; case 'top_uploaders': if (responseData.rows && Array.isArray(responseData.rows)) { // Convert rows to array of objects const uploadersData = responseData.rows.map(row => { const obj = {}; responseData.columns.forEach((col, idx) => { obj[col] = row[idx]; }); return obj; }); populateStatsPubkeys(uploadersData); } break; default: console.log(`Unknown view name: ${viewName}`); } } catch (error) { log(`Error processing ${viewName} response: ${error.message}`, 'ERROR'); } } // Legacy handler for backward compatibility function handleStatsQueryResponse(responseData) { try { log('Processing stats query response...', 'INFO'); console.log('Stats response data:', responseData); if (responseData.query_type !== 'stats_query') { log('Ignoring non-stats response', 'WARNING'); return; } // Extract the actual data object const statsData = responseData.data || responseData; console.log('Extracted stats data:', statsData); // Populate overview table with blob statistics populateStatsOverview(statsData); // Populate blob type distribution table if (statsData.type_distribution && Array.isArray(statsData.type_distribution)) { populateStatsKinds(statsData.type_distribution); } // Populate time-based statistics if (statsData.blobs_24h !== undefined) { populateStatsTime(statsData); } // Populate top uploaders table if (statsData.top_uploaders && Array.isArray(statsData.top_uploaders)) { populateStatsPubkeys(statsData.top_uploaders); } updateStatsStatus('loaded'); log('Blob statistics updated successfully', 'INFO'); } catch (error) { log(`Error processing stats response: ${error.message}`, 'ERROR'); updateStatsStatus('error', 'Failed to process response'); } } // Update statistics display from real-time monitoring event function updateStatsFromMonitoringEvent(monitoringData) { try { if (monitoringData.data_type !== 'event_kinds') { return; } // Update total blobs count and track rate for chart // Support both total_blobs (new) and total_events (legacy) field names const currentTotal = monitoringData.total_blobs || monitoringData.total_events; if (currentTotal !== undefined) { updateStatsCell('total-events', currentTotal.toString()); // Calculate new blobs since last update for chart const newBlobs = currentTotal - previousTotalBlobs; if (newBlobs > 0 && eventRateChart) { console.log(`Adding ${newBlobs} new blobs to rate chart (${currentTotal} - ${previousTotalBlobs})`); eventRateChart.addValue(newBlobs); } // Update previous total for next calculation previousTotalBlobs = currentTotal; } // Update blob types table with real-time data if (monitoringData.kinds && Array.isArray(monitoringData.kinds)) { populateStatsKindsFromMonitoring(monitoringData.kinds, currentTotal); } } catch (error) { log(`Error updating stats from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from time_stats monitoring event function updateStatsFromTimeMonitoringEvent(monitoringData) { try { if (monitoringData.data_type !== 'time_stats') { return; } // Update time-based statistics table with real-time data if (monitoringData.periods && Array.isArray(monitoringData.periods)) { // Use the existing populateStatsTime function which expects the nested time_stats object const timeStats = { last_24h: 0, last_7d: 0, last_30d: 0 }; // Extract values from periods array monitoringData.periods.forEach(period => { if (period.period === 'last_24h') timeStats.last_24h = period.count; else if (period.period === 'last_7d') timeStats.last_7d = period.count; else if (period.period === 'last_30d') timeStats.last_30d = period.count; }); populateStatsTime({ time_stats: timeStats }); } } catch (error) { log(`Error updating time stats from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from top_pubkeys monitoring event function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) { try { if (monitoringData.data_type !== 'top_pubkeys') { return; } // Update top pubkeys table with real-time data if (monitoringData.pubkeys && Array.isArray(monitoringData.pubkeys)) { // Pass total_events from monitoring data to the function populateStatsPubkeysFromMonitoring(monitoringData.pubkeys, monitoringData.total_events || 0); } } catch (error) { log(`Error updating top pubkeys from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from subscription_details monitoring event function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) { try { // DEBUG: Log every subscription_details event that arrives at the webpage // console.log('subscription_details', JSON.stringify(monitoringData, null, 2)); console.log('subscription_details decoded:', monitoringData); if (monitoringData.data_type !== 'subscription_details') { return; } // Update subscription details table with real-time data if (monitoringData.data && Array.isArray(monitoringData.data.subscriptions)) { populateSubscriptionDetailsTable(monitoringData.data.subscriptions); } } catch (error) { log(`Error updating subscription details from monitoring event: ${error.message}`, 'ERROR'); } } // Update statistics display from CPU metrics monitoring event function updateStatsFromCpuMonitoringEvent(monitoringData) { try { if (monitoringData.data_type !== 'cpu_metrics') { return; } // Update CPU metrics in the database statistics table if (monitoringData.process_id !== undefined) { updateStatsCell('process-id', monitoringData.process_id.toString()); } if (monitoringData.memory_usage_mb !== undefined) { updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB'); } if (monitoringData.current_cpu_core !== undefined) { updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core); } // Calculate CPU usage percentage if we have the data if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) { // For now, just show the raw process CPU time (simplified) // In a real implementation, you'd calculate deltas over time updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks'); } } catch (error) { log(`Error updating CPU metrics from monitoring event: ${error.message}`, 'ERROR'); } } // Populate event kinds table from monitoring data function populateStatsKindsFromMonitoring(kindsData, totalEvents) { const tableBody = document.getElementById('stats-kinds-table-body'); if (!tableBody) return; tableBody.innerHTML = ''; if (kindsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No event data'; tableBody.appendChild(row); return; } kindsData.forEach(kind => { const row = document.createElement('tr'); const percentage = totalEvents > 0 ? ((kind.count / totalEvents) * 100).toFixed(1) : '0.0'; row.innerHTML = ` ${kind.kind} ${kind.count} ${percentage}% `; tableBody.appendChild(row); }); } // Populate database overview table function populateStatsOverview(data) { if (!data) return; // Update individual cells with flash animation for changed values // Backend sends: total_bytes, total_blobs, first_upload, last_upload, version, process_id, memory_mb, cpu_core, fs_blob_count, fs_blob_size_mb updateStatsCell('db-size', data.total_bytes ? formatFileSize(data.total_bytes) : '-'); updateStatsCell('total-size', data.total_bytes ? formatFileSize(data.total_bytes) : '-'); updateStatsCell('total-events', data.total_blobs || '-'); updateStatsCell('oldest-event', data.first_upload ? formatTimestamp(data.first_upload) : '-'); updateStatsCell('newest-event', data.last_upload ? formatTimestamp(data.last_upload) : '-'); // System metrics from system table if (data.version) { updateStatsCell('version', data.version); } if (data.process_id) { updateStatsCell('process-id', data.process_id); } if (data.memory_mb) { updateStatsCell('memory-usage', data.memory_mb + ' MB'); } if (data.cpu_core) { updateStatsCell('cpu-core', 'Core ' + data.cpu_core); } if (data.fs_blob_count !== undefined) { updateStatsCell('fs-blob-count', data.fs_blob_count); } if (data.fs_blob_size_mb !== undefined) { updateStatsCell('fs-blob-size', data.fs_blob_size_mb + ' MB'); } } // Populate event kinds distribution table function populateStatsKinds(data) { const tableBody = document.getElementById('stats-kinds-table-body'); if (!tableBody) return; tableBody.innerHTML = ''; // Handle both old format (data.event_kinds) and new format (direct array from query_view) const kindsData = data.event_kinds || data; if (!Array.isArray(kindsData) || kindsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No blob type data'; tableBody.appendChild(row); return; } // Calculate total for percentages if not provided const total = kindsData.reduce((sum, item) => sum + (item.blob_count || item.count || 0), 0); kindsData.forEach(item => { const row = document.createElement('tr'); const mimeType = item.mime_type || item.kind || '-'; const count = item.blob_count || item.count || 0; const percentage = item.percentage || (total > 0 ? ((count / total) * 100).toFixed(1) : 0); row.innerHTML = ` ${mimeType} ${count} ${percentage}% `; tableBody.appendChild(row); }); } // Populate time-based statistics table function populateStatsTime(data) { if (!data) return; // Update cells with flash animation for changed values updateStatsCell('events-24h', data.blobs_24h || '0'); updateStatsCell('events-7d', data.blobs_7d || '0'); updateStatsCell('events-30d', data.blobs_30d || '0'); } // Populate top pubkeys table function populateStatsPubkeys(data) { const tableBody = document.getElementById('stats-pubkeys-table-body'); if (!tableBody) return; tableBody.innerHTML = ''; // Handle both old format (data.top_pubkeys) and new format (direct array from query_view) const pubkeysData = data.top_pubkeys || data; if (!Array.isArray(pubkeysData) || pubkeysData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No uploader data'; tableBody.appendChild(row); return; } // Calculate total for percentages if not provided const total = pubkeysData.reduce((sum, item) => sum + (item.blob_count || 0), 0); pubkeysData.forEach((item, index) => { const row = document.createElement('tr'); // Handle both uploader_pubkey (new) and pubkey (old) field names const pubkeyValue = item.uploader_pubkey || item.pubkey || '-'; const count = item.blob_count || 0; const totalBytes = item.total_bytes || 0; const percentage = item.percentage || (total > 0 ? ((count / total) * 100).toFixed(1) : 0); // Convert hex pubkey to npub for display let displayPubkey = pubkeyValue; let npubLink = displayPubkey; try { if (pubkeyValue && pubkeyValue.length === 64 && /^[0-9a-fA-F]+$/.test(pubkeyValue)) { const npub = window.NostrTools.nip19.npubEncode(pubkeyValue); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode pubkey to npub:', error.message); } row.innerHTML = ` ${index + 1} ${npubLink} ${count} ${formatFileSize(totalBytes)} ${percentage}% `; tableBody.appendChild(row); }); } // Populate top pubkeys table from monitoring data function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) { const tableBody = document.getElementById('stats-pubkeys-table-body'); if (!tableBody || !pubkeysData || !Array.isArray(pubkeysData)) return; tableBody.innerHTML = ''; if (pubkeysData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No pubkey data'; tableBody.appendChild(row); return; } pubkeysData.forEach((pubkey, index) => { const row = document.createElement('tr'); // Convert hex pubkey to npub for display let displayPubkey = pubkey.pubkey || '-'; let npubLink = displayPubkey; try { if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) { const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey); displayPubkey = npub; npubLink = `${npub}`; } } catch (error) { console.log('Failed to encode pubkey to npub:', error.message); } // Calculate percentage using totalEvents parameter const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0'; row.innerHTML = ` ${index + 1} ${npubLink} ${pubkey.event_count} ${percentage}% `; tableBody.appendChild(row); }); } // Populate subscription details table from monitoring data function populateSubscriptionDetailsTable(subscriptionsData) { const tableBody = document.getElementById('subscription-details-table-body'); if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return; // Store current expand/collapse state before rebuilding const expandedGroups = new Set(); const headerRows = tableBody.querySelectorAll('.subscription-group-header'); headerRows.forEach(header => { const wsiPointer = header.getAttribute('data-wsi-pointer'); const isExpanded = header.getAttribute('data-expanded') === 'true'; if (isExpanded) { expandedGroups.add(wsiPointer); } }); tableBody.innerHTML = ''; if (subscriptionsData.length === 0) { const row = document.createElement('tr'); row.innerHTML = 'No active subscriptions'; tableBody.appendChild(row); return; } // Sort subscriptions by wsi_pointer to group them together subscriptionsData.sort((a, b) => { const wsiA = a.wsi_pointer || ''; const wsiB = b.wsi_pointer || ''; return wsiA.localeCompare(wsiB); }); // Group subscriptions by wsi_pointer const groupedSubscriptions = {}; subscriptionsData.forEach(sub => { const wsiKey = sub.wsi_pointer || 'N/A'; if (!groupedSubscriptions[wsiKey]) { groupedSubscriptions[wsiKey] = []; } groupedSubscriptions[wsiKey].push(sub); }); // Create rows for each group Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => { // Calculate group summary const subCount = subscriptions.length; const now = Math.floor(Date.now() / 1000); const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at)); const oldestDurationStr = formatDuration(oldestDuration); // Create header row (summary) const headerRow = document.createElement('tr'); headerRow.className = 'subscription-group-header'; headerRow.setAttribute('data-wsi-pointer', wsiPointer); const wasExpanded = expandedGroups.has(wsiPointer); headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false'); headerRow.innerHTML = ` Websocket: ${wsiPointer} Subscriptions: ${subCount} | Oldest: ${oldestDurationStr} `; // Add click handler to toggle expansion headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer)); tableBody.appendChild(headerRow); // Create detail rows (initially hidden) subscriptions.forEach((subscription, index) => { const detailRow = document.createElement('tr'); detailRow.className = 'subscription-detail-row'; detailRow.setAttribute('data-wsi-group', wsiPointer); detailRow.style.display = 'none'; // Calculate duration const duration = now - subscription.created_at; const durationStr = formatDuration(duration); // Format filters let filtersDisplay = 'None'; if (subscription.filters && subscription.filters.length > 0) { const filterDetails = []; subscription.filters.forEach((filter) => { const parts = []; if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) { parts.push(`kinds:[${filter.kinds.join(',')}]`); } if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) { const authorCount = filter.authors.length; if (authorCount === 1) { const shortPubkey = filter.authors[0].substring(0, 8) + '...'; parts.push(`authors:[${shortPubkey}]`); } else { parts.push(`authors:[${authorCount} pubkeys]`); } } if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) { const idCount = filter.ids.length; parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`); } const timeParts = []; if (filter.since && filter.since > 0) { const sinceDate = new Date(filter.since * 1000).toLocaleString(); timeParts.push(`since:${sinceDate}`); } if (filter.until && filter.until > 0) { const untilDate = new Date(filter.until * 1000).toLocaleString(); timeParts.push(`until:${untilDate}`); } if (timeParts.length > 0) { parts.push(timeParts.join(', ')); } if (filter.limit && filter.limit > 0) { parts.push(`limit:${filter.limit}`); } if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) { parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`); } if (parts.length > 0) { filterDetails.push(parts.join(', ')); } else { filterDetails.push('empty filter'); } }); filtersDisplay = filterDetails.join(' | '); } detailRow.innerHTML = ` └─ ${subscription.id || 'N/A'} ${durationStr} ${filtersDisplay} `; tableBody.appendChild(detailRow); // Restore expand/collapse state after adding all rows if (wasExpanded) { const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`); detailRows.forEach(row => row.style.display = 'table-row'); const expandIcon = headerRow.querySelector('.expand-icon'); if (expandIcon) { expandIcon.textContent = '▼'; expandIcon.style.transform = 'rotate(90deg)'; } } }); }); } // Toggle function for expanding/collapsing groups function toggleSubscriptionGroup(wsiPointer) { const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`); const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`); const expandIcon = headerRow.querySelector('.expand-icon'); const isExpanded = headerRow.getAttribute('data-expanded') === 'true'; if (isExpanded) { // Collapse detailRows.forEach(row => row.style.display = 'none'); expandIcon.textContent = '▶'; expandIcon.style.transform = 'rotate(0deg)'; headerRow.setAttribute('data-expanded', 'false'); } else { // Expand detailRows.forEach(row => row.style.display = 'table-row'); expandIcon.textContent = '▼'; expandIcon.style.transform = 'rotate(90deg)'; headerRow.setAttribute('data-expanded', 'true'); } } // Helper function to format duration in human-readable format function formatDuration(seconds) { if (seconds < 60) return `${seconds}s`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`; } // Update statistics status indicator (disabled - status display removed) function updateStatsStatus(status, message = '') { // Status display has been removed from the UI return; } // Utility function to format file size function formatFileSize(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Utility function to format timestamp function formatTimestamp(timestamp) { if (!timestamp) return '-'; const date = new Date(timestamp * 1000); return date.toLocaleString(); } // Update statistics cell with flash animation if value changed function updateStatsCell(cellId, newValue) { const cell = document.getElementById(cellId); if (!cell) return; const currentValue = cell.textContent; cell.textContent = newValue; // Flash if value changed if (currentValue !== newValue && currentValue !== '-') { cell.classList.add('flash-value'); setTimeout(() => { cell.classList.remove('flash-value'); }, 500); } } // Start polling for statistics (every 10 seconds) function startStatsPolling() { console.log('=== STARTING STATISTICS POLLING ==='); // Initialize the event rate chart if not already initialized if (!eventRateChart) { console.log('Initializing event rate chart from startStatsPolling...'); setTimeout(() => { initializeEventRateChart(); }, 1000); // Delay to ensure text_graph.js is loaded } console.log('Current page:', currentPage); console.log('Is logged in:', isLoggedIn); console.log('User pubkey:', userPubkey); console.log('Relay pubkey:', relayPubkey); // Stop any existing polling first stopStatsPolling(); // Fetch immediately console.log('Fetching statistics immediately...'); sendStatsQuery().catch(error => { console.error('Initial stats fetch failed:', error); }); // Set up polling interval (10 seconds) console.log('Setting up 10-second polling interval...'); statsAutoRefreshInterval = setInterval(() => { console.log('⏰ Polling interval triggered - fetching statistics...'); sendStatsQuery().catch(error => { console.error('Polling stats fetch failed:', error); }); }, 10000); console.log('Statistics polling started successfully'); console.log('Interval ID:', statsAutoRefreshInterval); log('Statistics polling started (10 second interval)', 'INFO'); } // Stop polling for statistics function stopStatsPolling() { if (statsAutoRefreshInterval) { clearInterval(statsAutoRefreshInterval); statsAutoRefreshInterval = null; log('Statistics polling stopped', 'INFO'); } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } // Reset countdown display updateCountdownDisplay(); } // Legacy function - kept for backward compatibility function startStatsAutoRefresh() { // DISABLED - Using real-time monitoring events instead of polling // This function is kept for backward compatibility but no longer starts auto-refresh log('Database statistics auto-refresh DISABLED - using real-time monitoring events', 'INFO'); } // Legacy function - kept for backward compatibility function stopStatsAutoRefresh() { stopStatsPolling(); } // Original stopStatsAutoRefresh implementation (now unused) function stopStatsAutoRefresh_ORIGINAL() { if (statsAutoRefreshInterval) { clearInterval(statsAutoRefreshInterval); statsAutoRefreshInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } // Reset countdown display updateCountdownDisplay(); log('Database statistics auto-refresh stopped', 'INFO'); } // Update countdown display in refresh button function updateCountdownDisplay() { const refreshBtn = document.getElementById('refresh-stats-btn'); if (!refreshBtn) return; // DISABLED - No countdown display when using real-time monitoring // Show empty button text refreshBtn.textContent = ''; } // Flash refresh button red on successful refresh function flashRefreshButton() { const refreshBtn = document.getElementById('refresh-stats-btn'); if (!refreshBtn) return; // DISABLED - No flashing when using real-time monitoring // This function is kept for backward compatibility } // Event handlers for test buttons document.addEventListener('DOMContentLoaded', () => { // Test button event handlers const testGetAuthRulesBtn = document.getElementById('test-get-auth-rules-btn'); const testClearAuthRulesBtn = document.getElementById('test-clear-auth-rules-btn'); const testAddBlacklistBtn = document.getElementById('test-add-blacklist-btn'); const testAddWhitelistBtn = document.getElementById('test-add-whitelist-btn'); const testConfigQueryBtn = document.getElementById('test-config-query-btn'); const testPostEventBtn = document.getElementById('test-post-event-btn'); const clearTestLogBtn = document.getElementById('clear-test-log-btn'); const generateTestKeyBtn = document.getElementById('generate-test-key-btn'); if (testGetAuthRulesBtn) { testGetAuthRulesBtn.addEventListener('click', testGetAuthRules); } if (testClearAuthRulesBtn) { testClearAuthRulesBtn.addEventListener('click', testClearAuthRules); } if (testAddBlacklistBtn) { testAddBlacklistBtn.addEventListener('click', testAddBlacklist); } if (testAddWhitelistBtn) { testAddWhitelistBtn.addEventListener('click', testAddWhitelist); } if (testConfigQueryBtn) { testConfigQueryBtn.addEventListener('click', testConfigQuery); } if (testPostEventBtn) { testPostEventBtn.addEventListener('click', testPostEvent); } if (clearTestLogBtn) { clearTestLogBtn.addEventListener('click', () => { const testLog = document.getElementById('test-event-log'); if (testLog) { testLog.innerHTML = '
SYSTEM: Test log cleared.
'; } }); } if (generateTestKeyBtn) { generateTestKeyBtn.addEventListener('click', generateRandomTestKey); } // Show test input section when needed const testInputSection = document.getElementById('test-input-section'); if (testInputSection) { testInputSection.style.display = 'block'; } // Database statistics event handlers const refreshStatsBtn = document.getElementById('refresh-stats-btn'); if (refreshStatsBtn) { refreshStatsBtn.addEventListener('click', sendStatsQuery); } // Subscription details section is always visible when authenticated // NIP-17 DM event handlers if (sendDmBtn) { sendDmBtn.addEventListener('click', sendNIP17DM); } // SQL Query event handlers const executeSqlBtn = document.getElementById('execute-sql-btn'); const clearSqlBtn = document.getElementById('clear-sql-btn'); const clearHistoryBtn = document.getElementById('clear-history-btn'); if (executeSqlBtn) { executeSqlBtn.addEventListener('click', executeSqlQuery); } if (clearSqlBtn) { clearSqlBtn.addEventListener('click', clearSqlQuery); } if (clearHistoryBtn) { clearHistoryBtn.addEventListener('click', clearQueryHistory); } }); // Dark mode functionality function toggleDarkMode() { const body = document.body; const isDarkMode = body.classList.contains('dark-mode'); if (isDarkMode) { body.classList.remove('dark-mode'); localStorage.setItem('darkMode', 'false'); updateDarkModeButton(false); log('Switched to light mode', 'INFO'); } else { body.classList.add('dark-mode'); localStorage.setItem('darkMode', 'true'); updateDarkModeButton(true); log('Switched to dark mode', 'INFO'); } } function updateDarkModeButton(isDarkMode) { const navDarkModeBtn = document.getElementById('nav-dark-mode-btn'); if (navDarkModeBtn) { navDarkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE'; } } function initializeDarkMode() { const savedDarkMode = localStorage.getItem('darkMode'); const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark); if (shouldBeDark) { document.body.classList.add('dark-mode'); updateDarkModeButton(true); } else { updateDarkModeButton(false); } } // Side navigation functions function toggleSideNav() { const sideNav = document.getElementById('side-nav'); const overlay = document.getElementById('side-nav-overlay'); if (sideNavOpen) { sideNav.classList.remove('open'); overlay.classList.remove('show'); sideNavOpen = false; } else { sideNav.classList.add('open'); overlay.classList.add('show'); sideNavOpen = true; } } function closeSideNav() { const sideNav = document.getElementById('side-nav'); const overlay = document.getElementById('side-nav-overlay'); sideNav.classList.remove('open'); overlay.classList.remove('show'); sideNavOpen = false; } function switchPage(pageName) { // Keep statistics polling running in background regardless of page // (removed stopStatsPolling call - stats continue updating) // Update current page currentPage = pageName; // Start statistics polling if switching TO statistics page and not already running if (pageName === 'statistics' && !statsAutoRefreshInterval) { startStatsPolling(); } // Update navigation active state const navItems = document.querySelectorAll('.nav-item'); navItems.forEach(item => { item.classList.remove('active'); if (item.getAttribute('data-page') === pageName) { item.classList.add('active'); } }); // Hide all sections const sections = [ 'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config', 'authRulesSection', 'relayEventsSection', 'nip17DMSection', 'sqlQuerySection' ]; sections.forEach(sectionId => { const section = document.getElementById(sectionId); if (section) { section.style.display = 'none'; } }); // Show selected section const pageMap = { 'statistics': 'databaseStatisticsSection', 'subscriptions': 'subscriptionDetailsSection', 'configuration': 'div_config', 'authorization': 'authRulesSection', 'relay-events': 'relayEventsSection', 'dm': 'nip17DMSection', 'database': 'sqlQuerySection' }; const targetSectionId = pageMap[pageName]; if (targetSectionId) { const targetSection = document.getElementById(targetSectionId); if (targetSection) { targetSection.style.display = 'block'; } } // Special handling for configuration page - ensure config-display is visible and refresh data if (pageName === 'configuration') { const configDisplay = document.getElementById('config-display'); if (configDisplay) { configDisplay.classList.remove('hidden'); } // Always refresh configuration data when navigating to config page fetchConfiguration().catch(error => { console.log('Failed to refresh configuration on page switch: ' + error.message); }); } // Close side navigation closeSideNav(); log(`Switched to page: ${pageName}`, 'INFO'); } // Initialize the app document.addEventListener('DOMContentLoaded', () => { console.log('Blossom Admin API interface loaded'); // Initialize dark mode initializeDarkMode(); // Initialize sidebar button text const navDarkModeBtn = document.getElementById('nav-dark-mode-btn'); if (navDarkModeBtn) { navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE'; } // Start RELAY letter animation startRelayAnimation(); // Initialize real-time event rate chart setTimeout(() => { initializeEventRateChart(); }, 1000); // Delay to ensure text_graph.js is loaded // Initialize side navigation initializeSideNavigation(); // Ensure admin sections are hidden by default on page load updateAdminSectionsVisibility(); setTimeout(() => { initializeApp(); }, 100); }); // Initialize side navigation event handlers function initializeSideNavigation() { // Header title click handler const headerTitle = document.getElementById('header-title'); if (headerTitle) { headerTitle.addEventListener('click', toggleSideNav); } // Overlay click handler const overlay = document.getElementById('side-nav-overlay'); if (overlay) { overlay.addEventListener('click', closeSideNav); } // Navigation item click handlers const navItems = document.querySelectorAll('.nav-item'); navItems.forEach(item => { item.addEventListener('click', (e) => { const pageName = e.target.getAttribute('data-page'); if (pageName) { switchPage(pageName); } }); }); // Footer button handlers const navDarkModeBtn = document.getElementById('nav-dark-mode-btn'); const navLogoutBtn = document.getElementById('nav-logout-btn'); if (navDarkModeBtn) { navDarkModeBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleDarkMode(); // Update button text after toggle setTimeout(() => { navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE'; }, 10); closeSideNav(); }); } if (navLogoutBtn) { navLogoutBtn.addEventListener('click', (e) => { e.stopPropagation(); logout(); closeSideNav(); }); } // Set initial page switchPage(currentPage); } // ================================ // SQL QUERY FUNCTIONS // ================================ // Predefined query templates const SQL_QUERY_TEMPLATES = { recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20", event_stats: "SELECT * FROM event_stats", subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC", top_pubkeys: "SELECT * FROM top_pubkeys_view", event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC", time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)" }; // Query history management (localStorage) const QUERY_HISTORY_KEY = 'c_relay_sql_history'; const MAX_HISTORY_ITEMS = 20; // Load query history from localStorage function loadQueryHistory() { try { const history = localStorage.getItem(QUERY_HISTORY_KEY); return history ? JSON.parse(history) : []; } catch (e) { console.error('Failed to load query history:', e); return []; } } // Save query to history function saveQueryToHistory(query) { if (!query || query.trim().length === 0) return; try { let history = loadQueryHistory(); // Remove duplicate if exists history = history.filter(q => q !== query); // Add to beginning history.unshift(query); // Limit size if (history.length > MAX_HISTORY_ITEMS) { history = history.slice(0, MAX_HISTORY_ITEMS); } localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history)); updateQueryDropdown(); } catch (e) { console.error('Failed to save query history:', e); } } // Clear query history function clearQueryHistory() { if (confirm('Clear all query history?')) { localStorage.removeItem(QUERY_HISTORY_KEY); updateQueryDropdown(); } } // Update dropdown with history function updateQueryDropdown() { const historyGroup = document.getElementById('history-group'); if (!historyGroup) return; // Clear existing history options historyGroup.innerHTML = ''; const history = loadQueryHistory(); if (history.length === 0) { const option = document.createElement('option'); option.value = ''; option.textContent = '(no history)'; option.disabled = true; historyGroup.appendChild(option); return; } history.forEach((query, index) => { const option = document.createElement('option'); option.value = `history_${index}`; // Truncate long queries for display const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query; option.textContent = displayQuery; option.dataset.query = query; historyGroup.appendChild(option); }); } // Load selected query from dropdown function loadSelectedQuery() { const dropdown = document.getElementById('query-dropdown'); const selectedValue = dropdown.value; if (!selectedValue) return; let query = ''; // Check if it's a template if (SQL_QUERY_TEMPLATES[selectedValue]) { query = SQL_QUERY_TEMPLATES[selectedValue]; } // Check if it's from history else if (selectedValue.startsWith('history_')) { const selectedOption = dropdown.options[dropdown.selectedIndex]; query = selectedOption.dataset.query; } if (query) { document.getElementById('sql-input').value = query; } // Reset dropdown to placeholder dropdown.value = ''; } // Clear the SQL query input function clearSqlQuery() { document.getElementById('sql-input').value = ''; document.getElementById('query-info').innerHTML = ''; document.getElementById('query-table').innerHTML = ''; } // Execute SQL query via admin API using HTTP POST async function executeSqlQuery() { const query = document.getElementById('sql-input').value; if (!query.trim()) { log('Please enter a SQL query', 'ERROR'); document.getElementById('query-info').innerHTML = '
❌ Please enter a SQL query
'; return; } try { // Show loading state document.getElementById('query-info').innerHTML = '
Executing query...
'; document.getElementById('query-table').innerHTML = ''; // Save to history (before execution, so it's saved even if query fails) saveQueryToHistory(query.trim()); // Send SQL query via HTTP POST const responseData = await sendAdminCommandHTTP(["sql_query", query]); // Process the response handleSqlQueryResponse(responseData); } catch (error) { log('Failed to execute query: ' + error.message, 'ERROR'); document.getElementById('query-info').innerHTML = '
❌ Failed to execute query: ' + error.message + '
'; } } // Display SQL query results function displaySqlQueryResults(response) { const infoDiv = document.getElementById('query-info'); const tableDiv = document.getElementById('query-table'); if (response.status === 'error' || response.error) { infoDiv.innerHTML = `
❌ ${response.error || 'Query failed'}
`; tableDiv.innerHTML = ''; return; } // Show query info with request ID for debugging const rowCount = response.row_count || 0; const execTime = response.execution_time_ms || 0; const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown'; infoDiv.innerHTML = `
✅ Query executed successfully Rows: ${rowCount} Execution Time: ${execTime}ms Request: ${requestId}
`; // Build results table if (response.rows && response.rows.length > 0) { let html = ''; response.columns.forEach(col => { html += ``; }); html += ''; response.rows.forEach(row => { html += ''; row.forEach(cell => { const cellValue = cell === null ? 'NULL' : escapeHtml(String(cell)); html += ``; }); html += ''; }); html += '
${escapeHtml(col)}
${cellValue}
'; tableDiv.innerHTML = html; } else { tableDiv.innerHTML = '

No results returned

'; } } // Handle SQL query response (called by event listener) function handleSqlQueryResponse(response) { console.log('=== HANDLING SQL QUERY RESPONSE ==='); console.log('Response:', response); // Always display SQL query results when received displaySqlQueryResults(response); // Clean up any pending queries if (response.request_id && pendingSqlQueries.has(response.request_id)) { pendingSqlQueries.delete(response.request_id); } } // Helper function to escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Initialize query history on page load document.addEventListener('DOMContentLoaded', function() { updateQueryDropdown(); }); // RELAY letter animation function function startRelayAnimation() { const letters = document.querySelectorAll('.relay-letter'); let currentIndex = 0; function animateLetter() { // Remove underline from all letters first letters.forEach(letter => letter.classList.remove('underlined')); // Add underline to current letter if (letters[currentIndex]) { letters[currentIndex].classList.add('underlined'); } // Move to next letter currentIndex++; // If we've gone through all letters, remove all underlines and wait 4000ms then restart if (currentIndex > letters.length) { // Remove all underlines before the pause letters.forEach(letter => letter.classList.remove('underlined')); setTimeout(() => { currentIndex = 0; animateLetter(); }, 4000); } else { // Otherwise, continue to next letter after 200ms setTimeout(animateLetter, 100); } } // Start the animation animateLetter(); } // ================================ // CONFIG TOGGLE BUTTON COMPONENT // ================================ // Global registry for config toggle buttons const configToggleButtons = new Map(); // ConfigToggleButton class for tri-state boolean config toggles class ConfigToggleButton { constructor(configKey, container, options = {}) { this.configKey = configKey; this.container = container; this.state = 'false'; // Start in false state by default this.pendingValue = null; this.options = { dataType: 'boolean', category: 'monitoring', ...options }; this.render(); this.attachEventListeners(); // Register this button instance configToggleButtons.set(configKey, this); } render() { console.log('=== RENDERING CONFIG TOGGLE BUTTON ==='); console.log('Config key:', this.configKey); console.log('Container:', this.container); // Create button element this.button = document.createElement('button'); this.button.className = 'config-toggle-btn'; this.button.setAttribute('data-config-key', this.configKey); this.button.setAttribute('data-state', this.state); this.button.setAttribute('title', `Toggle ${this.configKey}`); this.updateIcon(); console.log('Button element created:', this.button); console.log('Container before append:', this.container); console.log('Container children before:', this.container.children.length); this.container.appendChild(this.button); console.log('Container children after:', this.container.children.length); console.log('Button in DOM:', document.contains(this.button)); } updateIcon() { const icons = { 'true': 'I', 'false': '0', 'indeterminate': '⟳' }; this.button.textContent = icons[this.state] || '?'; } setState(newState) { if (['true', 'false', 'indeterminate'].includes(newState)) { this.state = newState; this.button.setAttribute('data-state', newState); this.updateIcon(); } } async toggle() { console.log('=== TOGGLE BUTTON CLICKED ==='); console.log('Current state:', this.state); console.log('Button element:', this.button); if (this.state === 'indeterminate') { console.log('Ignoring toggle - currently indeterminate'); return; // Don't toggle while pending } // Toggle between true and false const newValue = this.state === 'true' ? 'false' : 'true'; this.pendingValue = newValue; console.log('Sending toggle command:', newValue); // Set to indeterminate while waiting this.setState('indeterminate'); // Create config object const configObj = { key: this.configKey, value: newValue, data_type: this.options.dataType, category: this.options.category }; console.log('Config object:', configObj); try { // Send config update command console.log('Sending config update command...'); await sendConfigUpdateCommand([configObj]); console.log('Config update command sent successfully'); log(`Config toggle sent: ${this.configKey} = ${newValue}`, 'INFO'); } catch (error) { console.log('Config update command failed:', error); log(`Failed to send config toggle: ${error.message}`, 'ERROR'); // Revert to previous state on error this.setState('false'); this.pendingValue = null; } } handleResponse(success, actualValue) { console.log('=== HANDLE RESPONSE ==='); console.log('Success:', success); console.log('Actual value:', actualValue); console.log('Pending value:', this.pendingValue); if (success) { console.log('Success - setting to actual server value:', actualValue); this.setState(actualValue); } else { console.log('Failed - reverting to false state'); // Failed - revert to false state this.setState('false'); } this.pendingValue = null; console.log('Pending value cleared'); } attachEventListeners() { this.button.addEventListener('click', () => this.toggle()); } } // Helper function to get a registered toggle button function getConfigToggleButton(configKey) { return configToggleButtons.get(configKey); } // Monitoring is now subscription-based - no toggle button needed // Monitoring automatically activates when someone subscribes to kind 24567 events function initializeMonitoringToggleButton() { console.log('=== MONITORING IS NOW SUBSCRIPTION-BASED ==='); console.log('No toggle button needed - monitoring activates automatically when subscribing to kind 24567'); log('Monitoring system is subscription-based - no manual toggle required', 'INFO'); return null; } // Monitoring is subscription-based - no toggle button response handling needed const originalHandleConfigUpdateResponse = handleConfigUpdateResponse; handleConfigUpdateResponse = function(responseData) { console.log('=== CONFIG UPDATE RESPONSE HANDLER ==='); console.log('Response data:', responseData); // Call original handler originalHandleConfigUpdateResponse(responseData); // Monitoring is now subscription-based - no toggle buttons to update console.log('Monitoring system is subscription-based - no toggle buttons to handle'); }; // Monitoring is now subscription-based - no toggle buttons needed function initializeToggleButtonsFromConfig(configData) { console.log('=== MONITORING IS SUBSCRIPTION-BASED ==='); console.log('No toggle buttons needed - monitoring activates automatically when subscribing to kind 24567'); log('Monitoring system initialized - subscription-based activation ready', 'INFO'); } // ================================ // RELAY EVENTS FUNCTIONS // ================================ // Handle received relay events function handleRelayEventReceived(event) { console.log('Handling relay event:', event.kind, event); switch (event.kind) { case 0: populateKind0Form(event); break; case 10050: populateKind10050Form(event); break; case 10002: populateKind10002Form(event); break; default: console.log('Unknown relay event kind:', event.kind); } } // Populate Kind 0 form (User Metadata) function populateKind0Form(event) { try { const metadata = JSON.parse(event.content); console.log('Populating Kind 0 form with:', metadata); // Update form fields const nameField = document.getElementById('kind0-name'); const aboutField = document.getElementById('kind0-about'); const pictureField = document.getElementById('kind0-picture'); const bannerField = document.getElementById('kind0-banner'); const nip05Field = document.getElementById('kind0-nip05'); const websiteField = document.getElementById('kind0-website'); if (nameField) nameField.value = metadata.name || ''; if (aboutField) aboutField.value = metadata.about || ''; if (pictureField) pictureField.value = metadata.picture || ''; if (bannerField) bannerField.value = metadata.banner || ''; if (nip05Field) nip05Field.value = metadata.nip05 || ''; if (websiteField) websiteField.value = metadata.website || ''; showStatus('kind0-status', 'Metadata loaded from relay', 'success'); } catch (error) { console.error('Error populating Kind 0 form:', error); showStatus('kind0-status', 'Error loading metadata', 'error'); } } // Populate Kind 10050 form (DM Relay List) function populateKind10050Form(event) { try { console.log('Populating Kind 10050 form with tags:', event.tags); // Extract relay URLs from "relay" tags const relayUrls = event.tags .filter(tag => tag[0] === 'relay' && tag[1]) .map(tag => tag[1]); const relaysField = document.getElementById('kind10050-relays'); if (relaysField) { relaysField.value = relayUrls.join('\n'); } showStatus('kind10050-status', 'DM relay list loaded from relay', 'success'); } catch (error) { console.error('Error populating Kind 10050 form:', error); showStatus('kind10050-status', 'Error loading DM relay list', 'error'); } } // Populate Kind 10002 form (Relay List) function populateKind10002Form(event) { try { console.log('Populating Kind 10002 form with tags:', event.tags); // Clear existing entries const container = document.getElementById('kind10002-relay-entries'); if (container) { container.innerHTML = ''; } // Extract relay entries from "r" tags event.tags.forEach(tag => { if (tag[0] === 'r' && tag[1]) { const url = tag[1]; const marker = tag[2] || 'read'; // Default to read if no marker const read = marker.includes('read'); const write = marker.includes('write'); addRelayEntry(url, read, write); } }); showStatus('kind10002-status', 'Relay list loaded from relay', 'success'); } catch (error) { console.error('Error populating Kind 10002 form:', error); showStatus('kind10002-status', 'Error loading relay list', 'error'); } } // Submit Kind 0 event via HTTP POST async function submitKind0Event() { try { showStatus('kind0-status', 'Submitting metadata...', 'info'); // Collect form data const metadata = { name: document.getElementById('kind0-name').value.trim(), about: document.getElementById('kind0-about').value.trim(), picture: document.getElementById('kind0-picture').value.trim(), banner: document.getElementById('kind0-banner').value.trim(), nip05: document.getElementById('kind0-nip05').value.trim(), website: document.getElementById('kind0-website').value.trim() }; // Remove empty fields Object.keys(metadata).forEach(key => { if (!metadata[key]) delete metadata[key]; }); // Validate required fields if (!metadata.name) { showStatus('kind0-status', 'Name is required', 'error'); return; } // Send create_relay_event command via HTTP POST const responseData = await sendAdminCommandHTTP(["create_relay_event", 0, metadata]); handleAdminResponseData(responseData); showStatus('kind0-status', 'Metadata updated successfully', 'success'); } catch (error) { console.error('Error submitting Kind 0 event:', error); showStatus('kind0-status', 'Error updating metadata: ' + error.message, 'error'); } } // Submit Kind 10050 event via HTTP POST async function submitKind10050Event() { try { showStatus('kind10050-status', 'Submitting DM relay list...', 'info'); // Parse textarea content const relaysText = document.getElementById('kind10050-relays').value.trim(); const relays = relaysText.split('\n') .map(url => url.trim()) .filter(url => url.length > 0) .filter(url => isValidRelayUrl(url)); if (relays.length === 0) { showStatus('kind10050-status', 'At least one valid relay URL is required', 'error'); return; } // Send create_relay_event command via HTTP POST const responseData = await sendAdminCommandHTTP(["create_relay_event", 10050, { relays }]); handleAdminResponseData(responseData); showStatus('kind10050-status', 'DM relay list updated successfully', 'success'); } catch (error) { console.error('Error submitting Kind 10050 event:', error); showStatus('kind10050-status', 'Error updating DM relay list: ' + error.message, 'error'); } } // Submit Kind 10002 event via HTTP POST async function submitKind10002Event() { try { showStatus('kind10002-status', 'Submitting relay list...', 'info'); // Collect relay entries const relays = []; const entries = document.querySelectorAll('.relay-entry'); entries.forEach(entry => { const url = entry.querySelector('.relay-url').value.trim(); const read = entry.querySelector('.relay-read').checked; const write = entry.querySelector('.relay-write').checked; if (url && isValidRelayUrl(url)) { relays.push({ url: url, read: read, write: write }); } }); if (relays.length === 0) { showStatus('kind10002-status', 'At least one valid relay entry is required', 'error'); return; } // Send create_relay_event command via HTTP POST const responseData = await sendAdminCommandHTTP(["create_relay_event", 10002, { relays }]); handleAdminResponseData(responseData); showStatus('kind10002-status', 'Relay list updated successfully', 'success'); } catch (error) { console.error('Error submitting Kind 10002 event:', error); showStatus('kind10002-status', 'Error updating relay list: ' + error.message, 'error'); } } // Validation helpers function isValidUrl(url) { try { new URL(url); return true; } catch { return false; } } function isValidRelayUrl(url) { if (!isValidUrl(url)) return false; return url.startsWith('ws://') || url.startsWith('wss://'); } // UI helpers function showStatus(elementId, message, type = 'info') { const element = document.getElementById(elementId); if (!element) return; // Remove emojis from message const cleanMessage = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, ''); element.textContent = cleanMessage; element.className = 'status-message'; element.style.display = 'block'; // Ensure it's visible // Add type-specific styling switch (type) { case 'success': element.style.color = 'var(--accent-color)'; break; case 'error': element.style.color = '#ff0000'; break; case 'info': default: element.style.color = 'var(--primary-color)'; break; } // Auto-hide after 5 seconds setTimeout(() => { element.style.display = 'none'; }, 5000); } function addRelayEntry(url = '', read = true, write = true) { const container = document.getElementById('kind10002-relay-entries'); if (!container) return; const entryDiv = document.createElement('div'); entryDiv.className = 'relay-entry'; entryDiv.innerHTML = `
`; container.appendChild(entryDiv); } function removeRelayEntry(button) { const entry = button.closest('.relay-entry'); if (entry) { entry.remove(); } } // Initialize toggle button after DOM is ready document.addEventListener('DOMContentLoaded', function() { console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ==='); // Initialize the monitoring toggle button setTimeout(() => { console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ==='); initializeMonitoringToggleButton(); }, 500); // Small delay to ensure DOM is fully ready // Initialize relay events functionality initializeRelayEvents(); }); // Initialize relay events functionality function initializeRelayEvents() { console.log('Initializing relay events functionality...'); // Set up event handlers for relay events page const submitKind0Btn = document.getElementById('submit-kind0-btn'); const submitKind10050Btn = document.getElementById('submit-kind10050-btn'); const submitKind10002Btn = document.getElementById('submit-kind10002-btn'); const addRelayEntryBtn = document.getElementById('add-relay-entry-btn'); if (submitKind0Btn) { submitKind0Btn.addEventListener('click', submitKind0Event); } if (submitKind10050Btn) { submitKind10050Btn.addEventListener('click', submitKind10050Event); } if (submitKind10002Btn) { submitKind10002Btn.addEventListener('click', submitKind10002Event); } if (addRelayEntryBtn) { addRelayEntryBtn.addEventListener('click', () => addRelayEntry()); } // Add one empty relay entry by default for Kind 10002 const kind10002Container = document.getElementById('kind10002-relay-entries'); if (kind10002Container && kind10002Container.children.length === 0) { addRelayEntry(); // Add one empty entry to start console.log('Added initial empty relay entry for Kind 10002'); } console.log('Relay events functionality initialized'); }