Files
spore-ui/public/scripts/components/EventComponent.js
2025-10-27 20:44:57 +01:00

1129 lines
40 KiB
JavaScript

// 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 = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="5" r="1.5"/>
<circle cx="12" cy="12" r="1.5"/>
<circle cx="12" cy="19" r="1.5"/>
<circle cx="5" cy="12" r="1.5"/>
<circle cx="19" cy="12" r="1.5"/>
<path d="M12 7v3m0 2v3m-5-3h3m2 0h3"/>
</svg>
<span>Rearrange</span>
`;
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 = `<code class="topic-code">${topics[0]}</code>`;
} else {
topicDisplay = `
${topics.map(t => `<code class="topic-code" style="margin-right: 4px;">${t}</code>`).join('')}
<br><small class="topic-multi-indicator">(${topics.length} topics for node: ${nodeId})</small>
`;
}
header.innerHTML = `
<div class="message-list-header-title">
Topics: ${topicDisplay}
</div>
<div class="message-list-header-subtitle">
Showing live websocket messages for these topics
</div>
`;
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 = `
<div class="message-item-header">
<div class="message-id">#${allMessages.length - index}${topicIndicator}</div>
<div class="message-timestamp">${timeStr}</div>
</div>
<pre class="message-content">${this.escapeHtml(displayText)}</pre>
`;
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;