diff --git a/README.md b/README.md index ca9bd6f..9a58615 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b ![UI](./assets/cluster.png) ### Topology ![UI](./assets/topology.png) +### Events +![UI](./assets/events.png) ### Monitoring ![UI](./assets/monitoring.png) ### Firmware diff --git a/assets/events.png b/assets/events.png new file mode 100644 index 0000000..98f93e0 Binary files /dev/null and b/assets/events.png differ diff --git a/assets/firmware.png b/assets/firmware.png index dfca4be..92d7a3b 100644 Binary files a/assets/firmware.png and b/assets/firmware.png differ diff --git a/docs/Events.md b/docs/Events.md new file mode 100644 index 0000000..815ae8a --- /dev/null +++ b/docs/Events.md @@ -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 +
+
+
+
+
Waiting for websocket events...
+
+
+
+
+``` + +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. + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d94e548 --- /dev/null +++ b/docs/README.md @@ -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` + diff --git a/public/index.html b/public/index.html index 15c0a96..2f0b9cd 100644 --- a/public/index.html +++ b/public/index.html @@ -39,6 +39,18 @@ Topology + + + + + + + + + + + Events + @@ -238,6 +250,16 @@ + +
+
+
+
+
Waiting for websocket events...
+
+
+
+
@@ -264,6 +286,7 @@ + diff --git a/public/scripts/app.js b/public/scripts/app.js index 88fbbfe..b97e577 100644 --- a/public/scripts/app.js +++ b/public/scripts/app.js @@ -17,7 +17,8 @@ document.addEventListener('DOMContentLoaded', async function() { const clusterFirmwareViewModel = new ClusterFirmwareViewModel(); const topologyViewModel = new TopologyViewModel(); 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 clusterViewModel.subscribe('members', (members) => { @@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', async function() { app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel); app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel); app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel); + app.registerRoute('events', EventComponent, 'events-view', eventsViewModel); logger.debug('App: Routes registered and components pre-initialized'); // Initialize cluster status component for header badge diff --git a/public/scripts/components/EventComponent.js b/public/scripts/components/EventComponent.js new file mode 100644 index 0000000..51901a0 --- /dev/null +++ b/public/scripts/components/EventComponent.js @@ -0,0 +1,812 @@ +// 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(); + } + + updateDimensions() { + const container = this.findElement('#events-graph-container'); + if (!container) { + logger.error('EventComponent: Container not found!'); + return; + } + + const rect = container.getBoundingClientRect(); + this.width = rect.width || 1400; + this.height = rect.height || 800; + + this.width = Math.max(this.width, 800); + this.height = Math.max(this.height, 600); + } + + mount() { + if (this.isMounted) return; + + if (!this.isInitialized) { + this.initialize(); + } + + this.isMounted = true; + this.setupEventListeners(); + this.setupViewModelListeners(); + this.render(); + } + + initialize() { + if (this.isInitialized) return; + + // Create SVG + const container = this.findElement('#events-graph-container'); + if (!container) { + logger.error('EventComponent: Container not found'); + return; + } + + this.updateDimensions(); + + // Clear container + container.innerHTML = ''; + + // Create a wrapper div for relative positioning + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'position: relative; width: 100%; height: 100%; overflow: hidden;'; + container.appendChild(wrapper); + + // Store wrapper for button placement + this.wrapper = wrapper; + + // Add rearrange button + this.createRearrangeButton(wrapper); + + // Create SVG with D3 - match topology view style + this.svg = d3.select(wrapper) + .append('svg') + .attr('width', '100%') + .attr('height', '100%') + .attr('viewBox', `0 0 ${this.width} ${this.height}`) + .style('border', '1px solid rgba(255, 255, 255, 0.1)') + .style('border-radius', '12px'); + + // Create zoom behavior + this.zoom = d3.zoom() + .scaleExtent([0.5, 5]) + .on('zoom', (event) => { + this.svg.select('g').attr('transform', event.transform); + }); + + this.svg.call(this.zoom); + + // Create main group for graph elements + const mainGroup = this.svg.append('g'); + + // Apply initial zoom to show the graph more zoomed in + mainGroup.attr('transform', 'scale(1.4) translate(-200, -150)'); + + // Initialize simulation + this.simulation = d3.forceSimulation() + .force('link', d3.forceLink().id(d => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(this.width / 2, this.height / 2)) + .force('collision', d3.forceCollide().radius(35)) + .alphaDecay(0.0228) // Slower decay to allow simulation to run longer + .velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement + + this.isInitialized = true; + } + + setupEventListeners() { + // Handle window resize + this.addEventListener(window, 'resize', () => { + if (this.resizeTimeout) clearTimeout(this.resizeTimeout); + this.resizeTimeout = setTimeout(() => { + this.handleResize(); + }, 250); + }); + } + + handleResize() { + const container = this.findElement('#events-graph-container'); + if (container && this.svg) { + this.updateDimensions(); + this.svg.attr('viewBox', `0 0 ${this.width} ${this.height}`); + if (this.simulation) { + this.simulation.force('center', d3.forceCenter(this.width / 2, this.height / 2)); + this.simulation.alpha(0.3).restart(); + } + } + } + + setupViewModelListeners() { + // Listen for events changes + this.subscribeToProperty('events', (newValue, previousValue) => { + // Check for new or updated events to animate + if (newValue && newValue.size > 0 && previousValue) { + for (const [topic, data] of newValue) { + const prevData = previousValue.get(topic); + if (!prevData || data.count > prevData.count) { + // New event or count increased - trigger animation + this.animateEventMessage(topic, data); + } + } + } + + // Remove empty state if it exists + if (this.svg) { + const g = this.svg.select('g'); + if (!g.empty()) { + g.selectAll('.empty-state').remove(); + } + } + this.updateGraph(); + }); + + // Listen for lastUpdateTime changes + this.subscribeToProperty('lastUpdateTime', () => { + this.updateGraph(); + }); + } + + updateGraph() { + if (!this.isInitialized || !this.svg) { + return; + } + + const events = this.viewModel.get('events'); + + // Get existing nodes from simulation to preserve positions + const existingNodesMap = new Map(); + if (this.simulation && this.simulation.nodes) { + this.simulation.nodes().forEach(node => { + existingNodesMap.set(node.id, node); + }); + } + + // Create nodes map with center node (always present) + const nodesMap = new Map(); + const centerX = this.width / 2; + const centerY = this.height / 2; + + // Check if center node already exists in simulation (has been dragged) + const existingCenter = existingNodesMap.get('center'); + let centerNodeWithPos; + + if (existingCenter && existingCenter.fx !== undefined && existingCenter.fy !== undefined) { + // Preserve the dragged position + centerNodeWithPos = { + ...this.centerNode, + x: existingCenter.fx !== null ? existingCenter.fx : existingCenter.x, + y: existingCenter.fy !== null ? existingCenter.fy : existingCenter.y, + fx: existingCenter.fx, + fy: existingCenter.fy + }; + } else { + // New center node at center, or if it was never dragged + centerNodeWithPos = { + ...this.centerNode, + x: centerX, + y: centerY, + fx: null, + fy: null + }; + } + nodesMap.set('center', centerNodeWithPos); + + // Only process events if they exist + if (events && events.size > 0) { + // First pass: create all unique topic part nodes with proper hierarchy + for (const [topic, data] of events) { + data.parts.forEach((part, index) => { + if (!nodesMap.has(part)) { + const existingNode = existingNodesMap.get(part); + + if (existingNode && existingNode.x && existingNode.y) { + // Preserve existing position + nodesMap.set(part, { + id: part, + type: 'topic', + label: part, + count: 0, + x: existingNode.x, + y: existingNode.y, + fx: existingNode.fx, + fy: existingNode.fy + }); + } else { + // Determine where to place this new node based on hierarchy + let parentPart; + let refPos; + + if (index === 0) { + // First part connects to center + parentPart = 'center'; + } else { + // Subsequent parts connect to previous part in this topic + parentPart = data.parts[index - 1]; + } + + // Get parent position (should exist since we process in order) + refPos = nodesMap.get(parentPart); + + // Fallback to center if parent doesn't exist yet + if (!refPos) { + refPos = nodesMap.get('center'); + } + + // Initialize new node very close to its parent + const angle = Math.random() * Math.PI * 2; + const distance = 60 + Math.random() * 30; + nodesMap.set(part, { + id: part, + type: 'topic', + label: part, + count: 0, + x: refPos.x + Math.cos(angle) * distance, + y: refPos.y + Math.sin(angle) * distance, + fx: null, + fy: null + }); + } + } + }); + } + + // Second pass: count occurrences of each part + for (const [topic, data] of events) { + data.parts.forEach(part => { + const node = nodesMap.get(part); + if (node) { + node.count += data.count; + } + }); + } + } + + this.graphNodes = Array.from(nodesMap.values()); + + // Build links as chains for each topic + // For "cluster/update", create: center -> cluster -> update + this.graphLinks = []; + const linkSet = new Set(); // Track links to avoid duplicates + + if (events && events.size > 0) { + for (const [topic, data] of events) { + const parts = data.parts; + + if (parts.length === 0) continue; + + // Connect center to first part + const firstPart = parts[0]; + const centerToFirst = `center-${firstPart}`; + if (!linkSet.has(centerToFirst)) { + this.graphLinks.push({ + source: 'center', + target: firstPart, + type: 'center-link', + count: data.count, + topic: topic + }); + linkSet.add(centerToFirst); + } else { + // Update count for existing link + const existingLink = this.graphLinks.find(l => + `${l.source}-${l.target}` === centerToFirst + ); + if (existingLink) { + existingLink.count += data.count; + } + } + + // Connect each part to the next (creating a chain) + for (let i = 0; i < parts.length - 1; i++) { + const source = parts[i]; + const target = parts[i + 1]; + const linkKey = `${source}-${target}`; + + if (!linkSet.has(linkKey)) { + this.graphLinks.push({ + source: source, + target: target, + type: 'topic-link', + topic: topic, + count: data.count + }); + linkSet.add(linkKey); + } else { + // Update count for existing link + const existingLink = this.graphLinks.find(l => + `${l.source}-${l.target}` === linkKey + ); + if (existingLink) { + existingLink.count += data.count; + } + } + } + } + } + + // Update D3 simulation + this.updateSimulation(); + } + + updateSimulation() { + const g = this.svg.select('g'); + if (!g || g.empty()) { + return; + } + + // Update links + const links = g.selectAll('.link') + .data(this.graphLinks, d => `${d.source}-${d.target}`); + + // Remove old links + links.exit() + .transition() + .duration(300) + .style('opacity', 0) + .remove(); + + // Add new links + const linksEnter = links.enter() + .append('line') + .attr('class', d => `link ${d.type}`) + .attr('stroke-opacity', 0); + + // Merge and create linksUpdate + const linksUpdate = linksEnter.merge(links); + + // Add hover interactions to all links (existing ones will be updated) + linksUpdate + .on('mouseover', function(event, d) { + d3.select(event.currentTarget) + .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(3, Math.min(5, Math.sqrt(d.count) + 2)) : 3.5) + .attr('stroke-opacity', 0.9); + }) + .on('mouseout', function(event, d) { + d3.select(event.currentTarget) + .attr('stroke-width', d => d.type === 'topic-link' ? Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)) : 2.5) + .attr('stroke-opacity', 0.7); + }); + + // Update link attributes directly (no transitions to avoid event conflicts) + linksUpdate + .attr('stroke', d => { + if (d.type === 'center-link') return '#3498db'; + return '#10b981'; + }) + .attr('stroke-opacity', 0.7) + .attr('stroke-width', d => { + if (d.type === 'topic-link') return Math.max(1.5, Math.min(4, Math.sqrt(d.count) + 1)); + return 2.5; + }); + + // Update nodes + const nodes = g.selectAll('.node') + .data(this.graphNodes, d => d.id); + + // Remove old nodes + nodes.exit() + .transition() + .duration(300) + .style('opacity', 0) + .remove(); + + // Add new nodes + const nodesEnter = nodes.enter() + .append('g') + .attr('class', d => `node ${d.type}`) + .style('opacity', 0) + .call(this.drag()); + + // Add circle + nodesEnter.append('circle') + .attr('r', d => d.type === 'center' ? 18 : 14) + .attr('fill', d => { + if (d.type === 'center') return '#3498db'; + return '#10b981'; + }) + .attr('stroke', '#fff') + .attr('stroke-width', 2.5); + + // Add text labels + nodesEnter.append('text') + .attr('dy', d => d.type === 'center' ? -28 : -20) + .attr('text-anchor', 'middle') + .attr('font-size', d => d.type === 'center' ? '16px' : '13px') + .attr('fill', 'var(--text-primary)') + .attr('font-weight', '600') + .text(d => d.label); + + // Merge and update all nodes + const nodesUpdate = nodesEnter.merge(nodes); + + // Update node visibility + nodesUpdate.style('opacity', 1); + + // Update circles + nodesUpdate.select('circle') + .attr('r', d => d.type === 'center' ? 18 : 14) + .attr('fill', d => { + if (d.type === 'center') return '#3498db'; + return '#10b981'; + }); + + // Update text labels + nodesUpdate.select('text') + .text(d => d.label); + + // Remove any existing count badges + nodesUpdate.selectAll('.count-badge').remove(); + + // Update simulation + this.simulation.nodes(this.graphNodes); + + // Only add links force if there are links + if (this.graphLinks.length > 0) { + const linkForce = this.simulation.force('link'); + if (linkForce) { + linkForce.links(this.graphLinks); + } else { + // Create the link force if it doesn't exist + this.simulation.force('link', d3.forceLink().id(d => d.id).distance(100)); + this.simulation.force('link').links(this.graphLinks); + } + } else { + // Remove link force if no links + this.simulation.force('link', null); + } + + // Update positions on tick + this.simulation.on('tick', () => { + if (linksUpdate && this.graphLinks.length > 0) { + linksUpdate.each(function(d) { + const link = d3.select(this); + + // Calculate direction from source to target + const dx = d.target.x - d.source.x; + const dy = d.target.y - d.source.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length > 0) { + // Normalize direction vector + const nx = dx / length; + const ny = dy / length; + + // Edge radius based on target node type + const targetRadius = d.target.type === 'center' ? 18 : 14; + const sourceRadius = d.source.type === 'center' ? 18 : 14; + + // Start from edge of source node + const x1 = d.source.x + nx * sourceRadius; + const y1 = d.source.y + ny * sourceRadius; + + // End at edge of target node + const x2 = d.target.x - nx * targetRadius; + const y2 = d.target.y - ny * targetRadius; + + link.attr('x1', x1) + .attr('y1', y1) + .attr('x2', x2) + .attr('y2', y2); + } + }); + } + + nodesUpdate + .attr('transform', d => `translate(${d.x},${d.y})`); + }); + + // Use lower alpha to avoid jumping - just tweak positions gently + this.simulation.alpha(0.3).restart(); + } + + drag() { + const simulation = this.simulation; + const component = this; + + let initialCenterX = 0; + let initialCenterY = 0; + + function dragstarted(event, d) { + if (!event.active) simulation.alphaTarget(0.3).restart(); + d.fx = d.x; + d.fy = d.y; + + // If dragging the center node, store initial position and get all nodes + if (d.type === 'center') { + initialCenterX = d.x; + initialCenterY = d.y; + } + } + + function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; + + // If dragging the center node, move all other nodes by the same offset + if (d.type === 'center') { + const dx = event.x - initialCenterX; + const dy = event.y - initialCenterY; + + // Apply the same offset to all other nodes + simulation.nodes().forEach(node => { + if (node.id !== 'center') { + node.fx = node.x + dx; + node.fy = node.y + dy; + } + }); + + // Update initial position for next iteration + initialCenterX = event.x; + initialCenterY = event.y; + } + } + + function dragended(event, d) { + if (!event.active) simulation.alphaTarget(0); + // Keep positions fixed after dragging + d.fx = d.x; + d.fy = d.y; + + // If dragging the center node, preserve all node positions + if (d.type === 'center') { + simulation.nodes().forEach(node => { + node.fx = node.x; + node.fy = node.y; + }); + } + } + + return d3.drag() + .on('start', dragstarted) + .on('drag', dragged) + .on('end', dragended); + } + + render() { + if (!this.isInitialized) { + this.initialize(); + } + + const events = this.viewModel.get('events'); + + // If no events exist yet, show an empty state message + if (!events || events.size === 0) { + this.showEmptyState(); + } else { + // Remove empty state if it exists + const g = this.svg.select('g'); + if (!g.empty()) { + g.selectAll('.empty-state').remove(); + } + } + + // Always call updateGraph to show at least the center node + this.updateGraph(); + } + + showEmptyState() { + if (!this.isInitialized || !this.svg) return; + + const g = this.svg.select('g'); + if (g.empty()) return; + + // Remove any existing message + g.selectAll('.empty-state').remove(); + + // Account for the initial zoom transform (scale(1.4) translate(-200, -150)) + // We need to place the text in the center of the transformed coordinate space + const transformX = -200; + const transformY = -150; + const scale = 1.4; + + // Calculate centered position in transformed space + const x = ((this.width / 2) - transformX) / scale; + const y = ((this.height / 2) - transformY) / scale; + + const emptyMsg = g.append('text') + .attr('class', 'empty-state') + .attr('x', x) + .attr('y', y) + .attr('text-anchor', 'middle') + .attr('font-size', '18px') + .attr('fill', 'var(--text-secondary)') + .attr('font-weight', '500') + .text('Waiting for websocket events...'); + } + + onPause() { + if (this.simulation) { + this.simulation.stop(); + } + } + + onResume() { + if (this.simulation) { + this.simulation.alpha(0.3).restart(); + } + } + + shouldRenderOnResume() { + return false; + } + + createRearrangeButton(container) { + const button = document.createElement('button'); + button.className = 'topology-rearrange-btn'; + button.innerHTML = ` + + + + + + + + + Rearrange + `; + button.title = 'Rearrange nodes into a clean layout'; + button.style.cssText = ` + position: absolute; + left: 16px; + top: 16px; + z-index: 1000; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + background: var(--card-background, rgba(30, 30, 30, 0.95)); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1)); + border-radius: 8px; + color: var(--text-primary, #ecf0f1); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + `; + + // Add hover effect + button.addEventListener('mouseenter', () => { + button.style.background = 'var(--card-hover, rgba(40, 40, 40, 0.95))'; + button.style.borderColor = 'var(--primary-color, #3498db)'; + button.style.transform = 'translateY(-1px)'; + button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; + }); + + button.addEventListener('mouseleave', () => { + button.style.background = 'var(--card-background, rgba(30, 30, 30, 0.95))'; + button.style.borderColor = 'var(--border-color, rgba(255, 255, 255, 0.1))'; + button.style.transform = 'translateY(0)'; + button.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)'; + }); + + // Add click handler + button.addEventListener('click', () => { + this.rearrangeNodes(); + }); + + container.appendChild(button); + } + + rearrangeNodes() { + if (!this.simulation) { + return; + } + + // Clear fixed positions for all nodes except center + this.graphNodes.forEach(node => { + if (node.type !== 'center') { + node.fx = null; + node.fy = null; + } + }); + + // Restart simulation with higher energy + this.simulation.alpha(1).restart(); + } + + animateEventMessage(topic, data) { + const g = this.svg.select('g'); + if (g.empty()) return; + + const parts = data.parts || topic.split('/').filter(p => p); + if (parts.length === 0) return; + + // Wait for the next tick to ensure nodes exist + setTimeout(() => { + // Build the chain: center -> first -> second -> ... -> last + const chain = ['center', ...parts]; + + // Animate along each segment of the chain + let delay = 0; + for (let i = 0; i < chain.length - 1; i++) { + const sourceId = chain[i]; + const targetId = chain[i + 1]; + + const sourceNode = this.graphNodes.find(n => n.id === sourceId); + const targetNode = this.graphNodes.find(n => n.id === targetId); + + if (!sourceNode || !targetNode) continue; + + setTimeout(() => { + this.animateDotAlongLink(sourceNode, targetNode, g); + }, delay); + + // Add delay between segments (staggered animation) + delay += 400; // Duration (300ms) + small gap (100ms) + } + }, 100); + } + + animateDotAlongLink(sourceNode, targetNode, g) { + if (!sourceNode || !targetNode || !g) return; + + // Calculate positions + const dx = targetNode.x - sourceNode.x; + const dy = targetNode.y - sourceNode.y; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) return; + + // Normalize direction + const nx = dx / length; + const ny = dy / length; + + // Calculate node radii + const sourceRadius = sourceNode.type === 'center' ? 18 : 14; + const targetRadius = targetNode.type === 'center' ? 18 : 14; + + // Start from edge of source node + const startX = sourceNode.x + nx * sourceRadius; + const startY = sourceNode.y + ny * sourceRadius; + + // End at edge of target node + const endX = targetNode.x - nx * targetRadius; + const endY = targetNode.y - ny * targetRadius; + + // Create animation dot + const animationGroup = g.append('g').attr('class', 'animation-group'); + + const dot = animationGroup.append('circle') + .attr('r', 4) + .attr('fill', '#FFD700') + .attr('stroke', '#fff') + .attr('stroke-width', 2) + .attr('cx', startX) + .attr('cy', startY); + + // Animate the dot along the path + dot.transition() + .duration(300) + .ease(d3.easeLinear) + .attr('cx', endX) + .attr('cy', endY) + .on('end', function() { + // Fade out after reaching destination + dot.transition() + .duration(100) + .style('opacity', 0) + .remove(); + animationGroup.remove(); + }); + } +} + +window.EventComponent = EventComponent; diff --git a/public/scripts/view-models.js b/public/scripts/view-models.js index 060c127..883eb16 100644 --- a/public/scripts/view-models.js +++ b/public/scripts/view-models.js @@ -1235,4 +1235,147 @@ class WiFiConfigViewModel extends ViewModel { } } -window.WiFiConfigViewModel = WiFiConfigViewModel; \ No newline at end of file +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 } + 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) { + 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'); + }); + } + + // 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; \ No newline at end of file diff --git a/public/styles/main.css b/public/styles/main.css index b2f321e..155c07c 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -559,11 +559,12 @@ p { } /* Topology graph node interactions */ -#topology-graph-container .node { +#topology-graph-container .node, #events-graph-container .node { 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); } @@ -4335,7 +4336,7 @@ select.param-input:focus { width: 100%; /* Use full container width */ } -#topology-graph-container { +#topology-graph-container, #events-graph-container { background: var(--bg-tertiary); border-radius: 12px; height: 100%; @@ -4351,24 +4352,27 @@ select.param-input:focus { max-height: 100%; /* Ensure it doesn't exceed parent height */ } -#topology-graph-container svg { +#topology-graph-container svg, #events-graph-container svg { width: 100%; height: 100%; } #topology-graph-container .loading, #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; color: var(--text-tertiary); font-size: 1.1rem; } -#topology-graph-container .error { +#topology-graph-container .error, #events-graph-container .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); }