Files
spore-ui/docs/Events.md
2025-10-26 18:58:53 +01:00

19 KiB

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

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
  • 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: clusterevent, apineopattern

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

// 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:

// 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:

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

Links are created as chains representing complete topic paths:

        // 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:

        // 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:

    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:

    // 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:

    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

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