// Events Component - Visualizes websocket events as a graph class EventComponent extends Component { constructor(container, viewModel, eventBus) { super(container, viewModel, eventBus); this.svg = null; this.simulation = null; this.zoom = null; this.width = 0; this.height = 0; this.isInitialized = false; // Center node data - will be initialized with proper coordinates later this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 }; // Track nodes for D3 this.graphNodes = []; this.graphLinks = []; // 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() { const container = this.findElement('#events-graph-container'); if (!container) { logger.error('EventComponent: Container not found!'); return; } const rect = container.getBoundingClientRect(); this.width = rect.width || 1400; this.height = rect.height || 800; this.width = Math.max(this.width, 800); this.height = Math.max(this.height, 600); } mount() { if (this.isMounted) return; if (!this.isInitialized) { this.initialize(); } this.isMounted = true; this.setupEventListeners(); this.setupViewModelListeners(); this.render(); } initialize() { if (this.isInitialized) return; // Create SVG const container = this.findElement('#events-graph-container'); if (!container) { logger.error('EventComponent: Container not found'); return; } this.updateDimensions(); // Clear container container.innerHTML = ''; // Create a wrapper div for relative positioning const wrapper = document.createElement('div'); wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;'; container.appendChild(wrapper); // Store wrapper for button placement this.wrapper = wrapper; // Add rearrange button this.createRearrangeButton(wrapper); // Create SVG with D3 - match topology view style this.svg = d3.select(wrapper) .append('svg') .attr('width', '100%') .attr('height', '100%') .attr('viewBox', `0 0 ${this.width} ${this.height}`) .style('border', '1px solid rgba(255, 255, 255, 0.1)') .style('border-radius', '12px'); // Create zoom behavior this.zoom = d3.zoom() .scaleExtent([0.5, 5]) .on('zoom', (event) => { this.svg.select('g').attr('transform', event.transform); }); this.svg.call(this.zoom); // Create main group for graph elements const mainGroup = this.svg.append('g'); // Apply initial zoom to show the graph more zoomed in mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); // Initialize simulation this.simulation = d3.forceSimulation() .force('link', d3.forceLink().id(d => d.id).distance(100)) .force('charge', d3.forceManyBody().strength(-300)) .force('center', d3.forceCenter(this.width / 2, this.height / 2)) .force('collision', d3.forceCollide().radius(35)) .alphaDecay(0.0228) // Slower decay to allow simulation to run longer .velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement this.isInitialized = true; } setupEventListeners() { // Handle window resize this.addEventListener(window, 'resize', () => { if (this.resizeTimeout) clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { this.handleResize(); }, 250); }); } handleResize() { const container = this.findElement('#events-graph-container'); if (container && this.svg) { this.updateDimensions(); this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); if (this.simulation) { this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); this.simulation.alpha(0.3).restart(); } } } setupViewModelListeners() { // Listen for events changes this.subscribeToProperty('events', (newValue, previousValue) => { // Check for new or updated events to animate if (newValue && newValue.size > 0 && previousValue) { for (const [topic, data] of newValue) { const prevData = previousValue.get(topic); if (!prevData || data.count > prevData.count) { // New event or count increased - trigger animation this.animateEventMessage(topic, data); } } } // Remove empty state if it exists if (this.svg) { const g = this.svg.select('g'); if (!g.empty()) { g.selectAll('.empty-state').remove(); } } this.updateGraph(); }); // Listen for lastUpdateTime changes this.subscribeToProperty('lastUpdateTime', () => { this.updateGraph(); }); } updateGraph() { if (!this.isInitialized || !this.svg) { return; } const events = this.viewModel.get('events'); // Get existing nodes from simulation to preserve positions const existingNodesMap = new Map(); if (this.simulation && this.simulation.nodes) { this.simulation.nodes().forEach(node => { existingNodesMap.set(node.id, node); }); } // Create nodes map with center node (always present) const nodesMap = new Map(); const centerX = this.width / 2; const centerY = this.height / 2; // Check if center node already exists in simulation (has been dragged) const existingCenter = existingNodesMap.get('center'); let centerNodeWithPos; if (existingCenter && existingCenter.fx !== undefined && existingCenter.fy !== undefined) { // Preserve the dragged position centerNodeWithPos = { ...this.centerNode, x: existingCenter.fx !== null ? existingCenter.fx : existingCenter.x, y: existingCenter.fy !== null ? existingCenter.fy : existingCenter.y, fx: existingCenter.fx, fy: existingCenter.fy }; } else { // New center node at center, or if it was never dragged centerNodeWithPos = { ...this.centerNode, x: centerX, y: centerY, fx: null, fy: null }; } nodesMap.set('center', centerNodeWithPos); // Only process events if they exist if (events && events.size > 0) { // First pass: create all unique topic part nodes with proper hierarchy for (const [topic, data] of events) { data.parts.forEach((part, index) => { if (!nodesMap.has(part)) { const existingNode = existingNodesMap.get(part); if (existingNode && existingNode.x && existingNode.y) { // Preserve existing position nodesMap.set(part, { id: part, type: 'topic', label: part, count: 0, x: existingNode.x, y: existingNode.y, fx: existingNode.fx, fy: existingNode.fy }); } else { // Determine where to place this new node based on hierarchy let parentPart; let refPos; if (index === 0) { // First part connects to center parentPart = 'center'; } else { // Subsequent parts connect to previous part in this topic parentPart = data.parts[index - 1]; } // Get parent position (should exist since we process in order) refPos = nodesMap.get(parentPart); // Fallback to center if parent doesn't exist yet if (!refPos) { refPos = nodesMap.get('center'); } // Initialize new node very close to its parent const angle = Math.random() * Math.PI * 2; const distance = 60 + Math.random() * 30; nodesMap.set(part, { id: part, type: 'topic', label: part, count: 0, x: refPos.x + Math.cos(angle) * distance, y: refPos.y + Math.sin(angle) * distance, fx: null, fy: null }); } } }); } // Second pass: count occurrences of each part for (const [topic, data] of events) { data.parts.forEach(part => { const node = nodesMap.get(part); if (node) { node.count += data.count; } }); } } this.graphNodes = Array.from(nodesMap.values()); // Build links as chains for each topic // For "cluster/update", create: center -> cluster -> update this.graphLinks = []; const linkSet = new Set(); // Track links to avoid duplicates if (events && events.size > 0) { for (const [topic, data] of events) { const parts = data.parts; if (parts.length === 0) continue; // Connect center to first part const firstPart = parts[0]; const centerToFirst = `center-${firstPart}`; if (!linkSet.has(centerToFirst)) { this.graphLinks.push({ source: 'center', target: firstPart, type: 'center-link', count: data.count, topic: topic }); linkSet.add(centerToFirst); } else { // Update count for existing link const existingLink = this.graphLinks.find(l => `${l.source}-${l.target}` === centerToFirst ); if (existingLink) { existingLink.count += data.count; } } // Connect each part to the next (creating a chain) for (let i = 0; i < parts.length - 1; i++) { const source = parts[i]; const target = parts[i + 1]; const linkKey = `${source}-${target}`; if (!linkSet.has(linkKey)) { this.graphLinks.push({ source: source, target: target, type: 'topic-link', topic: topic, count: data.count }); linkSet.add(linkKey); } else { // Update count for existing link const existingLink = this.graphLinks.find(l => `${l.source}-${l.target}` === linkKey ); if (existingLink) { existingLink.count += data.count; } } } } } // Update D3 simulation this.updateSimulation(); } updateSimulation() { const g = this.svg.select('g'); if (!g || g.empty()) { return; } // Update links const links = g.selectAll('.link') .data(this.graphLinks, d => `${d.source}-${d.target}`); // Remove old links links.exit() .transition() .duration(300) .style('opacity', 0) .remove(); // Add new links const linksEnter = links.enter() .append('line') .attr('class', d => `link ${d.type}`) .attr('stroke-opacity', 0); // Merge and create linksUpdate const linksUpdate = linksEnter.merge(links); // Add hover interactions to all links (existing ones will be updated) linksUpdate .on('mouseover', function(event, d) { d3.select(event.currentTarget) .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(3, Math.min(5, Math.sqrt(d.count) + 2)) : 3.5) .attr('stroke-opacity', 0.9); }) .on('mouseout', function(event, d) { d3.select(event.currentTarget) .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)) : 2.5) .attr('stroke-opacity', 0.7); }); // Update link attributes directly (no transitions to avoid event conflicts) linksUpdate .attr('stroke', d => { if (d.type === 'center-link') return '#3498db'; return '#10b981'; }) .attr('stroke-opacity', 0.7) .attr('stroke-width', d => { if (d.type === 'topic-link') return Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)); return 2.5; }); // Update nodes const nodes = g.selectAll('.node') .data(this.graphNodes, d => d.id); // Remove old nodes nodes.exit() .transition() .duration(300) .style('opacity', 0) .remove(); // Add new nodes const nodesEnter = nodes.enter() .append('g') .attr('class', d => `node ${d.type}`) .style('opacity', 0) .call(this.drag()); // 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) .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') .attr('dy', d => d.type === 'center' ? -28 : -20) .attr('text-anchor', 'middle') .attr('font-size', d => d.type === 'center' ? '16px' : '13px') .attr('fill', 'var(--text-primary)') .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 (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); // Remove any existing count badges nodesUpdate.selectAll('.count-badge').remove(); // Update simulation this.simulation.nodes(this.graphNodes); // Only add links force if there are links if (this.graphLinks.length > 0) { const linkForce = this.simulation.force('link'); if (linkForce) { linkForce.links(this.graphLinks); } else { // Create the link force if it doesn't exist this.simulation.force('link', d3.forceLink().id(d => d.id).distance(100)); this.simulation.force('link').links(this.graphLinks); } } else { // Remove link force if no links this.simulation.force('link', null); } // Update positions on tick this.simulation.on('tick', () => { if (linksUpdate && this.graphLinks.length > 0) { linksUpdate.each(function(d) { const link = d3.select(this); // Calculate direction from source to target const dx = d.target.x - d.source.x; const dy = d.target.y - d.source.y; const length = Math.sqrt(dx * dx + dy * dy); if (length > 0) { // Normalize direction vector const nx = dx / length; const ny = dy / length; // Edge radius based on target node type const targetRadius = d.target.type === 'center' ? 18 : 14; const sourceRadius = d.source.type === 'center' ? 18 : 14; // Start from edge of source node const x1 = d.source.x + nx * sourceRadius; const y1 = d.source.y + ny * sourceRadius; // End at edge of target node const x2 = d.target.x - nx * targetRadius; const y2 = d.target.y - ny * targetRadius; link.attr('x1', x1) .attr('y1', y1) .attr('x2', x2) .attr('y2', y2); } }); } nodesUpdate .attr('transform', d => `translate(${d.x},${d.y})`); }); // Use lower alpha to avoid jumping - just tweak positions gently this.simulation.alpha(0.3).restart(); } drag() { const simulation = this.simulation; const component = this; let initialCenterX = 0; let initialCenterY = 0; function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; // If dragging the center node, store initial position and get all nodes if (d.type === 'center') { initialCenterX = d.x; initialCenterY = d.y; } } function dragged(event, d) { d.fx = event.x; d.fy = event.y; // If dragging the center node, move all other nodes by the same offset if (d.type === 'center') { const dx = event.x - initialCenterX; const dy = event.y - initialCenterY; // Apply the same offset to all other nodes simulation.nodes().forEach(node => { if (node.id !== 'center') { node.fx = node.x + dx; node.fy = node.y + dy; } }); // Update initial position for next iteration initialCenterX = event.x; initialCenterY = event.y; } } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); // Keep positions fixed after dragging d.fx = d.x; d.fy = d.y; // If dragging the center node, preserve all node positions if (d.type === 'center') { simulation.nodes().forEach(node => { node.fx = node.x; node.fy = node.y; }); } } return d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended); } render() { if (!this.isInitialized) { this.initialize(); } const events = this.viewModel.get('events'); // If no events exist yet, show an empty state message if (!events || events.size === 0) { this.showEmptyState(); } else { // Remove empty state if it exists const g = this.svg.select('g'); if (!g.empty()) { g.selectAll('.empty-state').remove(); } } // Always call updateGraph to show at least the center node this.updateGraph(); } showEmptyState() { if (!this.isInitialized || !this.svg) return; const g = this.svg.select('g'); if (g.empty()) return; // Remove any existing message g.selectAll('.empty-state').remove(); // Account for the initial zoom transform (scale(1.4) translate(-200, -150)) // We need to place the text in the center of the transformed coordinate space const transformX = -200; const transformY = -150; const scale = 1.4; // Calculate centered position in transformed space const x = ((this.width / 2) - transformX) / scale; const y = ((this.height / 2) - transformY) / scale; const emptyMsg = g.append('text') .attr('class', 'empty-state') .attr('x', x) .attr('y', y) .attr('text-anchor', 'middle') .attr('font-size', '18px') .attr('fill', 'var(--text-secondary)') .attr('font-weight', '500') .text('Waiting for websocket events...'); } onPause() { if (this.simulation) { this.simulation.stop(); } } onResume() { if (this.simulation) { this.simulation.alpha(0.3).restart(); } } shouldRenderOnResume() { return false; } createRearrangeButton(container) { const button = document.createElement('button'); button.className = 'topology-rearrange-btn'; button.innerHTML = ` Rearrange `; button.title = 'Rearrange nodes into a clean layout'; button.style.cssText = ` position: absolute; left: 16px; top: 16px; z-index: 1000; display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: var(--card-background, rgba(30, 30, 30, 0.95)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); border-radius: 8px; color: var(--text-primary, #ecf0f1); font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); `; // Add hover effect button.addEventListener('mouseenter', () => { button.style.background = 'var(--card-hover, rgba(40, 40, 40, 0.95))'; button.style.borderColor = 'var(--primary-color, #3498db)'; button.style.transform = 'translateY(-1px)'; button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; }); button.addEventListener('mouseleave', () => { button.style.background = 'var(--card-background, rgba(30, 30, 30, 0.95))'; button.style.borderColor = 'var(--border-color, rgba(255, 255, 255, 0.1))'; button.style.transform = 'translateY(0)'; button.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)'; }); // Add click handler button.addEventListener('click', () => { this.rearrangeNodes(); }); container.appendChild(button); } rearrangeNodes() { if (!this.simulation) { return; } // Clear fixed positions for all nodes except center this.graphNodes.forEach(node => { if (node.type !== 'center') { node.fx = null; node.fy = null; } }); // Restart simulation with higher energy this.simulation.alpha(1).restart(); } animateEventMessage(topic, data) { const g = this.svg.select('g'); if (g.empty()) return; const parts = data.parts || topic.split('/').filter(p => p); if (parts.length === 0) return; // Wait for the next tick to ensure nodes exist setTimeout(() => { // Build the chain: center -> first -> second -> ... -> last const chain = ['center', ...parts]; // Animate along each segment of the chain let delay = 0; for (let i = 0; i < chain.length - 1; i++) { const sourceId = chain[i]; const targetId = chain[i + 1]; const sourceNode = this.graphNodes.find(n => n.id === sourceId); const targetNode = this.graphNodes.find(n => n.id === targetId); if (!sourceNode || !targetNode) continue; setTimeout(() => { this.animateDotAlongLink(sourceNode, targetNode, g); }, delay); // Add delay between segments (staggered animation) delay += 400; // Duration (300ms) + small gap (100ms) } }, 100); } animateDotAlongLink(sourceNode, targetNode, g) { if (!sourceNode || !targetNode || !g) return; // Calculate positions const dx = targetNode.x - sourceNode.x; const dy = targetNode.y - sourceNode.y; const length = Math.sqrt(dx * dx + dy * dy); if (length === 0) return; // Normalize direction const nx = dx / length; const ny = dy / length; // Calculate node radii const sourceRadius = sourceNode.type === 'center' ? 18 : 14; const targetRadius = targetNode.type === 'center' ? 18 : 14; // Start from edge of source node const startX = sourceNode.x + nx * sourceRadius; const startY = sourceNode.y + ny * sourceRadius; // End at edge of target node const endX = targetNode.x - nx * targetRadius; const endY = targetNode.y - ny * targetRadius; // Create animation dot const animationGroup = g.append('g').attr('class', 'animation-group'); const dot = animationGroup.append('circle') .attr('r', 4) .attr('fill', '#FFD700') .attr('stroke', '#fff') .attr('stroke-width', 2) .attr('cx', startX) .attr('cy', startY); // Animate the dot along the path dot.transition() .duration(300) .ease(d3.easeLinear) .attr('cx', endX) .attr('cy', endY) .on('end', function() { // Fade out after reaching destination dot.transition() .duration(100) .style('opacity', 0) .remove(); 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;