Compare commits

..

7 Commits

16 changed files with 2236 additions and 36 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.git
.gitignore
.cursor
*.md
node_modules
README.md
docs
test
openapitools.json
*.backup

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Runtime stage
FROM node:20-alpine
# Install wget for health checks
RUN apk --no-cache add wget
WORKDIR /app
# Copy dependencies from builder
COPY --from=builder /app/node_modules ./node_modules
# Copy application files
COPY package*.json ./
COPY index.js index-standalone.js ./
COPY public ./public
COPY src ./src
# Create non-root user (let Alpine assign available GID/UID)
RUN addgroup spore && \
adduser -D -s /bin/sh -G spore spore && \
chown -R spore:spore /app
USER spore
# Expose port
EXPOSE 3000
# Run the application
CMD ["node", "index.js"]

54
Makefile Normal file
View File

@@ -0,0 +1,54 @@
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
# Install dependencies
install:
npm install
# Build the application (if needed)
build: install
# Run the application
run:
node index.js
# Start in development mode
dev:
node index.js
# Clean build artifacts
clean:
rm -rf node_modules
rm -f package-lock.json
# Docker variables
DOCKER_REGISTRY ?=
IMAGE_NAME = wirelos/spore-ui
IMAGE_TAG ?= latest
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
# Build Docker image
docker-build:
docker build -t $(FULL_IMAGE_NAME) .
# Run Docker container
docker-run:
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
# Push Docker image
docker-push:
docker push $(FULL_IMAGE_NAME)
# Build multiarch Docker image
docker-build-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.
# Push multiarch Docker image (if not pushed during build)
docker-push-multiarch:
docker buildx build --platform linux/amd64,linux/arm64 \
-t $(FULL_IMAGE_NAME) \
--push \
.

View File

@@ -23,6 +23,9 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b
![UI](./assets/cluster.png) ![UI](./assets/cluster.png)
### Topology ### Topology
![UI](./assets/topology.png) ![UI](./assets/topology.png)
### Events
![UI](./assets/events.png)
![UI](./assets/events-messages.png)
### Monitoring ### Monitoring
![UI](./assets/monitoring.png) ![UI](./assets/monitoring.png)
### Firmware ### Firmware

BIN
assets/events-messages.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

BIN
assets/events.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 343 KiB

518
docs/Events.md Normal file
View File

@@ -0,0 +1,518 @@
# Events Feature
## Overview
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
![Events View](./assets/events.png)
## Features
### Real-Time Event Visualization
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
### User Interactions
#### Drag to Reposition Nodes
- **Center Node**: Dragging the center (blue) node moves the entire graph together
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
#### Zoom Controls
- **Mouse Wheel**: Zoom in and out using the mouse wheel
- **Scale Range**: Zoom level constrained between 0.5x and 5x
- **Pan**: Click and drag the canvas to pan around the graph
#### Rearrange Layout
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
### Visual Elements
#### Node Types
- **Center Node** (Blue):
- Represents the central event hub
- Always visible in the graph
- Acts as the root node for all event chains
- Larger size (18px radius)
- **Topic Nodes** (Green):
- Represent individual topic segments
- Examples: `cluster`, `event`, `api`, `neopattern`
- Smaller size (14px radius)
- Positioned dynamically based on event frequency
#### Link Types
- **Center Links** (Blue):
- Connect the center node to root topic nodes
- Thicker lines (2.5px)
- Examples: center → `cluster`, center → `event`
- **Topic Links** (Green):
- Connect adjacent topics in event chains
- Line thickness proportional to event frequency
- Examples: `cluster``event`, `api``neopattern`
#### Hover Interactions
- **Link Hover**: Links become thicker and more opaque when hovered
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
## Architecture
### Component Structure
```12:37:spore-ui/public/scripts/components/EventComponent.js
// 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();
}
```
### ViewModel
The `EventViewModel` manages the state and WebSocket connections:
```1241:1253:spore-ui/public/scripts/view-models.js
// Events View Model for websocket event visualization
class EventViewModel extends ViewModel {
constructor() {
super();
this.setMultiple({
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
isLoading: false,
error: null,
lastUpdateTime: null
});
// Set up WebSocket listeners for real-time updates
this.setupWebSocketListeners();
}
```
### Event Tracking
Events are tracked with the following metadata:
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
- **Parts**: Array of topic segments split by `/`
- **Count**: Total occurrences of this event
- **First Seen**: ISO timestamp of first occurrence
- **Last Seen**: ISO timestamp of most recent occurrence
- **Last Data**: The most recent event payload
### Event Addition Logic
The view model handles nested events specially:
```1283:1329:spore-ui/public/scripts/view-models.js
// Add a topic (parsed by "/" separator)
addTopic(topic, data = null) {
// Get current events as a new Map to ensure change detection
const events = new Map(this.get('events'));
// Handle nested events from cluster/event
let fullTopic = topic;
if (topic === 'cluster/event' && data && data.data) {
try {
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
if (parsedData && parsedData.event) {
// Create nested topic chain: cluster/event/api/neopattern
fullTopic = `${topic}/${parsedData.event}`;
}
} catch (e) {
// If parsing fails, just use the original topic
}
}
const parts = fullTopic.split('/').filter(p => p);
if (events.has(fullTopic)) {
// Update existing event - create new object to ensure change detection
const existing = events.get(fullTopic);
events.set(fullTopic, {
topic: existing.topic,
parts: existing.parts,
count: existing.count + 1,
firstSeen: existing.firstSeen,
lastSeen: new Date().toISOString(),
lastData: data
});
} else {
// Create new event entry
events.set(fullTopic, {
topic: fullTopic,
parts: parts,
count: 1,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
lastData: data
});
}
// Use set to trigger change notification
this.set('events', events);
this.set('lastUpdateTime', new Date().toISOString());
}
```
## Graph Construction
### Node Creation
The graph construction process follows two passes:
1. **First Pass - Node Creation**:
- Creates nodes for each unique topic segment
- Preserves existing node positions if they've been dragged
- New nodes are positioned near their parent in the hierarchy
2. **Second Pass - Event Counting**:
- Counts occurrences of each topic segment
- Updates node counts to reflect event frequency
### Link Construction
Links are created as chains representing complete topic paths:
```283:342:spore-ui/public/scripts/components/EventComponent.js
// 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;
}
}
}
}
}
```
## Force Simulation
The D3.js force simulation provides the physics-based layout:
```103:111:spore-ui/public/scripts/components/EventComponent.js
// 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
```
### Force Parameters
- **Link Force**: Maintains 100px distance between connected nodes
- **Charge Force**: -300 strength creates repulsion between nodes
- **Center Force**: Keeps the graph centered in the viewport
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
- **Velocity Decay**: High decay (0.4) prevents excessive movement
## Animation System
### Event Arrival Animation
When a new event arrives, a golden animation dot travels along the event chain:
```725:809:spore-ui/public/scripts/components/EventComponent.js
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();
});
}
```
### Animation Characteristics
- **Golden Dot**: Visual indicator traveling along event chains
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
- **Duration**: 300ms per segment with 100ms delay between segments
- **Smooth Motion**: Linear easing for consistent speed
## WebSocket Integration
The Events view subscribes to all WebSocket messages through the gateway:
```1256:1280:spore-ui/public/scripts/view-models.js
// Set up WebSocket event listeners
setupWebSocketListeners() {
if (!window.wsClient) {
// Retry after a short delay to allow wsClient to initialize
setTimeout(() => this.setupWebSocketListeners(), 1000);
return;
}
// Listen for all websocket messages
window.wsClient.on('message', (data) => {
const topic = data.topic || data.type;
if (topic) {
this.addTopic(topic, data);
}
});
// Listen for connection status changes
window.wsClient.on('connected', () => {
logger.info('EventViewModel: WebSocket connected');
});
window.wsClient.on('disconnected', () => {
logger.debug('EventViewModel: WebSocket disconnected');
});
}
```
### Event Types Tracked
All WebSocket messages are captured and displayed, including:
- **cluster/update**: Node discovery and cluster membership changes
- **cluster/event**: Arbitrary events from cluster members
- **api/tasks**: Task execution notifications
- **api/config**: Configuration updates
- **node/***: Node-specific events
- Any custom topics sent by SPORE nodes
## Empty State
When no events have been received, the view displays a helpful message:
```604:632:spore-ui/public/scripts/components/EventComponent.js
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...');
}
```
## Best Practices
### Performance Considerations
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
3. **Animation**: Rapid event bursts may cause overlapping animations
### Usage Tips
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
4. **Interactive Exploration**: Hover over links to see event frequencies
## Troubleshooting
### No Events Showing
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
- **Browser Console**: Check for any JavaScript errors
### Graph Not Updating
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
### Performance Issues
- **Clear Events**: Reload the page to clear accumulated events
- **Reduce Events**: Limit the event rate in your SPORE nodes
- **Browser Settings**: Ensure hardware acceleration is enabled
## Technical Details
### Dependencies
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
- **SPORE Gateway**: WebSocket connection to cluster event stream
- **Component Framework**: Built on the SPORE UI component architecture
### Browser Compatibility
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
- **SVG Support**: Requires modern browser with full SVG support
- **WebSocket Support**: Native WebSocket API required
### View Structure
```254:262:spore-ui/public/index.html
<div id="events-view" class="view-content">
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
<div id="events-graph-container" style="flex: 1; min-height: 0;">
<div class="loading">
<div>Waiting for websocket events...</div>
</div>
</div>
</div>
</div>
```
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.

78
docs/README.md Normal file
View File

@@ -0,0 +1,78 @@
# SPORE UI Documentation
This directory contains detailed documentation for the SPORE UI frontend application.
## Documentation Index
### Core Documentation
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
### Feature Documentation
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
## Feature Overview
### Events Feature
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
**Key Capabilities:**
- Real-time event tracking and visualization
- Interactive force-directed graph layout
- Event counting and frequency visualization
- Animated event flow visualization
- Topic hierarchy display
See [Events.md](./Events.md) for complete documentation.
### Framework Architecture
The SPORE UI uses a component-based architecture with:
- View Models for state management
- Components for UI rendering
- Event Bus for pub/sub communication
- API Client for backend integration
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
### Discovery System
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
### Topology Visualization
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
## Quick Reference
### Most Popular Pages
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
2. [Events Feature](./Events.md) - Real-time event visualization
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
### Developer Resources
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
- Debugging: See [LOGGING.md](./LOGGING.md)
- Testing: See individual component documentation
## Contributing
When adding new features or documentation:
1. Follow the existing documentation structure
2. Include code examples and diagrams where helpful
3. Update this README when adding new documentation files
4. Maintain consistency with existing documentation style
## Related Documentation
For information about other SPORE components:
- **SPORE Gateway**: See `spore-gateway/README.md`
- **SPORE Registry**: See `spore-registry/README.md`
- **SPORE Embedded**: See `spore/README.md`

View File

@@ -70,14 +70,34 @@ udpServer.on('message', (msg, rinfo) => {
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`); console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
if (message.startsWith('CLUSTER_HEARTBEAT:')) { // Extract topic by splitting on first ":"
// Handle heartbeat messages that update member list const parts = message.split(':', 2);
const hostname = message.substring('CLUSTER_HEARTBEAT:'.length); if (parts.length < 2) {
updateNodeFromHeartbeat(sourceIp, sourcePort, hostname); console.log(`Invalid message format from ${sourceIp}:${sourcePort}: "${message}"`);
} else if (message.startsWith('NODE_UPDATE:')) { return;
// Handle node update messages that provide detailed node info }
handleNodeUpdate(sourceIp, message);
} else if (!message.startsWith('RAW:')) { const topic = parts[0] + ':';
const payload = parts[1];
// Handler map for different UDP message types
const handlers = {
'cluster/heartbeat:': (payload, sourceIp, sourcePort) => {
updateNodeFromHeartbeat(sourceIp, sourcePort, payload);
},
'node/update:': (payload, sourceIp) => {
handleNodeUpdate(sourceIp, 'node/update:' + payload);
},
'raw:': () => {
// Ignore raw messages
}
};
// Look up and execute handler
const handler = handlers[topic];
if (handler) {
handler(payload, sourceIp, sourcePort);
} else {
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`); console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
} }
} catch (error) { } catch (error) {
@@ -919,7 +939,7 @@ function broadcastClusterUpdate() {
// Get cluster members asynchronously // Get cluster members asynchronously
getCurrentClusterMembers().then(members => { getCurrentClusterMembers().then(members => {
const clusterData = { const clusterData = {
type: 'cluster_update', topic: 'cluster/update',
members: members, members: members,
primaryNode: primaryNodeIp, primaryNode: primaryNodeIp,
totalNodes: discoveredNodes.size, totalNodes: discoveredNodes.size,
@@ -946,7 +966,7 @@ function broadcastNodeDiscovery(nodeIp, action) {
if (wsClients.size === 0 || !wss) return; if (wsClients.size === 0 || !wss) return;
const eventData = { const eventData = {
type: 'node_discovery', topic: 'node/discovery',
action: action, // 'discovered' or 'stale' action: action, // 'discovered' or 'stale'
nodeIp: nodeIp, nodeIp: nodeIp,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
@@ -1122,7 +1142,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
initializeWebSocketServer(server); initializeWebSocketServer(server);
console.log('WebSocket server ready for real-time updates'); console.log('WebSocket server ready for real-time updates');
console.log('Waiting for CLUSTER_HEARTBEAT and NODE_UPDATE messages from SPORE nodes...'); console.log('Waiting for cluster/heartbeat and node/update messages from SPORE nodes...');
}); });
// Graceful shutdown handling // Graceful shutdown handling

View File

@@ -39,6 +39,18 @@
</svg> </svg>
Topology Topology
</a> </a>
<a href="/events" class="nav-tab" data-view="events">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<circle cx="5" cy="12" r="1.5" fill="currentColor"/>
<circle cx="18" cy="6" r="1.5" fill="currentColor"/>
<circle cx="18" cy="12" r="1.5" fill="currentColor"/>
<circle cx="18" cy="18" r="1.5" fill="currentColor"/>
<line x1="6.5" y1="12" x2="16.5" y2="6"/>
<line x1="6.5" y1="12" x2="16.5" y2="12"/>
<line x1="6.5" y1="12" x2="16.5" y2="18"/>
</svg>
Events
</a>
<a href="/monitoring" class="nav-tab" data-view="monitoring"> <a href="/monitoring" class="nav-tab" data-view="monitoring">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/> <path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
@@ -238,6 +250,16 @@
</div> </div>
</div> </div>
</div> </div>
<div id="events-view" class="view-content">
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
<div id="events-graph-container" style="flex: 1; min-height: 0;">
<div class="loading">
<div>Waiting for websocket events...</div>
</div>
</div>
</div>
</div>
</div> </div>
<script src="./vendor/d3.v7.min.js"></script> <script src="./vendor/d3.v7.min.js"></script>
@@ -264,6 +286,7 @@
<script src="./scripts/components/ClusterStatusComponent.js"></script> <script src="./scripts/components/ClusterStatusComponent.js"></script>
<script src="./scripts/components/TopologyGraphComponent.js"></script> <script src="./scripts/components/TopologyGraphComponent.js"></script>
<script src="./scripts/components/MonitoringViewComponent.js"></script> <script src="./scripts/components/MonitoringViewComponent.js"></script>
<script src="./scripts/components/EventComponent.js"></script>
<script src="./scripts/components/ComponentsLoader.js"></script> <script src="./scripts/components/ComponentsLoader.js"></script>
<script src="./scripts/theme-manager.js"></script> <script src="./scripts/theme-manager.js"></script>
<script src="./scripts/app.js"></script> <script src="./scripts/app.js"></script>

View File

@@ -263,7 +263,8 @@ class WebSocketClient {
try { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
logger.debug('WebSocket message received:', data); logger.debug('WebSocket message received:', data);
logger.debug('WebSocket message type:', data.type); const messageTopic = data.topic || data.type;
logger.debug('WebSocket message topic:', messageTopic);
this.emit('message', data); this.emit('message', data);
this.handleMessage(data); this.handleMessage(data);
} catch (error) { } catch (error) {
@@ -288,21 +289,21 @@ class WebSocketClient {
} }
handleMessage(data) { handleMessage(data) {
switch (data.type) { const messageTopic = data.topic || data.type;
case 'cluster_update':
this.emit('clusterUpdate', data); // Handler map for different WebSocket message types
break; const handlers = {
case 'node_discovery': 'cluster/update': (data) => this.emit('clusterUpdate', data),
this.emit('nodeDiscovery', data); 'node/discovery': (data) => this.emit('nodeDiscovery', data),
break; 'firmware/upload/status': (data) => this.emit('firmwareUploadStatus', data),
case 'firmware_upload_status': 'rollout/progress': (data) => this.emit('rolloutProgress', data)
this.emit('firmwareUploadStatus', data); };
break;
case 'rollout_progress': const handler = handlers[messageTopic];
this.emit('rolloutProgress', data); if (handler) {
break; handler(data);
default: } else {
logger.debug('Unknown WebSocket message type:', data.type); logger.debug('Unknown WebSocket message topic:', messageTopic);
} }
} }

View File

@@ -17,7 +17,8 @@ document.addEventListener('DOMContentLoaded', async function() {
const clusterFirmwareViewModel = new ClusterFirmwareViewModel(); const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
const topologyViewModel = new TopologyViewModel(); const topologyViewModel = new TopologyViewModel();
const monitoringViewModel = new MonitoringViewModel(); const monitoringViewModel = new MonitoringViewModel();
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel }); const eventsViewModel = new EventViewModel();
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel, eventsViewModel });
// Connect firmware view model to cluster data // Connect firmware view model to cluster data
clusterViewModel.subscribe('members', (members) => { clusterViewModel.subscribe('members', (members) => {
@@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', async function() {
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel); app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel); app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
app.registerRoute('events', EventComponent, 'events-view', eventsViewModel);
logger.debug('App: Routes registered and components pre-initialized'); logger.debug('App: Routes registered and components pre-initialized');
// Initialize cluster status component for header badge // Initialize cluster status component for header badge

File diff suppressed because it is too large Load Diff

View File

@@ -1235,4 +1235,200 @@ class WiFiConfigViewModel extends ViewModel {
} }
} }
window.WiFiConfigViewModel = WiFiConfigViewModel; window.WiFiConfigViewModel = WiFiConfigViewModel;
// Events View Model for websocket event visualization
class EventViewModel extends ViewModel {
constructor() {
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
});
// Set up WebSocket listeners for real-time updates
this.setupWebSocketListeners();
}
// Set up WebSocket event listeners
setupWebSocketListeners() {
if (!window.wsClient) {
// Retry after a short delay to allow wsClient to initialize
setTimeout(() => this.setupWebSocketListeners(), 1000);
return;
}
// Listen for all websocket messages
window.wsClient.on('message', (data) => {
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);
}
});
// Listen for connection status changes
window.wsClient.on('connected', () => {
logger.info('EventViewModel: WebSocket connected');
});
window.wsClient.on('disconnected', () => {
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) {
// Get current events as a new Map to ensure change detection
const events = new Map(this.get('events'));
// Handle nested events from cluster/event
let fullTopic = topic;
if (topic === 'cluster/event' && data && data.data) {
try {
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
if (parsedData && parsedData.event) {
// Create nested topic chain: cluster/event/api/neopattern
fullTopic = `${topic}/${parsedData.event}`;
}
} catch (e) {
// If parsing fails, just use the original topic
}
}
const parts = fullTopic.split('/').filter(p => p);
if (events.has(fullTopic)) {
// Update existing event - create new object to ensure change detection
const existing = events.get(fullTopic);
events.set(fullTopic, {
topic: existing.topic,
parts: existing.parts,
count: existing.count + 1,
firstSeen: existing.firstSeen,
lastSeen: new Date().toISOString(),
lastData: data
});
} else {
// Create new event entry
events.set(fullTopic, {
topic: fullTopic,
parts: parts,
count: 1,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
lastData: data
});
}
// Use set to trigger change notification
this.set('events', events);
this.set('lastUpdateTime', new Date().toISOString());
}
// Get all topic parts (unique segments after splitting by "/")
getTopicParts() {
const events = this.get('events');
const allParts = new Set();
for (const [topic, data] of events) {
data.parts.forEach(part => allParts.add(part));
}
return Array.from(allParts).map(part => {
// Count how many events contain this part
let count = 0;
for (const [topic, data] of events) {
if (data.parts.includes(part)) {
count += data.count;
}
}
return { part, count };
});
}
// Clear all events
clearTopics() {
this.set('events', new Map());
this.set('lastUpdateTime', new Date().toISOString());
}
// Get connections between topic parts (for graph edges)
getTopicConnections() {
const topics = this.get('topics');
const connections = [];
for (const [topic, data] of topics) {
const parts = data.parts;
// Create connections between adjacent parts
for (let i = 0; i < parts.length - 1; i++) {
connections.push({
source: parts[i],
target: parts[i + 1],
topic: topic,
count: data.count
});
}
}
return connections;
}
}
window.EventViewModel = EventViewModel;

View File

@@ -3,6 +3,127 @@
transform: rotate(-90deg); transform: rotate(-90deg);
transform-origin: 50% 50%; 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; margin: 0;
padding: 0; padding: 0;
@@ -559,11 +680,12 @@ p {
} }
/* Topology graph node interactions */ /* Topology graph node interactions */
#topology-graph-container .node { #topology-graph-container .node, #events-graph-container .node {
cursor: pointer; cursor: pointer;
} }
#topology-graph-container .node:hover circle:first-child { #topology-graph-container .node:hover circle:first-child,
#events-graph-container .node:hover circle:first-child {
filter: brightness(1.2); filter: brightness(1.2);
} }
@@ -4335,7 +4457,7 @@ select.param-input:focus {
width: 100%; /* Use full container width */ width: 100%; /* Use full container width */
} }
#topology-graph-container { #topology-graph-container, #events-graph-container {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-radius: 12px; border-radius: 12px;
height: 100%; height: 100%;
@@ -4351,24 +4473,27 @@ select.param-input:focus {
max-height: 100%; /* Ensure it doesn't exceed parent height */ max-height: 100%; /* Ensure it doesn't exceed parent height */
} }
#topology-graph-container svg { #topology-graph-container svg, #events-graph-container svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
#topology-graph-container .loading, #topology-graph-container .loading,
#topology-graph-container .error, #topology-graph-container .error,
#topology-graph-container .no-data { #topology-graph-container .no-data,
#events-graph-container .loading,
#events-graph-container .error,
#events-graph-container .no-data {
text-align: center; text-align: center;
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 1.1rem; font-size: 1.1rem;
} }
#topology-graph-container .error { #topology-graph-container .error, #events-graph-container .error {
color: var(--accent-error); color: var(--accent-error);
} }
#topology-graph-container .no-data { #topology-graph-container .no-data, #events-graph-container .no-data {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }