2025-05-16 18:17:54 +04:00

532 lines
18 KiB
JavaScript

// 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 = '<p>No blocks found</p>';
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 = `
<strong>${block.name}</strong> (ID: ${block.id})
<small style="display: block; margin-top: 4px; color: #666;">Last update: ${timeAgo(new Date(block.timestamp))}</small>
`;
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 = `
<h3>${blockName}</h3>
<p>Block ID: ${blockId}</p>
<p><small>Real-time updates enabled</small></p>
`;
// 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 = '<p>No events recorded for this block</p>';
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 = `
<div class="timeline-time">${date.toLocaleString()}</div>
<span class="action-type">${actionLabel}</span>
`;
// Add position information - always show current position
let position = event.NewPosition || event.Position;
if (position) {
timelineContent += `
<div class="block-action-details">
<strong>Position:</strong> (${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 += `
<br><strong>Old Position:</strong> (${event.OldPosition.X.toFixed(2)}, ${event.OldPosition.Y.toFixed(2)}, ${event.OldPosition.Z.toFixed(2)})
`;
}
timelineContent += '</div>';
}
// Add attributes - always show all attributes
if (event.Attributes && Object.keys(event.Attributes).length > 0) {
timelineContent += '<div class="block-action-details">';
timelineContent += '<button class="collapsible">Attributes</button>';
timelineContent += '<div class="collapse-content">';
timelineContent += '<div class="attribute-grid">';
// 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 += `
<div>
<strong>${key}:</strong> <span class="${attributeClass}">${value}</span>
</div>
`;
}
timelineContent += '</div>'; // Close attribute-grid
timelineContent += '</div>'; // Close collapse-content
// If this was an attribute change, show what changed
if (event.EventType === 'BlockAttributesChanged' && event.AttributeChanges) {
timelineContent += '<div class="attribute-section"><strong>Changed Values:</strong>';
for (const [key, change] of Object.entries(event.AttributeChanges)) {
timelineContent += `
<div>
<strong>${key}:</strong>
<span class="initial">${change.OldValue}</span> →
<span class="changes">${change.NewValue}</span>
</div>
`;
}
timelineContent += '</div>'; // Close attribute-section
}
timelineContent += '</div>'; // Close block-action-details
}
// Add other transformation details if available
if (event.EventType === 'BlockRotated' && event.OldRotation !== undefined && event.NewRotation !== undefined) {
timelineContent += `
<div class="block-action-details">
<strong>Rotation:</strong> ${(event.NewRotation * 180 / Math.PI).toFixed(2)}°
<br><strong>Old Rotation:</strong> ${(event.OldRotation * 180 / Math.PI).toFixed(2)}°
</div>
`;
} else if (event.EventType === 'BlockScaled' && event.OldScale && event.NewScale) {
timelineContent += `
<div class="block-action-details">
<strong>Scale:</strong> X=${event.NewScale.X.toFixed(3)}, Y=${event.NewScale.Y.toFixed(3)}, Z=${event.NewScale.Z.toFixed(3)}
<br><strong>Old Scale:</strong> X=${event.OldScale.X.toFixed(3)}, Y=${event.OldScale.Y.toFixed(3)}, Z=${event.OldScale.Z.toFixed(3)}
</div>
`;
}
timelineItem.innerHTML = timelineContent;
timelineDiv.appendChild(timelineItem);
});
// Restore scroll position
timelineDiv.scrollTop = scrollPosition;
// Initialize collapsible sections
activateCollapsibles();
})
.catch(error => {
document.getElementById('blockTimeline').innerHTML = `<p>Error: ${error.message}</p>`;
});
}
// 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);
}