532 lines
18 KiB
JavaScript
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);
|
|
}
|