feat: live message viewer

This commit is contained in:
2025-10-27 20:44:57 +01:00
parent cbe13f1d84
commit 2a7d170824
5 changed files with 496 additions and 5 deletions

View File

@@ -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

BIN
assets/events-messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

View File

@@ -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 = `<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;

View File

@@ -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) {

View File

@@ -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;