// Store the current blocks for comparison window.currentBlocks = {}; window.selectedBlockId = null; window.eventSource = null; window.maxReconnectAttempts = 5; window.reconnectAttempts = 0; window.reconnectTimeout = null; window.serverConnected = false; // Fetch blocks on page load window.onload = function() { fetchBlocks(); setupSSEConnection(); // Add manual reconnect button listeners const reconnectButton = document.getElementById('reconnect-button'); if (reconnectButton) { reconnectButton.addEventListener('click', function() { window.reconnectAttempts = 0; setupSSEConnection(); }); } else { console.error('Reconnect button not found in DOM'); } }; // Set up SSE connection for real-time updates function setupSSEConnection() { // Clear any existing SSE connection if (window.eventSource) { window.eventSource.close(); window.eventSource = null; } // Clear any pending reconnect timeout if (window.reconnectTimeout) { clearTimeout(window.reconnectTimeout); window.reconnectTimeout = null; } // Update connection status UI updateConnectionStatus('connecting'); // First check if server is reachable before attempting SSE connection fetch('/api/server-status') .then(response => { if (!response.ok) { throw new Error('Server returned status: ' + response.status); } return response.json(); }) .then(data => { console.log('Server is online:', data); // If server check succeeds, now establish SSE connection connectEventSource(); }) .catch(error => { console.error('Server status check failed:', error); updateConnectionStatus('failed'); // Only attempt to reconnect if we haven't exceeded max attempts if (window.reconnectAttempts < window.maxReconnectAttempts) { window.reconnectAttempts++; // Increase delay with each attempt (exponential backoff) const delay = Math.min(30000, 1000 * Math.pow(2, window.reconnectAttempts)); console.log(`Reconnect attempt ${window.reconnectAttempts}/${window.maxReconnectAttempts} in ${delay/1000} seconds`); updateConnectionStatus('waiting', delay/1000); // Try to reconnect after a delay window.reconnectTimeout = setTimeout(() => { setupSSEConnection(); }, delay); } else { console.log('Max reconnect attempts reached. Please reconnect manually.'); updateConnectionStatus('failed'); } }); } // Function to establish the actual EventSource connection function connectEventSource() { try { // Set up new SSE connection window.eventSource = new EventSource('/api/sse'); // Handle connection event window.eventSource.addEventListener('connected', function(e) { console.log('SSE Connection established'); window.reconnectAttempts = 0; window.serverConnected = true; updateConnectionStatus('connected'); }); // Handle real-time events window.eventSource.addEventListener('new-event', function(e) { try { const newEvent = JSON.parse(e.data); // Check if this is a block event if (newEvent.EventType && newEvent.EventType.startsWith('Block')) { console.log('Received real-time update:', newEvent); // Always refresh the blocks list to catch new/updated blocks fetchBlocks(); // Show notification showNotification(`${newEvent.BlockName}: ${newEvent.EventType}`); // If this event is for the currently selected block, update its timeline if (window.selectedBlockId && newEvent.ObjectId === window.selectedBlockId) { fetchBlockEvents(window.selectedBlockId); } } } catch (error) { console.error('Error processing real-time event:', error); } }); // Handle connection errors window.eventSource.addEventListener('error', function(e) { console.error('SSE connection error:', e); window.serverConnected = false; // Close the connection if (window.eventSource) { window.eventSource.close(); window.eventSource = null; } // Update connection status updateConnectionStatus('disconnected'); // Only attempt to reconnect if we haven't exceeded max attempts if (window.reconnectAttempts < window.maxReconnectAttempts) { window.reconnectAttempts++; // Increase delay with each attempt (exponential backoff) const delay = Math.min(30000, 1000 * Math.pow(2, window.reconnectAttempts)); console.log(`Reconnect attempt ${window.reconnectAttempts}/${window.maxReconnectAttempts} in ${delay/1000} seconds`); updateConnectionStatus('waiting', delay/1000); // Try to reconnect after a delay window.reconnectTimeout = setTimeout(() => { setupSSEConnection(); }, delay); } else { console.log('Max reconnect attempts reached. Please reconnect manually.'); updateConnectionStatus('failed'); } }); } catch (error) { console.error('Error setting up SSE connection:', error); updateConnectionStatus('failed'); } // When leaving the page, close the connection window.addEventListener('beforeunload', function() { if (window.eventSource) { window.eventSource.close(); window.eventSource = null; } }); } // Function to update connection status UI function updateConnectionStatus(status, seconds) { const statusElement = document.getElementById('connection-status'); const reconnectButton = document.getElementById('reconnect-button'); if (!statusElement) return; statusElement.className = 'status-indicator ' + status; switch(status) { case 'connected': statusElement.textContent = 'Connected'; reconnectButton.style.display = 'none'; break; case 'connecting': statusElement.textContent = 'Connecting...'; reconnectButton.style.display = 'none'; break; case 'disconnected': statusElement.textContent = 'Disconnected'; reconnectButton.style.display = 'inline-block'; break; case 'waiting': statusElement.textContent = `Reconnecting in ${seconds}s`; reconnectButton.style.display = 'inline-block'; break; case 'failed': statusElement.textContent = 'Connection failed'; reconnectButton.style.display = 'inline-block'; break; default: statusElement.textContent = 'Unknown status'; reconnectButton.style.display = 'inline-block'; } } // Fetch all blocks function fetchBlocks() { fetch('/api/blocks') .then(response => response.json()) .then(blocks => { const blocksDiv = document.getElementById('blocks'); // Save currently selected block ID const currentlySelectedId = window.selectedBlockId; // Store new blocks list but keep track of which ones are new/updated const updatedBlocks = {}; // Prepare to rebuild the blocks list blocksDiv.innerHTML = ''; if (blocks.length === 0) { blocksDiv.innerHTML = '

No blocks found

'; return; } blocks.forEach(block => { const blockDiv = document.createElement('div'); blockDiv.className = 'block-item'; // Check if this block was recently updated const existingBlock = window.currentBlocks[block.id]; const isUpdated = existingBlock && (existingBlock.lastEventType !== block.lastEventType || existingBlock.timestamp !== block.timestamp); // Check if this is a new block (not in currentBlocks) const isNew = !window.currentBlocks[block.id]; if (isUpdated) { blockDiv.classList.add('recently-updated'); } if (isNew) { blockDiv.classList.add('recently-updated'); showNotification(`New block detected: ${block.name}`); } // Mark as active if this was the selected block if (currentlySelectedId && currentlySelectedId === block.id) { blockDiv.classList.add('active'); } blockDiv.dataset.id = block.id; blockDiv.onclick = () => { // Set active class document.querySelectorAll('.block-item').forEach(item => { item.classList.remove('active'); }); blockDiv.classList.add('active'); // Store selected block ID window.selectedBlockId = block.id; // Load timeline loadBlockTimeline(block.id, block.name); }; blockDiv.innerHTML = ` ${block.name} (ID: ${block.id}) Last update: ${timeAgo(new Date(block.timestamp))} `; blocksDiv.appendChild(blockDiv); // Store this block for future comparison updatedBlocks[block.id] = block; }); // Update the stored blocks list window.currentBlocks = updatedBlocks; }) .catch(error => console.error('Error fetching blocks:', error)); } // Helper function to format time ago function timeAgo(date) { const seconds = Math.floor((new Date() - date) / 1000); let interval = Math.floor(seconds / 31536000); if (interval > 1) return interval + ' years ago'; if (interval === 1) return 'a year ago'; interval = Math.floor(seconds / 2592000); if (interval > 1) return interval + ' months ago'; if (interval === 1) return 'a month ago'; interval = Math.floor(seconds / 86400); if (interval > 1) return interval + ' days ago'; if (interval === 1) return 'a day ago'; interval = Math.floor(seconds / 3600); if (interval > 1) return interval + ' hours ago'; if (interval === 1) return 'an hour ago'; interval = Math.floor(seconds / 60); if (interval > 1) return interval + ' minutes ago'; if (interval === 1) return 'a minute ago'; if (seconds < 10) return 'just now'; return Math.floor(seconds) + ' seconds ago'; } // Load block timeline function loadBlockTimeline(blockId, blockName) { // Display block info const blockInfo = document.getElementById('blockInfo'); blockInfo.innerHTML = `

${blockName}

Block ID: ${blockId}

Real-time updates enabled

`; // Load block events fetchBlockEvents(blockId); } // Fetch block events function fetchBlockEvents(blockId) { // Track current event count const currentEvents = document.querySelectorAll('.timeline-item').length; // Fetch block events fetch('/api/events') .then(response => response.json()) .then(events => { // Filter events for this block const blockEvents = events.filter(event => event.ObjectId === blockId && event.EventType && event.EventType.startsWith('Block') ).sort((a, b) => new Date(a.Timestamp) - new Date(b.Timestamp)); // Check if we have new events if (blockEvents.length > currentEvents && currentEvents > 0) { showNotification(`${blockEvents.length - currentEvents} new event(s) received`); } const timelineDiv = document.getElementById('blockTimeline'); // Store the scroll position const scrollPosition = timelineDiv.scrollTop; // Clear current timeline timelineDiv.innerHTML = ''; if (blockEvents.length === 0) { timelineDiv.innerHTML = '

No events recorded for this block

'; return; } // Process events blockEvents.forEach(event => { const date = new Date(event.Timestamp); // Create timeline item const timelineItem = document.createElement('div'); // Set appropriate class based on event type let eventClass = ''; let actionLabel = ''; switch(event.EventType) { case 'BlockAttributesChanged': eventClass = 'attribute-edit'; actionLabel = 'Attribute Edit'; break; case 'BlockMoved': eventClass = 'moved'; actionLabel = 'Moved'; break; case 'BlockRotated': eventClass = 'rotated'; actionLabel = 'Rotated'; break; case 'BlockScaled': eventClass = 'scaled'; actionLabel = 'Scaled'; break; case 'BlockStretched': eventClass = 'stretched'; actionLabel = 'Stretched'; break; default: eventClass = ''; actionLabel = event.EventType.replace('Block', ''); } timelineItem.className = `timeline-item ${eventClass}`; // Create content for timeline item let timelineContent = `
${date.toLocaleString()}
${actionLabel} `; // Add position information - always show current position let position = event.NewPosition || event.Position; if (position) { timelineContent += `
Position: (${position.X.toFixed(2)}, ${position.Y.toFixed(2)}, ${position.Z.toFixed(2)}) `; // Add old position if this was a move event if (event.EventType === 'BlockMoved' && event.OldPosition) { timelineContent += `
Old Position: (${event.OldPosition.X.toFixed(2)}, ${event.OldPosition.Y.toFixed(2)}, ${event.OldPosition.Z.toFixed(2)}) `; } timelineContent += '
'; } // Add attributes - always show all attributes if (event.Attributes && Object.keys(event.Attributes).length > 0) { timelineContent += '
'; timelineContent += ''; timelineContent += '
'; timelineContent += '
'; // Sort the attributes by key for consistent display const sortedAttributes = Object.keys(event.Attributes).sort().reduce( (obj, key) => { obj[key] = event.Attributes[key]; return obj; }, {} ); for (const [key, value] of Object.entries(sortedAttributes)) { // Check if this attribute was changed in this event let attributeClass = 'attribute'; if (event.AttributeChanges && event.AttributeChanges[key]) { attributeClass = 'changes'; } timelineContent += `
${key}: ${value}
`; } timelineContent += '
'; // Close attribute-grid timelineContent += '
'; // Close collapse-content // If this was an attribute change, show what changed if (event.EventType === 'BlockAttributesChanged' && event.AttributeChanges) { timelineContent += '
Changed Values:'; for (const [key, change] of Object.entries(event.AttributeChanges)) { timelineContent += `
${key}: ${change.OldValue}${change.NewValue}
`; } timelineContent += '
'; // Close attribute-section } timelineContent += '
'; // Close block-action-details } // Add other transformation details if available if (event.EventType === 'BlockRotated' && event.OldRotation !== undefined && event.NewRotation !== undefined) { timelineContent += `
Rotation: ${(event.NewRotation * 180 / Math.PI).toFixed(2)}°
Old Rotation: ${(event.OldRotation * 180 / Math.PI).toFixed(2)}°
`; } else if (event.EventType === 'BlockScaled' && event.OldScale && event.NewScale) { timelineContent += `
Scale: X=${event.NewScale.X.toFixed(3)}, Y=${event.NewScale.Y.toFixed(3)}, Z=${event.NewScale.Z.toFixed(3)}
Old Scale: X=${event.OldScale.X.toFixed(3)}, Y=${event.OldScale.Y.toFixed(3)}, Z=${event.OldScale.Z.toFixed(3)}
`; } timelineItem.innerHTML = timelineContent; timelineDiv.appendChild(timelineItem); }); // Restore scroll position timelineDiv.scrollTop = scrollPosition; // Initialize collapsible sections activateCollapsibles(); }) .catch(error => { document.getElementById('blockTimeline').innerHTML = `

Error: ${error.message}

`; }); } // Helper function to activate collapsible sections after refresh function activateCollapsibles() { const collapsibles = document.querySelectorAll('.collapsible'); collapsibles.forEach(collapsible => { collapsible.addEventListener('click', function() { this.classList.toggle('active-collapse'); const content = this.nextElementSibling; if (content.style.maxHeight) { content.style.maxHeight = null; } else { content.style.maxHeight = content.scrollHeight + "px"; } }); }); } // Function to show notification function showNotification(message) { const notification = document.getElementById('notification-message'); notification.textContent = message; const notificationDiv = document.getElementById('notification'); notificationDiv.className = 'notification show'; setTimeout(() => { notificationDiv.className = 'notification'; }, 3000); }