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

### Events

+
### Monitoring

### 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 = `
+
+
+ `;
+ 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 = `
+
+ ${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;