diff --git a/README.md b/README.md index 9a58615..f85348c 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b ![UI](./assets/topology.png) ### Events ![UI](./assets/events.png) +![UI](./assets/events-messages.png) ### Monitoring ![UI](./assets/monitoring.png) ### Firmware diff --git a/assets/events-messages.png b/assets/events-messages.png new file mode 100644 index 0000000..c540699 Binary files /dev/null and b/assets/events-messages.png differ diff --git a/public/scripts/components/EventComponent.js b/public/scripts/components/EventComponent.js index 51901a0..1857342 100644 --- a/public/scripts/components/EventComponent.js +++ b/public/scripts/components/EventComponent.js @@ -19,6 +19,12 @@ class EventComponent extends Component { // Track recent events to trigger animations this.lastSeenEvents = new Set(); + + // Track rendered message IDs for animation + this.renderedMessageIds = new Set(); + + // Track selected node for highlighting + this.selectedNodeId = null; } updateDimensions() { @@ -414,15 +420,25 @@ class EventComponent extends Component { .style('opacity', 0) .call(this.drag()); - // Add circle - nodesEnter.append('circle') + // Add circle with click handler for non-center nodes + const circlesEnter = nodesEnter.append('circle') .attr('r', d => d.type === 'center' ? 18 : 14) .attr('fill', d => { if (d.type === 'center') return '#3498db'; return '#10b981'; }) .attr('stroke', '#fff') - .attr('stroke-width', 2.5); + .attr('stroke-width', 2.5) + .style('cursor', d => d.type === 'center' ? 'default' : 'pointer'); + + // Add outer ring for highlighted nodes + nodesEnter.append('circle') + .attr('class', 'node-highlight') + .attr('r', d => d.type === 'center' ? 22 : 18) + .attr('fill', 'none') + .attr('stroke', '#FFD700') + .attr('stroke-width', 3) + .attr('opacity', 0); // Add text labels nodesEnter.append('text') @@ -433,20 +449,37 @@ class EventComponent extends Component { .attr('font-weight', '600') .text(d => d.label); + // Add click handler to open drawer for topic nodes + nodesEnter.on('click', (event, d) => { + if (d.type !== 'center' && d.id) { + this.selectedNodeId = d.id; + // Update the graph to show highlight + if (this.svg) { + this.updateSimulation(); + } + this.openTopicDrawer(d.id); + } + }); + // Merge and update all nodes const nodesUpdate = nodesEnter.merge(nodes); // Update node visibility nodesUpdate.style('opacity', 1); - // Update circles - nodesUpdate.select('circle') + // Update circles (excluding highlight ring) + nodesUpdate.select('circle:not(.node-highlight)') .attr('r', d => d.type === 'center' ? 18 : 14) .attr('fill', d => { if (d.type === 'center') return '#3498db'; return '#10b981'; }); + // Update highlight rings + nodesUpdate.select('.node-highlight') + .attr('opacity', d => d.id === this.selectedNodeId ? 1 : 0) + .classed('highlighted', d => d.id === this.selectedNodeId); + // Update text labels nodesUpdate.select('text') .text(d => d.label); @@ -807,6 +840,289 @@ class EventComponent extends Component { animationGroup.remove(); }); } + + // Get topics that contain this node ID (part) + getTopicsForNodeId(nodeId) { + const events = this.viewModel.get('events'); + const topics = []; + + for (const [topic, data] of events) { + if (data.parts && data.parts.includes(nodeId)) { + topics.push(topic); + } + } + + return topics; + } + + // Open drawer to show messages for a topic node + openTopicDrawer(nodeId) { + // Find topics that contain this node + const topics = this.getTopicsForNodeId(nodeId); + + if (topics.length === 0) { + logger.warn('EventComponent: No topics found for node', nodeId); + return; + } + + // Store all matching topics + this._selectedNodeId = nodeId; + this._selectedTopics = topics; + + // Set selected topic in view model (first topic for backwards compatibility) + this.viewModel.setSelectedTopic(topics[0]); + + // Update highlight state + this.selectedNodeId = nodeId; + this.updateSimulation(); + + // Open drawer with all matching topics + this.openMessagesDrawer(nodeId, topics); + } + + // Open the messages drawer + openMessagesDrawer(nodeId, topics) { + if (!window.DrawerComponent) { + logger.error('EventComponent: DrawerComponent not available'); + return; + } + + const drawer = new window.DrawerComponent(); + + const title = topics.length === 1 + ? `Live Messages: ${topics[0]}` + : `Live Messages: ${nodeId} (${topics.length} topics)`; + + drawer.openDrawer( + title, + (container) => { + this.renderMessageList(container, nodeId, topics); + }, + null, + () => { + // On close callback - cleanup + if (this._messageUnsubscribe) { + this._messageUnsubscribe(); + this._messageUnsubscribe = null; + } + this._selectedNodeId = null; + this._selectedTopics = null; + this.selectedNodeId = null; + // Update graph to remove highlight + this.updateSimulation(); + this.viewModel.clearSelectedTopic(); + }, + true // Hide terminal button for events drawer + ); + } + + // Render message list in the drawer + renderMessageList(container, nodeId, topics) { + // Clear loading state + container.innerHTML = ''; + + // Create scrollable container + const messagesContainer = document.createElement('div'); + messagesContainer.className = 'topic-messages-container'; + + // Create header info + const header = document.createElement('div'); + header.className = 'message-list-header'; + + // Build topic display + let topicDisplay = ''; + if (topics.length === 1) { + topicDisplay = `${topics[0]}`; + } else { + topicDisplay = ` + ${topics.map(t => `${t}`).join('')} +
(${topics.length} topics for node: ${nodeId}) + `; + } + + header.innerHTML = ` +
+ Topics: ${topicDisplay} +
+
+ Showing live websocket messages for these topics +
+ `; + messagesContainer.appendChild(header); + + // Message list + const messageList = document.createElement('div'); + messageList.id = `message-list-${nodeId}`; + messageList.className = 'message-list'; + messagesContainer.appendChild(messageList); + + container.appendChild(messagesContainer); + + // Initial render + this.updateMessageList(nodeId, topics); + + // Subscribe to allMessagesCounter changes to update in real-time + // Store unsubscribe function for cleanup + const updateHandler = () => { + this.updateMessageList(nodeId, topics); + }; + + // Subscribe to the counter which changes on every new message + this._messageUnsubscribe = this.viewModel.subscribe('allMessagesCounter', updateHandler); + } + + // Update the message list display + updateMessageList(nodeId, topics) { + const messageList = document.getElementById(`message-list-${nodeId}`); + if (!messageList) return; + + // Get messages for all topics + const allMessages = []; + topics.forEach(topic => { + const messages = this.viewModel.getMessagesForTopic(topic); + allMessages.push(...messages); + }); + + // Sort by timestamp (newest first) + allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); + + // Clear existing content + messageList.innerHTML = ''; + + if (allMessages.length === 0) { + const emptyMsg = document.createElement('div'); + emptyMsg.className = 'empty-message-state'; + emptyMsg.textContent = 'No messages yet. Waiting for websocket events...'; + messageList.appendChild(emptyMsg); + return; + } + + // Show messages (newest at top) + allMessages.forEach((msg, index) => { + // Check if this is a new message (not previously rendered) + const isNewMessage = !this.renderedMessageIds.has(msg.id); + + const messageDiv = document.createElement('div'); + messageDiv.className = 'message-item'; + if (isNewMessage) { + // Add animation class for new messages + messageDiv.classList.add('message-item-new'); + } + + // Timestamp + const timestamp = new Date(msg.timestamp); + const timeStr = timestamp.toLocaleTimeString(); + + // Get full message data (exclude internal properties) + const messageData = this.getDisplayableMessageData(msg); + + // Format message for display + let displayText = ''; + if (messageData && Object.keys(messageData).length > 0) { + try { + displayText = JSON.stringify(messageData, null, 2); + } catch (e) { + displayText = String(messageData); + } + } else { + displayText = '(empty message)'; + } + + const topicIndicator = topics.length > 1 ? ` - ${msg.topic || 'unknown'}` : ''; + + messageDiv.innerHTML = ` +
+
#${allMessages.length - index}${topicIndicator}
+
${timeStr}
+
+
${this.escapeHtml(displayText)}
+ `; + + messageList.appendChild(messageDiv); + + // Mark as rendered + this.renderedMessageIds.add(msg.id); + }); + + // Auto-scroll to top (newest messages) + messageList.scrollTop = 0; + } + + // Get displayable message data (exclude internal properties) + getDisplayableMessageData(msg) { + // Clone the message and exclude internal properties + const data = { ...msg }; + delete data.id; + delete data.timestamp; + delete data.topic; + + // If the message has a 'data' property, that's usually what we want to show + // Otherwise show all properties + if (data.data !== undefined) { + return this.unescapeJson(data.data); + } + + return this.unescapeJson(data); + } + + // Recursively unescape JSON strings + unescapeJson(obj, depth = 0) { + // Prevent infinite recursion + if (depth > 10) return obj; + + // If it's a string, try to parse as JSON + if (typeof obj === 'string') { + try { + const parsed = JSON.parse(obj); + // If parsing succeeded and it's an object/array, recursively unescape + if (typeof parsed === 'object' && parsed !== null) { + return this.unescapeJson(parsed, depth + 1); + } + return parsed; + } catch (e) { + // Not a valid JSON string, return as-is + return obj; + } + } + + // If it's an array, process each element + if (Array.isArray(obj)) { + return obj.map(item => this.unescapeJson(item, depth + 1)); + } + + // If it's an object, process each property + if (obj && typeof obj === 'object') { + const result = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + result[key] = this.unescapeJson(obj[key], depth + 1); + } + } + return result; + } + + // Not a string, array, or object - return as-is + return obj; + } + + // Escape HTML to prevent XSS + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Cleanup method + unmount() { + // Unsubscribe from messages if subscribed + if (this._messageUnsubscribe) { + this._messageUnsubscribe(); + this._messageUnsubscribe = null; + } + + // Call parent unmount + super.unmount(); + } } window.EventComponent = EventComponent; diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index 883eb16..cce47ef 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -1243,6 +1243,9 @@ class EventViewModel extends ViewModel { super(); this.setMultiple({ events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp } + allMessages: [], // Array of all messages for live viewing + allMessagesCounter: 0, // Counter to trigger updates + selectedTopic: null, // Currently selected topic for drawer isLoading: false, error: null, lastUpdateTime: null @@ -1265,6 +1268,26 @@ class EventViewModel extends ViewModel { const topic = data.topic || data.type; if (topic) { + // Store the full message + const allMessages = this.get('allMessages') || []; + const newMessage = { + ...data, + topic: topic, + timestamp: new Date().toISOString(), + id: `${topic}-${Date.now()}-${Math.random()}` + }; + allMessages.push(newMessage); + + // Keep only the last 1000 messages to prevent memory issues + const maxMessages = 1000; + if (allMessages.length > maxMessages) { + allMessages.splice(0, allMessages.length - maxMessages); + } + + // Update messages and trigger change notification + this.set('allMessages', allMessages); + this.set('allMessagesCounter', this.get('allMessagesCounter') + 1); + this.addTopic(topic, data); } }); @@ -1278,6 +1301,36 @@ class EventViewModel extends ViewModel { logger.debug('EventViewModel: WebSocket disconnected'); }); } + + // Get messages for a specific topic + getMessagesForTopic(topic) { + const allMessages = this.get('allMessages') || []; + return allMessages.filter(msg => { + // Handle nested events from cluster/event + if (msg.topic === 'cluster/event' && msg.data) { + try { + const parsedData = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data; + if (parsedData && parsedData.event) { + const fullTopic = `${msg.topic}/${parsedData.event}`; + return fullTopic === topic; + } + } catch (e) { + // If parsing fails, just use the original topic + } + } + return msg.topic === topic; + }); + } + + // Set the selected topic for drawer + setSelectedTopic(topic) { + this.set('selectedTopic', topic); + } + + // Clear selected topic + clearSelectedTopic() { + this.set('selectedTopic', null); + } // Add a topic (parsed by "/" separator) addTopic(topic, data = null) { diff --git a/public/styles/main.css b/public/styles/main.css index 155c07c..7b19777 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -3,6 +3,127 @@ transform: rotate(-90deg); transform-origin: 50% 50%; } + +/* ======================================== + MESSAGE LIST STYLES FOR EVENTS DRAWER + ======================================== */ + +.topic-messages-container { + height: 100%; + overflow-y: auto; + padding: 16px; +} + +.message-list-header { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); +} + +.message-list-header-title { + color: var(--text-primary); + font-weight: 600; + margin-bottom: 8px; +} + +.message-list-header-subtitle { + color: var(--text-secondary); + font-size: 0.9em; +} + +.topic-code { + background: var(--card-background); + padding: 2px 6px; + border-radius: 4px; +} + +.message-item { + margin-bottom: 12px; + padding: 12px; + background: var(--card-background, rgba(30, 30, 30, 0.5)); + border-left: 3px solid var(--primary-color, #3498db); + border-radius: 4px; +} + +.message-item-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.message-id { + color: var(--text-secondary); + font-size: 0.85em; +} + +.message-timestamp { + color: var(--text-secondary); + font-size: 0.85em; +} + +.message-content { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-family: monospace; + font-size: 0.9em; + color: var(--text-primary); +} + +.empty-message-state { + text-align: center; + padding: 32px; + color: var(--text-secondary); +} + +.topic-multi-indicator { + color: var(--text-secondary); +} + +/* Message animations - only for new messages */ +.message-item-new { + animation: messageSlideIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +@keyframes messageSlideIn { + 0% { + opacity: 0; + transform: translateY(-15px) scale(0.98); + max-height: 0; + margin-bottom: 0; + padding-top: 0; + padding-bottom: 0; + } + 50% { + opacity: 0.8; + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + max-height: 1000px; + margin-bottom: 12px; + padding-top: 12px; + padding-bottom: 12px; + } +} + +/* Node highlight animation - only for selected nodes */ +.node-highlight.highlighted { + animation: nodePulse 2s ease-in-out infinite; + filter: drop-shadow(0 0 3px rgba(255, 215, 0, 0.6)); +} + +@keyframes nodePulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.05); + } +} * { margin: 0; padding: 0;