Compare commits
1 Commits
2b33da41b5
...
d1d5414a8c
| Author | SHA1 | Date | |
|---|---|---|---|
| d1d5414a8c |
@@ -23,8 +23,6 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b
|
|||||||

|

|
||||||
### Topology
|
### Topology
|
||||||

|

|
||||||
### Events
|
|
||||||

|
|
||||||
### Monitoring
|
### Monitoring
|
||||||

|

|
||||||
### Firmware
|
### Firmware
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 372 KiB |
518
docs/Events.md
518
docs/Events.md
@@ -1,518 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# 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`
|
|
||||||
|
|
||||||
@@ -41,13 +41,11 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/events" class="nav-tab" data-view="events">
|
<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;">
|
<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="6" cy="6" r="2" fill="currentColor"/>
|
||||||
<circle cx="18" cy="6" r="1.5" fill="currentColor"/>
|
<circle cx="18" cy="6" r="2" fill="currentColor"/>
|
||||||
<circle cx="18" cy="12" r="1.5" fill="currentColor"/>
|
<circle cx="12" cy="18" r="2" fill="currentColor"/>
|
||||||
<circle cx="18" cy="18" r="1.5" fill="currentColor"/>
|
<line x1="6" y1="6" x2="12" y2="18"/>
|
||||||
<line x1="6.5" y1="12" x2="16.5" y2="6"/>
|
<line x1="18" y1="6" x2="12" y2="18"/>
|
||||||
<line x1="6.5" y1="12" x2="16.5" y2="12"/>
|
|
||||||
<line x1="6.5" y1="12" x2="16.5" y2="18"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
Events
|
Events
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class EventComponent extends Component {
|
|||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
|
|
||||||
// Center node data - will be initialized with proper coordinates later
|
// Center node data - will be initialized with proper coordinates later
|
||||||
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
|
this.centerNode = { id: 'center', type: 'center', label: 'Events', x: 0, y: 0 };
|
||||||
|
|
||||||
// Track nodes for D3
|
// Track nodes for D3
|
||||||
this.graphNodes = [];
|
this.graphNodes = [];
|
||||||
@@ -64,19 +64,8 @@ class EventComponent extends Component {
|
|||||||
// Clear container
|
// Clear container
|
||||||
container.innerHTML = '';
|
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
|
// Create SVG with D3 - match topology view style
|
||||||
this.svg = d3.select(wrapper)
|
this.svg = d3.select(container)
|
||||||
.append('svg')
|
.append('svg')
|
||||||
.attr('width', '100%')
|
.attr('width', '100%')
|
||||||
.attr('height', '100%')
|
.attr('height', '100%')
|
||||||
@@ -183,41 +172,25 @@ class EventComponent extends Component {
|
|||||||
const centerX = this.width / 2;
|
const centerX = this.width / 2;
|
||||||
const centerY = this.height / 2;
|
const centerY = this.height / 2;
|
||||||
|
|
||||||
// Check if center node already exists in simulation (has been dragged)
|
const centerNodeWithPos = {
|
||||||
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,
|
...this.centerNode,
|
||||||
x: centerX,
|
x: centerX,
|
||||||
y: centerY,
|
y: centerY,
|
||||||
fx: null,
|
fx: centerX, // Fix x position
|
||||||
fy: null
|
fy: centerY // Fix y position
|
||||||
};
|
};
|
||||||
}
|
|
||||||
nodesMap.set('center', centerNodeWithPos);
|
nodesMap.set('center', centerNodeWithPos);
|
||||||
|
|
||||||
// Only process events if they exist
|
// Only process events if they exist
|
||||||
if (events && events.size > 0) {
|
if (events && events.size > 0) {
|
||||||
// First pass: create all unique topic part nodes with proper hierarchy
|
// First pass: create all unique topic part nodes
|
||||||
for (const [topic, data] of events) {
|
for (const [topic, data] of events) {
|
||||||
data.parts.forEach((part, index) => {
|
data.parts.forEach((part, index) => {
|
||||||
if (!nodesMap.has(part)) {
|
if (!nodesMap.has(part)) {
|
||||||
const existingNode = existingNodesMap.get(part);
|
const existingNode = existingNodesMap.get(part);
|
||||||
|
|
||||||
if (existingNode && existingNode.x && existingNode.y) {
|
if (existingNode && existingNode.x && existingNode.y) {
|
||||||
// Preserve existing position
|
// Preserve existing position, including fixed state for center node
|
||||||
nodesMap.set(part, {
|
nodesMap.set(part, {
|
||||||
id: part,
|
id: part,
|
||||||
type: 'topic',
|
type: 'topic',
|
||||||
@@ -229,36 +202,16 @@ class EventComponent extends Component {
|
|||||||
fy: existingNode.fy
|
fy: existingNode.fy
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Determine where to place this new node based on hierarchy
|
// Initialize new node with random position
|
||||||
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 angle = Math.random() * Math.PI * 2;
|
||||||
const distance = 60 + Math.random() * 30;
|
const distance = 150 + Math.random() * 100;
|
||||||
nodesMap.set(part, {
|
nodesMap.set(part, {
|
||||||
id: part,
|
id: part,
|
||||||
type: 'topic',
|
type: 'topic',
|
||||||
label: part,
|
label: part,
|
||||||
count: 0,
|
count: 0,
|
||||||
x: refPos.x + Math.cos(angle) * distance,
|
x: (this.width / 2) + Math.cos(angle) * distance,
|
||||||
y: refPos.y + Math.sin(angle) * distance,
|
y: (this.height / 2) + Math.sin(angle) * distance,
|
||||||
fx: null,
|
fx: null,
|
||||||
fy: null
|
fy: null
|
||||||
});
|
});
|
||||||
@@ -518,59 +471,22 @@ class EventComponent extends Component {
|
|||||||
|
|
||||||
drag() {
|
drag() {
|
||||||
const simulation = this.simulation;
|
const simulation = this.simulation;
|
||||||
const component = this;
|
|
||||||
|
|
||||||
let initialCenterX = 0;
|
|
||||||
let initialCenterY = 0;
|
|
||||||
|
|
||||||
function dragstarted(event, d) {
|
function dragstarted(event, d) {
|
||||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||||
d.fx = d.x;
|
d.fx = d.x;
|
||||||
d.fy = d.y;
|
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) {
|
function dragged(event, d) {
|
||||||
d.fx = event.x;
|
d.fx = event.x;
|
||||||
d.fy = event.y;
|
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) {
|
function dragended(event, d) {
|
||||||
if (!event.active) simulation.alphaTarget(0);
|
if (!event.active) simulation.alphaTarget(0);
|
||||||
// Keep positions fixed after dragging
|
|
||||||
d.fx = d.x;
|
d.fx = d.x;
|
||||||
d.fy = d.y;
|
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()
|
return d3.drag()
|
||||||
@@ -647,81 +563,6 @@ class EventComponent extends Component {
|
|||||||
return false;
|
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) {
|
animateEventMessage(topic, data) {
|
||||||
const g = this.svg.select('g');
|
const g = this.svg.select('g');
|
||||||
if (g.empty()) return;
|
if (g.empty()) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user