Compare commits
9 Commits
5850931614
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09052cbfc2 | |||
| 2a7d170824 | |||
| cbe13f1d84 | |||
| 65b491640b | |||
| f6dc4e8bf3 | |||
| 359d681c72 | |||
| 62badbc692 | |||
| 698a150162 | |||
| 741cc48ce5 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
*.md
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
test
|
||||||
|
openapitools.json
|
||||||
|
*.backup
|
||||||
|
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install wget for health checks
|
||||||
|
RUN apk --no-cache add wget
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from builder
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY index.js index-standalone.js ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Create non-root user (let Alpine assign available GID/UID)
|
||||||
|
RUN addgroup spore && \
|
||||||
|
adduser -D -s /bin/sh -G spore spore && \
|
||||||
|
chown -R spore:spore /app
|
||||||
|
|
||||||
|
USER spore
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["node", "index.js"]
|
||||||
|
|
||||||
54
Makefile
Normal file
54
Makefile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the application (if needed)
|
||||||
|
build: install
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
run:
|
||||||
|
node index.js
|
||||||
|
|
||||||
|
# Start in development mode
|
||||||
|
dev:
|
||||||
|
node index.js
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -f package-lock.json
|
||||||
|
|
||||||
|
# Docker variables
|
||||||
|
DOCKER_REGISTRY ?=
|
||||||
|
IMAGE_NAME = wirelos/spore-ui
|
||||||
|
IMAGE_TAG ?= latest
|
||||||
|
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker-build:
|
||||||
|
docker build -t $(FULL_IMAGE_NAME) .
|
||||||
|
|
||||||
|
# Run Docker container
|
||||||
|
docker-run:
|
||||||
|
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
|
||||||
|
|
||||||
|
# Push Docker image
|
||||||
|
docker-push:
|
||||||
|
docker push $(FULL_IMAGE_NAME)
|
||||||
|
|
||||||
|
# Build multiarch Docker image
|
||||||
|
docker-build-multiarch:
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||||
|
-t $(FULL_IMAGE_NAME) \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
|
# Push multiarch Docker image (if not pushed during build)
|
||||||
|
docker-push-multiarch:
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||||
|
-t $(FULL_IMAGE_NAME) \
|
||||||
|
--push \
|
||||||
|
.
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b
|
|||||||

|

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

|

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

|
||||||
|

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

|

|
||||||
### Firmware
|
### Firmware
|
||||||
|
|||||||
BIN
assets/events-messages.png
Normal file
BIN
assets/events-messages.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
assets/events.png
Normal file
BIN
assets/events.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 343 KiB |
518
docs/Events.md
Normal file
518
docs/Events.md
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
# Events Feature
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Real-Time Event Visualization
|
||||||
|
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
|
||||||
|
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
|
||||||
|
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
|
||||||
|
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
|
||||||
|
|
||||||
|
### User Interactions
|
||||||
|
|
||||||
|
#### Drag to Reposition Nodes
|
||||||
|
- **Center Node**: Dragging the center (blue) node moves the entire graph together
|
||||||
|
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
|
||||||
|
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
|
||||||
|
|
||||||
|
#### Zoom Controls
|
||||||
|
- **Mouse Wheel**: Zoom in and out using the mouse wheel
|
||||||
|
- **Scale Range**: Zoom level constrained between 0.5x and 5x
|
||||||
|
- **Pan**: Click and drag the canvas to pan around the graph
|
||||||
|
|
||||||
|
#### Rearrange Layout
|
||||||
|
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
|
||||||
|
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
|
||||||
|
|
||||||
|
### Visual Elements
|
||||||
|
|
||||||
|
#### Node Types
|
||||||
|
- **Center Node** (Blue):
|
||||||
|
- Represents the central event hub
|
||||||
|
- Always visible in the graph
|
||||||
|
- Acts as the root node for all event chains
|
||||||
|
- Larger size (18px radius)
|
||||||
|
|
||||||
|
- **Topic Nodes** (Green):
|
||||||
|
- Represent individual topic segments
|
||||||
|
- Examples: `cluster`, `event`, `api`, `neopattern`
|
||||||
|
- Smaller size (14px radius)
|
||||||
|
- Positioned dynamically based on event frequency
|
||||||
|
|
||||||
|
#### Link Types
|
||||||
|
- **Center Links** (Blue):
|
||||||
|
- Connect the center node to root topic nodes
|
||||||
|
- Thicker lines (2.5px)
|
||||||
|
- Examples: center → `cluster`, center → `event`
|
||||||
|
|
||||||
|
- **Topic Links** (Green):
|
||||||
|
- Connect adjacent topics in event chains
|
||||||
|
- Line thickness proportional to event frequency
|
||||||
|
- Examples: `cluster` → `event`, `api` → `neopattern`
|
||||||
|
|
||||||
|
#### Hover Interactions
|
||||||
|
- **Link Hover**: Links become thicker and more opaque when hovered
|
||||||
|
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```12:37:spore-ui/public/scripts/components/EventComponent.js
|
||||||
|
// Events Component - Visualizes websocket events as a graph
|
||||||
|
class EventComponent extends Component {
|
||||||
|
constructor(container, viewModel, eventBus) {
|
||||||
|
super(container, viewModel, eventBus);
|
||||||
|
|
||||||
|
this.svg = null;
|
||||||
|
this.simulation = null;
|
||||||
|
this.zoom = null;
|
||||||
|
this.width = 0;
|
||||||
|
this.height = 0;
|
||||||
|
this.isInitialized = false;
|
||||||
|
|
||||||
|
// Center node data - will be initialized with proper coordinates later
|
||||||
|
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
|
||||||
|
|
||||||
|
// Track nodes for D3
|
||||||
|
this.graphNodes = [];
|
||||||
|
this.graphLinks = [];
|
||||||
|
|
||||||
|
// Track recent events to trigger animations
|
||||||
|
this.lastSeenEvents = new Set();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ViewModel
|
||||||
|
|
||||||
|
The `EventViewModel` manages the state and WebSocket connections:
|
||||||
|
|
||||||
|
```1241:1253:spore-ui/public/scripts/view-models.js
|
||||||
|
// Events View Model for websocket event visualization
|
||||||
|
class EventViewModel extends ViewModel {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setMultiple({
|
||||||
|
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdateTime: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up WebSocket listeners for real-time updates
|
||||||
|
this.setupWebSocketListeners();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Tracking
|
||||||
|
|
||||||
|
Events are tracked with the following metadata:
|
||||||
|
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
|
||||||
|
- **Parts**: Array of topic segments split by `/`
|
||||||
|
- **Count**: Total occurrences of this event
|
||||||
|
- **First Seen**: ISO timestamp of first occurrence
|
||||||
|
- **Last Seen**: ISO timestamp of most recent occurrence
|
||||||
|
- **Last Data**: The most recent event payload
|
||||||
|
|
||||||
|
### Event Addition Logic
|
||||||
|
|
||||||
|
The view model handles nested events specially:
|
||||||
|
|
||||||
|
```1283:1329:spore-ui/public/scripts/view-models.js
|
||||||
|
// Add a topic (parsed by "/" separator)
|
||||||
|
addTopic(topic, data = null) {
|
||||||
|
// Get current events as a new Map to ensure change detection
|
||||||
|
const events = new Map(this.get('events'));
|
||||||
|
|
||||||
|
// Handle nested events from cluster/event
|
||||||
|
let fullTopic = topic;
|
||||||
|
if (topic === 'cluster/event' && data && data.data) {
|
||||||
|
try {
|
||||||
|
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
||||||
|
if (parsedData && parsedData.event) {
|
||||||
|
// Create nested topic chain: cluster/event/api/neopattern
|
||||||
|
fullTopic = `${topic}/${parsedData.event}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, just use the original topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = fullTopic.split('/').filter(p => p);
|
||||||
|
|
||||||
|
if (events.has(fullTopic)) {
|
||||||
|
// Update existing event - create new object to ensure change detection
|
||||||
|
const existing = events.get(fullTopic);
|
||||||
|
events.set(fullTopic, {
|
||||||
|
topic: existing.topic,
|
||||||
|
parts: existing.parts,
|
||||||
|
count: existing.count + 1,
|
||||||
|
firstSeen: existing.firstSeen,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
lastData: data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new event entry
|
||||||
|
events.set(fullTopic, {
|
||||||
|
topic: fullTopic,
|
||||||
|
parts: parts,
|
||||||
|
count: 1,
|
||||||
|
firstSeen: new Date().toISOString(),
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
lastData: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use set to trigger change notification
|
||||||
|
this.set('events', events);
|
||||||
|
this.set('lastUpdateTime', new Date().toISOString());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graph Construction
|
||||||
|
|
||||||
|
### Node Creation
|
||||||
|
|
||||||
|
The graph construction process follows two passes:
|
||||||
|
|
||||||
|
1. **First Pass - Node Creation**:
|
||||||
|
- Creates nodes for each unique topic segment
|
||||||
|
- Preserves existing node positions if they've been dragged
|
||||||
|
- New nodes are positioned near their parent in the hierarchy
|
||||||
|
|
||||||
|
2. **Second Pass - Event Counting**:
|
||||||
|
- Counts occurrences of each topic segment
|
||||||
|
- Updates node counts to reflect event frequency
|
||||||
|
|
||||||
|
### Link Construction
|
||||||
|
|
||||||
|
Links are created as chains representing complete topic paths:
|
||||||
|
|
||||||
|
```283:342:spore-ui/public/scripts/components/EventComponent.js
|
||||||
|
// Build links as chains for each topic
|
||||||
|
// For "cluster/update", create: center -> cluster -> update
|
||||||
|
this.graphLinks = [];
|
||||||
|
const linkSet = new Set(); // Track links to avoid duplicates
|
||||||
|
|
||||||
|
if (events && events.size > 0) {
|
||||||
|
for (const [topic, data] of events) {
|
||||||
|
const parts = data.parts;
|
||||||
|
|
||||||
|
if (parts.length === 0) continue;
|
||||||
|
|
||||||
|
// Connect center to first part
|
||||||
|
const firstPart = parts[0];
|
||||||
|
const centerToFirst = `center-${firstPart}`;
|
||||||
|
if (!linkSet.has(centerToFirst)) {
|
||||||
|
this.graphLinks.push({
|
||||||
|
source: 'center',
|
||||||
|
target: firstPart,
|
||||||
|
type: 'center-link',
|
||||||
|
count: data.count,
|
||||||
|
topic: topic
|
||||||
|
});
|
||||||
|
linkSet.add(centerToFirst);
|
||||||
|
} else {
|
||||||
|
// Update count for existing link
|
||||||
|
const existingLink = this.graphLinks.find(l =>
|
||||||
|
`${l.source}-${l.target}` === centerToFirst
|
||||||
|
);
|
||||||
|
if (existingLink) {
|
||||||
|
existingLink.count += data.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect each part to the next (creating a chain)
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
const source = parts[i];
|
||||||
|
const target = parts[i + 1];
|
||||||
|
const linkKey = `${source}-${target}`;
|
||||||
|
|
||||||
|
if (!linkSet.has(linkKey)) {
|
||||||
|
this.graphLinks.push({
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
type: 'topic-link',
|
||||||
|
topic: topic,
|
||||||
|
count: data.count
|
||||||
|
});
|
||||||
|
linkSet.add(linkKey);
|
||||||
|
} else {
|
||||||
|
// Update count for existing link
|
||||||
|
const existingLink = this.graphLinks.find(l =>
|
||||||
|
`${l.source}-${l.target}` === linkKey
|
||||||
|
);
|
||||||
|
if (existingLink) {
|
||||||
|
existingLink.count += data.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Force Simulation
|
||||||
|
|
||||||
|
The D3.js force simulation provides the physics-based layout:
|
||||||
|
|
||||||
|
```103:111:spore-ui/public/scripts/components/EventComponent.js
|
||||||
|
// Initialize simulation
|
||||||
|
this.simulation = d3.forceSimulation()
|
||||||
|
.force('link', d3.forceLink().id(d => d.id).distance(100))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-300))
|
||||||
|
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius(35))
|
||||||
|
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
|
||||||
|
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Parameters
|
||||||
|
- **Link Force**: Maintains 100px distance between connected nodes
|
||||||
|
- **Charge Force**: -300 strength creates repulsion between nodes
|
||||||
|
- **Center Force**: Keeps the graph centered in the viewport
|
||||||
|
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
|
||||||
|
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
|
||||||
|
- **Velocity Decay**: High decay (0.4) prevents excessive movement
|
||||||
|
|
||||||
|
## Animation System
|
||||||
|
|
||||||
|
### Event Arrival Animation
|
||||||
|
|
||||||
|
When a new event arrives, a golden animation dot travels along the event chain:
|
||||||
|
|
||||||
|
```725:809:spore-ui/public/scripts/components/EventComponent.js
|
||||||
|
animateEventMessage(topic, data) {
|
||||||
|
const g = this.svg.select('g');
|
||||||
|
if (g.empty()) return;
|
||||||
|
|
||||||
|
const parts = data.parts || topic.split('/').filter(p => p);
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
|
||||||
|
// Wait for the next tick to ensure nodes exist
|
||||||
|
setTimeout(() => {
|
||||||
|
// Build the chain: center -> first -> second -> ... -> last
|
||||||
|
const chain = ['center', ...parts];
|
||||||
|
|
||||||
|
// Animate along each segment of the chain
|
||||||
|
let delay = 0;
|
||||||
|
for (let i = 0; i < chain.length - 1; i++) {
|
||||||
|
const sourceId = chain[i];
|
||||||
|
const targetId = chain[i + 1];
|
||||||
|
|
||||||
|
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
|
||||||
|
const targetNode = this.graphNodes.find(n => n.id === targetId);
|
||||||
|
|
||||||
|
if (!sourceNode || !targetNode) continue;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.animateDotAlongLink(sourceNode, targetNode, g);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// Add delay between segments (staggered animation)
|
||||||
|
delay += 400; // Duration (300ms) + small gap (100ms)
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
animateDotAlongLink(sourceNode, targetNode, g) {
|
||||||
|
if (!sourceNode || !targetNode || !g) return;
|
||||||
|
|
||||||
|
// Calculate positions
|
||||||
|
const dx = targetNode.x - sourceNode.x;
|
||||||
|
const dy = targetNode.y - sourceNode.y;
|
||||||
|
const length = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
if (length === 0) return;
|
||||||
|
|
||||||
|
// Normalize direction
|
||||||
|
const nx = dx / length;
|
||||||
|
const ny = dy / length;
|
||||||
|
|
||||||
|
// Calculate node radii
|
||||||
|
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
|
||||||
|
const targetRadius = targetNode.type === 'center' ? 18 : 14;
|
||||||
|
|
||||||
|
// Start from edge of source node
|
||||||
|
const startX = sourceNode.x + nx * sourceRadius;
|
||||||
|
const startY = sourceNode.y + ny * sourceRadius;
|
||||||
|
|
||||||
|
// End at edge of target node
|
||||||
|
const endX = targetNode.x - nx * targetRadius;
|
||||||
|
const endY = targetNode.y - ny * targetRadius;
|
||||||
|
|
||||||
|
// Create animation dot
|
||||||
|
const animationGroup = g.append('g').attr('class', 'animation-group');
|
||||||
|
|
||||||
|
const dot = animationGroup.append('circle')
|
||||||
|
.attr('r', 4)
|
||||||
|
.attr('fill', '#FFD700')
|
||||||
|
.attr('stroke', '#fff')
|
||||||
|
.attr('stroke-width', 2)
|
||||||
|
.attr('cx', startX)
|
||||||
|
.attr('cy', startY);
|
||||||
|
|
||||||
|
// Animate the dot along the path
|
||||||
|
dot.transition()
|
||||||
|
.duration(300)
|
||||||
|
.ease(d3.easeLinear)
|
||||||
|
.attr('cx', endX)
|
||||||
|
.attr('cy', endY)
|
||||||
|
.on('end', function() {
|
||||||
|
// Fade out after reaching destination
|
||||||
|
dot.transition()
|
||||||
|
.duration(100)
|
||||||
|
.style('opacity', 0)
|
||||||
|
.remove();
|
||||||
|
animationGroup.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Animation Characteristics
|
||||||
|
- **Golden Dot**: Visual indicator traveling along event chains
|
||||||
|
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
|
||||||
|
- **Duration**: 300ms per segment with 100ms delay between segments
|
||||||
|
- **Smooth Motion**: Linear easing for consistent speed
|
||||||
|
|
||||||
|
## WebSocket Integration
|
||||||
|
|
||||||
|
The Events view subscribes to all WebSocket messages through the gateway:
|
||||||
|
|
||||||
|
```1256:1280:spore-ui/public/scripts/view-models.js
|
||||||
|
// Set up WebSocket event listeners
|
||||||
|
setupWebSocketListeners() {
|
||||||
|
if (!window.wsClient) {
|
||||||
|
// Retry after a short delay to allow wsClient to initialize
|
||||||
|
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for all websocket messages
|
||||||
|
window.wsClient.on('message', (data) => {
|
||||||
|
const topic = data.topic || data.type;
|
||||||
|
|
||||||
|
if (topic) {
|
||||||
|
this.addTopic(topic, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for connection status changes
|
||||||
|
window.wsClient.on('connected', () => {
|
||||||
|
logger.info('EventViewModel: WebSocket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wsClient.on('disconnected', () => {
|
||||||
|
logger.debug('EventViewModel: WebSocket disconnected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Types Tracked
|
||||||
|
|
||||||
|
All WebSocket messages are captured and displayed, including:
|
||||||
|
- **cluster/update**: Node discovery and cluster membership changes
|
||||||
|
- **cluster/event**: Arbitrary events from cluster members
|
||||||
|
- **api/tasks**: Task execution notifications
|
||||||
|
- **api/config**: Configuration updates
|
||||||
|
- **node/***: Node-specific events
|
||||||
|
- Any custom topics sent by SPORE nodes
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
When no events have been received, the view displays a helpful message:
|
||||||
|
|
||||||
|
```604:632:spore-ui/public/scripts/components/EventComponent.js
|
||||||
|
showEmptyState() {
|
||||||
|
if (!this.isInitialized || !this.svg) return;
|
||||||
|
|
||||||
|
const g = this.svg.select('g');
|
||||||
|
if (g.empty()) return;
|
||||||
|
|
||||||
|
// Remove any existing message
|
||||||
|
g.selectAll('.empty-state').remove();
|
||||||
|
|
||||||
|
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
|
||||||
|
// We need to place the text in the center of the transformed coordinate space
|
||||||
|
const transformX = -200;
|
||||||
|
const transformY = -150;
|
||||||
|
const scale = 1.4;
|
||||||
|
|
||||||
|
// Calculate centered position in transformed space
|
||||||
|
const x = ((this.width / 2) - transformX) / scale;
|
||||||
|
const y = ((this.height / 2) - transformY) / scale;
|
||||||
|
|
||||||
|
const emptyMsg = g.append('text')
|
||||||
|
.attr('class', 'empty-state')
|
||||||
|
.attr('x', x)
|
||||||
|
.attr('y', y)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('font-size', '18px')
|
||||||
|
.attr('fill', 'var(--text-secondary)')
|
||||||
|
.attr('font-weight', '500')
|
||||||
|
.text('Waiting for websocket events...');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
|
||||||
|
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
|
||||||
|
3. **Animation**: Rapid event bursts may cause overlapping animations
|
||||||
|
|
||||||
|
### Usage Tips
|
||||||
|
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
|
||||||
|
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
|
||||||
|
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
|
||||||
|
4. **Interactive Exploration**: Hover over links to see event frequencies
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No Events Showing
|
||||||
|
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
|
||||||
|
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
|
||||||
|
- **Browser Console**: Check for any JavaScript errors
|
||||||
|
|
||||||
|
### Graph Not Updating
|
||||||
|
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
|
||||||
|
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
- **Clear Events**: Reload the page to clear accumulated events
|
||||||
|
- **Reduce Events**: Limit the event rate in your SPORE nodes
|
||||||
|
- **Browser Settings**: Ensure hardware acceleration is enabled
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
|
||||||
|
- **SPORE Gateway**: WebSocket connection to cluster event stream
|
||||||
|
- **Component Framework**: Built on the SPORE UI component architecture
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
|
||||||
|
- **SVG Support**: Requires modern browser with full SVG support
|
||||||
|
- **WebSocket Support**: Native WebSocket API required
|
||||||
|
|
||||||
|
### View Structure
|
||||||
|
|
||||||
|
```254:262:spore-ui/public/index.html
|
||||||
|
<div id="events-view" class="view-content">
|
||||||
|
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||||
|
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Waiting for websocket events...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.
|
||||||
|
|
||||||
78
docs/README.md
Normal file
78
docs/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# SPORE UI Documentation
|
||||||
|
|
||||||
|
This directory contains detailed documentation for the SPORE UI frontend application.
|
||||||
|
|
||||||
|
## Documentation Index
|
||||||
|
|
||||||
|
### Core Documentation
|
||||||
|
|
||||||
|
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
|
||||||
|
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
|
||||||
|
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
|
||||||
|
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
|
||||||
|
|
||||||
|
### Feature Documentation
|
||||||
|
|
||||||
|
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
|
||||||
|
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
### Events Feature
|
||||||
|
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||||
|
|
||||||
|
**Key Capabilities:**
|
||||||
|
- Real-time event tracking and visualization
|
||||||
|
- Interactive force-directed graph layout
|
||||||
|
- Event counting and frequency visualization
|
||||||
|
- Animated event flow visualization
|
||||||
|
- Topic hierarchy display
|
||||||
|
|
||||||
|
See [Events.md](./Events.md) for complete documentation.
|
||||||
|
|
||||||
|
### Framework Architecture
|
||||||
|
The SPORE UI uses a component-based architecture with:
|
||||||
|
- View Models for state management
|
||||||
|
- Components for UI rendering
|
||||||
|
- Event Bus for pub/sub communication
|
||||||
|
- API Client for backend integration
|
||||||
|
|
||||||
|
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
|
||||||
|
|
||||||
|
### Discovery System
|
||||||
|
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
|
||||||
|
|
||||||
|
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
|
||||||
|
|
||||||
|
### Topology Visualization
|
||||||
|
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
|
||||||
|
|
||||||
|
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Most Popular Pages
|
||||||
|
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
|
||||||
|
2. [Events Feature](./Events.md) - Real-time event visualization
|
||||||
|
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
|
||||||
|
|
||||||
|
### Developer Resources
|
||||||
|
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
|
||||||
|
- Debugging: See [LOGGING.md](./LOGGING.md)
|
||||||
|
- Testing: See individual component documentation
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new features or documentation:
|
||||||
|
1. Follow the existing documentation structure
|
||||||
|
2. Include code examples and diagrams where helpful
|
||||||
|
3. Update this README when adding new documentation files
|
||||||
|
4. Maintain consistency with existing documentation style
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
For information about other SPORE components:
|
||||||
|
- **SPORE Gateway**: See `spore-gateway/README.md`
|
||||||
|
- **SPORE Registry**: See `spore-registry/README.md`
|
||||||
|
- **SPORE Embedded**: See `spore/README.md`
|
||||||
|
|
||||||
@@ -70,14 +70,34 @@ udpServer.on('message', (msg, rinfo) => {
|
|||||||
|
|
||||||
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
|
|
||||||
if (message.startsWith('CLUSTER_HEARTBEAT:')) {
|
// Extract topic by splitting on first ":"
|
||||||
// Handle heartbeat messages that update member list
|
const parts = message.split(':', 2);
|
||||||
const hostname = message.substring('CLUSTER_HEARTBEAT:'.length);
|
if (parts.length < 2) {
|
||||||
updateNodeFromHeartbeat(sourceIp, sourcePort, hostname);
|
console.log(`Invalid message format from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
} else if (message.startsWith('NODE_UPDATE:')) {
|
return;
|
||||||
// Handle node update messages that provide detailed node info
|
}
|
||||||
handleNodeUpdate(sourceIp, message);
|
|
||||||
} else if (!message.startsWith('RAW:')) {
|
const topic = parts[0] + ':';
|
||||||
|
const payload = parts[1];
|
||||||
|
|
||||||
|
// Handler map for different UDP message types
|
||||||
|
const handlers = {
|
||||||
|
'cluster/heartbeat:': (payload, sourceIp, sourcePort) => {
|
||||||
|
updateNodeFromHeartbeat(sourceIp, sourcePort, payload);
|
||||||
|
},
|
||||||
|
'node/update:': (payload, sourceIp) => {
|
||||||
|
handleNodeUpdate(sourceIp, 'node/update:' + payload);
|
||||||
|
},
|
||||||
|
'raw:': () => {
|
||||||
|
// Ignore raw messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Look up and execute handler
|
||||||
|
const handler = handlers[topic];
|
||||||
|
if (handler) {
|
||||||
|
handler(payload, sourceIp, sourcePort);
|
||||||
|
} else {
|
||||||
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
|
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -919,7 +939,7 @@ function broadcastClusterUpdate() {
|
|||||||
// Get cluster members asynchronously
|
// Get cluster members asynchronously
|
||||||
getCurrentClusterMembers().then(members => {
|
getCurrentClusterMembers().then(members => {
|
||||||
const clusterData = {
|
const clusterData = {
|
||||||
type: 'cluster_update',
|
topic: 'cluster/update',
|
||||||
members: members,
|
members: members,
|
||||||
primaryNode: primaryNodeIp,
|
primaryNode: primaryNodeIp,
|
||||||
totalNodes: discoveredNodes.size,
|
totalNodes: discoveredNodes.size,
|
||||||
@@ -946,7 +966,7 @@ function broadcastNodeDiscovery(nodeIp, action) {
|
|||||||
if (wsClients.size === 0 || !wss) return;
|
if (wsClients.size === 0 || !wss) return;
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
type: 'node_discovery',
|
topic: 'node/discovery',
|
||||||
action: action, // 'discovered' or 'stale'
|
action: action, // 'discovered' or 'stale'
|
||||||
nodeIp: nodeIp,
|
nodeIp: nodeIp,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
@@ -1122,7 +1142,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
|||||||
initializeWebSocketServer(server);
|
initializeWebSocketServer(server);
|
||||||
console.log('WebSocket server ready for real-time updates');
|
console.log('WebSocket server ready for real-time updates');
|
||||||
|
|
||||||
console.log('Waiting for CLUSTER_HEARTBEAT and NODE_UPDATE messages from SPORE nodes...');
|
console.log('Waiting for cluster/heartbeat and node/update messages from SPORE nodes...');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown handling
|
// Graceful shutdown handling
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Topology
|
Topology
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/events" class="nav-tab" data-view="events">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||||
|
<circle cx="5" cy="12" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="18" cy="6" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="18" cy="12" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="18" cy="18" r="1.5" fill="currentColor"/>
|
||||||
|
<line x1="6.5" y1="12" x2="16.5" y2="6"/>
|
||||||
|
<line x1="6.5" y1="12" x2="16.5" y2="12"/>
|
||||||
|
<line x1="6.5" y1="12" x2="16.5" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
Events
|
||||||
|
</a>
|
||||||
<a href="/monitoring" class="nav-tab" data-view="monitoring">
|
<a href="/monitoring" class="nav-tab" data-view="monitoring">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||||
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
|
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
|
||||||
@@ -238,6 +250,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="events-view" class="view-content">
|
||||||
|
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||||
|
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||||
|
<div class="loading">
|
||||||
|
<div>Waiting for websocket events...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="./vendor/d3.v7.min.js"></script>
|
<script src="./vendor/d3.v7.min.js"></script>
|
||||||
@@ -264,6 +286,7 @@
|
|||||||
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
||||||
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
<script src="./scripts/components/TopologyGraphComponent.js"></script>
|
||||||
<script src="./scripts/components/MonitoringViewComponent.js"></script>
|
<script src="./scripts/components/MonitoringViewComponent.js"></script>
|
||||||
|
<script src="./scripts/components/EventComponent.js"></script>
|
||||||
<script src="./scripts/components/ComponentsLoader.js"></script>
|
<script src="./scripts/components/ComponentsLoader.js"></script>
|
||||||
<script src="./scripts/theme-manager.js"></script>
|
<script src="./scripts/theme-manager.js"></script>
|
||||||
<script src="./scripts/app.js"></script>
|
<script src="./scripts/app.js"></script>
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ class WebSocketClient {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
logger.debug('WebSocket message received:', data);
|
logger.debug('WebSocket message received:', data);
|
||||||
logger.debug('WebSocket message type:', data.type);
|
const messageTopic = data.topic || data.type;
|
||||||
|
logger.debug('WebSocket message topic:', messageTopic);
|
||||||
this.emit('message', data);
|
this.emit('message', data);
|
||||||
this.handleMessage(data);
|
this.handleMessage(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -288,21 +289,21 @@ class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(data) {
|
handleMessage(data) {
|
||||||
switch (data.type) {
|
const messageTopic = data.topic || data.type;
|
||||||
case 'cluster_update':
|
|
||||||
this.emit('clusterUpdate', data);
|
// Handler map for different WebSocket message types
|
||||||
break;
|
const handlers = {
|
||||||
case 'node_discovery':
|
'cluster/update': (data) => this.emit('clusterUpdate', data),
|
||||||
this.emit('nodeDiscovery', data);
|
'node/discovery': (data) => this.emit('nodeDiscovery', data),
|
||||||
break;
|
'firmware/upload/status': (data) => this.emit('firmwareUploadStatus', data),
|
||||||
case 'firmware_upload_status':
|
'rollout/progress': (data) => this.emit('rolloutProgress', data)
|
||||||
this.emit('firmwareUploadStatus', data);
|
};
|
||||||
break;
|
|
||||||
case 'rollout_progress':
|
const handler = handlers[messageTopic];
|
||||||
this.emit('rolloutProgress', data);
|
if (handler) {
|
||||||
break;
|
handler(data);
|
||||||
default:
|
} else {
|
||||||
logger.debug('Unknown WebSocket message type:', data.type);
|
logger.debug('Unknown WebSocket message topic:', messageTopic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
|
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
|
||||||
const topologyViewModel = new TopologyViewModel();
|
const topologyViewModel = new TopologyViewModel();
|
||||||
const monitoringViewModel = new MonitoringViewModel();
|
const monitoringViewModel = new MonitoringViewModel();
|
||||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel });
|
const eventsViewModel = new EventViewModel();
|
||||||
|
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel, eventsViewModel });
|
||||||
|
|
||||||
// Connect firmware view model to cluster data
|
// Connect firmware view model to cluster data
|
||||||
clusterViewModel.subscribe('members', (members) => {
|
clusterViewModel.subscribe('members', (members) => {
|
||||||
@@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||||
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
|
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
|
||||||
|
app.registerRoute('events', EventComponent, 'events-view', eventsViewModel);
|
||||||
logger.debug('App: Routes registered and components pre-initialized');
|
logger.debug('App: Routes registered and components pre-initialized');
|
||||||
|
|
||||||
// Initialize cluster status component for header badge
|
// Initialize cluster status component for header badge
|
||||||
|
|||||||
1128
public/scripts/components/EventComponent.js
Normal file
1128
public/scripts/components/EventComponent.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,12 +99,10 @@ class TopologyGraphComponent extends Component {
|
|||||||
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
.map(([k, v]) => `<span class=\"label-chip\">${this.escapeHtml(String(k))}: ${this.escapeHtml(String(v))}</span>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
// Add hint for non-primary nodes
|
// Add hint for node interactions
|
||||||
let hint = '';
|
let hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 255, 255, 0.7); text-align: center;">💡 Click to set as API Node & toggle view</div>';
|
||||||
if (nodeData && !nodeData.isPrimary) {
|
if (nodeData && nodeData.isPrimary) {
|
||||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 255, 255, 0.7); text-align: center;">💡 Click to switch to primary & view details</div>';
|
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 215, 0, 0.9); text-align: center;">⭐ API Node - Click to toggle view</div>';
|
||||||
} else if (nodeData && nodeData.isPrimary) {
|
|
||||||
hint = '<div style="margin-top: 8px; font-size: 11px; color: rgba(255, 215, 0, 0.9); text-align: center;">⭐ Primary Node - Click to view details</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>${hint}`;
|
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>${hint}`;
|
||||||
@@ -622,30 +620,19 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||||
|
|
||||||
// Status indicator
|
// Status indicator - removed
|
||||||
nodeEnter.append('circle')
|
|
||||||
.attr('r', 3)
|
|
||||||
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
|
||||||
.attr('cx', -8)
|
|
||||||
.attr('cy', -8);
|
|
||||||
|
|
||||||
// Primary node badge
|
// Primary node badge
|
||||||
const primaryBadge = nodeEnter.filter(d => d.isPrimary)
|
const primaryBadge = nodeEnter.filter(d => d.isPrimary)
|
||||||
.append('g')
|
.append('g')
|
||||||
.attr('class', 'primary-badge')
|
.attr('class', 'primary-badge')
|
||||||
.attr('transform', 'translate(8, -8)');
|
.attr('transform', 'translate(0, 0)');
|
||||||
|
|
||||||
primaryBadge.append('circle')
|
|
||||||
.attr('r', 8)
|
|
||||||
.attr('fill', '#FFD700')
|
|
||||||
.attr('stroke', '#FFF')
|
|
||||||
.attr('stroke-width', 1);
|
|
||||||
|
|
||||||
primaryBadge.append('text')
|
primaryBadge.append('text')
|
||||||
.text('P')
|
.text('A')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'central')
|
.attr('dominant-baseline', 'central')
|
||||||
.attr('font-size', '10px')
|
.attr('font-size', '12px')
|
||||||
.attr('font-weight', 'bold')
|
.attr('font-weight', 'bold')
|
||||||
.attr('fill', '#000');
|
.attr('fill', '#000');
|
||||||
|
|
||||||
@@ -701,10 +688,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
||||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
||||||
|
|
||||||
nodeMerge.select('circle:nth-child(2)')
|
// Update status indicator - removed
|
||||||
.transition()
|
|
||||||
.duration(300)
|
|
||||||
.attr('fill', d => this.getStatusIndicatorColor(d.status));
|
|
||||||
|
|
||||||
nodeMerge.select('.hostname-text')
|
nodeMerge.select('.hostname-text')
|
||||||
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname);
|
.text(d => d.hostname.length > 15 ? d.hostname.substring(0, 15) + '...' : d.hostname);
|
||||||
@@ -735,19 +719,13 @@ class TopologyGraphComponent extends Component {
|
|||||||
if (nodeGroup.select('.primary-badge').empty()) {
|
if (nodeGroup.select('.primary-badge').empty()) {
|
||||||
const badge = nodeGroup.append('g')
|
const badge = nodeGroup.append('g')
|
||||||
.attr('class', 'primary-badge')
|
.attr('class', 'primary-badge')
|
||||||
.attr('transform', 'translate(8, -8)');
|
.attr('transform', 'translate(0, 0)');
|
||||||
|
|
||||||
badge.append('circle')
|
|
||||||
.attr('r', 8)
|
|
||||||
.attr('fill', '#FFD700')
|
|
||||||
.attr('stroke', '#FFF')
|
|
||||||
.attr('stroke-width', 1);
|
|
||||||
|
|
||||||
badge.append('text')
|
badge.append('text')
|
||||||
.text('P')
|
.text('A')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'central')
|
.attr('dominant-baseline', 'central')
|
||||||
.attr('font-size', '10px')
|
.attr('font-size', '12px')
|
||||||
.attr('font-weight', 'bold')
|
.attr('font-weight', 'bold')
|
||||||
.attr('fill', '#000');
|
.attr('fill', '#000');
|
||||||
}
|
}
|
||||||
@@ -769,59 +747,39 @@ class TopologyGraphComponent extends Component {
|
|||||||
.on('click', async (event, d) => {
|
.on('click', async (event, d) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// Always open drawer/details for the clicked node
|
// Check if we're toggling back to mesh (clicking same center node in star mode)
|
||||||
this.viewModel.selectNode(d.id);
|
const currentMode = this.viewModel.get('topologyMode');
|
||||||
this.updateSelection(d.id);
|
const currentCenter = this.viewModel.get('starCenterNode');
|
||||||
if (this.isDesktop()) {
|
const togglingToMesh = currentMode === 'star' && currentCenter === d.ip;
|
||||||
this.openDrawerForNode(d);
|
|
||||||
} else {
|
|
||||||
this.showMemberCardOverlay(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If clicking on non-primary node, also switch it to primary
|
// Toggle topology mode - switch between mesh and star
|
||||||
|
await this.viewModel.toggleTopologyMode(d.ip);
|
||||||
|
|
||||||
|
// Also switch to this node as primary (if not already)
|
||||||
if (!d.isPrimary) {
|
if (!d.isPrimary) {
|
||||||
try {
|
try {
|
||||||
// Visual feedback - highlight the clicked node
|
|
||||||
d3.select(event.currentTarget).select('circle')
|
|
||||||
.transition()
|
|
||||||
.duration(200)
|
|
||||||
.attr('stroke-width', 4)
|
|
||||||
.attr('stroke', '#FFD700');
|
|
||||||
|
|
||||||
logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`);
|
logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`);
|
||||||
|
|
||||||
// Switch to this node as primary
|
|
||||||
await this.viewModel.switchToPrimaryNode(d.ip);
|
await this.viewModel.switchToPrimaryNode(d.ip);
|
||||||
|
|
||||||
// Show success feedback briefly
|
|
||||||
this.showTooltip({
|
|
||||||
...d,
|
|
||||||
hostname: `✓ Switched to ${d.hostname}`
|
|
||||||
}, event.pageX, event.pageY);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.hideTooltip();
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('TopologyGraphComponent: Failed to switch primary:', error);
|
logger.error('TopologyGraphComponent: Failed to switch primary:', error);
|
||||||
|
}
|
||||||
// Show error feedback
|
}
|
||||||
this.showTooltip({
|
|
||||||
...d,
|
// Open or close drawer based on mode toggle
|
||||||
hostname: `✗ Failed: ${error.message}`
|
if (togglingToMesh) {
|
||||||
}, event.pageX, event.pageY);
|
// Switching back to mesh - close drawer
|
||||||
|
this.viewModel.clearSelection();
|
||||||
setTimeout(() => {
|
if (this.isDesktop()) {
|
||||||
this.hideTooltip();
|
this.closeDrawer();
|
||||||
}, 3000);
|
}
|
||||||
|
} else {
|
||||||
// Revert visual feedback
|
// Switching to star or changing center - open drawer
|
||||||
d3.select(event.currentTarget).select('circle')
|
this.viewModel.selectNode(d.id);
|
||||||
.transition()
|
this.updateSelection(d.id);
|
||||||
.duration(200)
|
if (this.isDesktop()) {
|
||||||
.attr('stroke-width', 2)
|
this.openDrawerForNode(d);
|
||||||
.attr('stroke', '#fff');
|
} else {
|
||||||
|
this.showMemberCardOverlay(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -843,6 +801,20 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateLinkLabels(svgGroup, links) {
|
updateLinkLabels(svgGroup, links) {
|
||||||
|
const topologyMode = this.viewModel.get('topologyMode') || 'mesh';
|
||||||
|
|
||||||
|
// Only show latency labels in star mode
|
||||||
|
if (topologyMode !== 'star') {
|
||||||
|
// Clear any existing labels in mesh mode
|
||||||
|
const linkLabelGroup = svgGroup.select('.link-label-group');
|
||||||
|
if (!linkLabelGroup.empty()) {
|
||||||
|
linkLabelGroup.selectAll('text').remove();
|
||||||
|
}
|
||||||
|
this.linkLabelSelection = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Star mode - show latency labels
|
||||||
// Get or create link label group
|
// Get or create link label group
|
||||||
let linkLabelGroup = svgGroup.select('.link-label-group');
|
let linkLabelGroup = svgGroup.select('.link-label-group');
|
||||||
if (linkLabelGroup.empty()) {
|
if (linkLabelGroup.empty()) {
|
||||||
@@ -1005,10 +977,8 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addLegend(svgGroup) {
|
addLegend(svgGroup) {
|
||||||
// Only add legend if it doesn't exist
|
// Remove existing legend to recreate with current mode
|
||||||
if (!svgGroup.select('.legend-group').empty()) {
|
svgGroup.select('.legend-group').remove();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const legend = svgGroup.append('g')
|
const legend = svgGroup.append('g')
|
||||||
.attr('class', 'legend-group graph-element')
|
.attr('class', 'legend-group graph-element')
|
||||||
@@ -1111,7 +1081,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('stroke-width', 1);
|
.attr('stroke-width', 1);
|
||||||
|
|
||||||
primaryBadge.append('text')
|
primaryBadge.append('text')
|
||||||
.text('P')
|
.text('A')
|
||||||
.attr('text-anchor', 'middle')
|
.attr('text-anchor', 'middle')
|
||||||
.attr('dominant-baseline', 'central')
|
.attr('dominant-baseline', 'central')
|
||||||
.attr('font-size', '8px')
|
.attr('font-size', '8px')
|
||||||
@@ -1125,16 +1095,20 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('fill', '#ecf0f1');
|
.attr('fill', '#ecf0f1');
|
||||||
|
|
||||||
// Star topology info
|
// Topology mode info
|
||||||
|
const topologyMode = this.viewModel.get('topologyMode') || 'mesh';
|
||||||
|
const modeText = topologyMode === 'mesh' ? 'Full Mesh' : 'Star Topology';
|
||||||
|
const modeDesc = topologyMode === 'mesh' ? '(All to All)' : '(Center to Others)';
|
||||||
|
|
||||||
topologyLegend.append('text')
|
topologyLegend.append('text')
|
||||||
.text('Star Topology')
|
.text(modeText)
|
||||||
.attr('x', 0)
|
.attr('x', 0)
|
||||||
.attr('y', 50)
|
.attr('y', 50)
|
||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('fill', '#ecf0f1');
|
.attr('fill', '#ecf0f1');
|
||||||
|
|
||||||
topologyLegend.append('text')
|
topologyLegend.append('text')
|
||||||
.text('(Primary to Members)')
|
.text(modeDesc)
|
||||||
.attr('x', 0)
|
.attr('x', 0)
|
||||||
.attr('y', 65)
|
.attr('y', 65)
|
||||||
.attr('font-size', '10px')
|
.attr('font-size', '10px')
|
||||||
@@ -1288,7 +1262,9 @@ class TopologyGraphComponent extends Component {
|
|||||||
// Get or create animation group inside the main transformed group
|
// Get or create animation group inside the main transformed group
|
||||||
let animGroup = mainGroup.select('.discovery-animation-group');
|
let animGroup = mainGroup.select('.discovery-animation-group');
|
||||||
if (animGroup.empty()) {
|
if (animGroup.empty()) {
|
||||||
animGroup = mainGroup.append('g').attr('class', 'discovery-animation-group');
|
animGroup = mainGroup.append('g')
|
||||||
|
.attr('class', 'discovery-animation-group')
|
||||||
|
.style('pointer-events', 'none'); // Make entire animation group non-interactive
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit a dot to each other node
|
// Emit a dot to each other node
|
||||||
@@ -1300,20 +1276,38 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animateDiscoveryDot(animGroup, sourceNode, targetNode) {
|
animateDiscoveryDot(animGroup, sourceNode, targetNode) {
|
||||||
// Create a small circle at the source node
|
// Calculate spawn position outside the node boundary
|
||||||
|
const nodeRadius = this.getNodeRadius(sourceNode.status);
|
||||||
|
const spawnDistance = nodeRadius + 8; // Spawn 8px outside the node
|
||||||
|
|
||||||
|
// Calculate direction from source to target
|
||||||
|
const dx = targetNode.x - sourceNode.x;
|
||||||
|
const dy = targetNode.y - sourceNode.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Normalize direction and spawn outside the node
|
||||||
|
const normalizedX = dx / distance;
|
||||||
|
const normalizedY = dy / distance;
|
||||||
|
const spawnX = sourceNode.x + normalizedX * spawnDistance;
|
||||||
|
const spawnY = sourceNode.y + normalizedY * spawnDistance;
|
||||||
|
|
||||||
|
// Create a small circle outside the source node
|
||||||
const dot = animGroup.append('circle')
|
const dot = animGroup.append('circle')
|
||||||
.attr('class', 'discovery-dot')
|
.attr('class', 'discovery-dot')
|
||||||
.attr('r', 6)
|
.attr('r', 6)
|
||||||
.attr('cx', sourceNode.x)
|
.attr('cx', spawnX)
|
||||||
.attr('cy', sourceNode.y)
|
.attr('cy', spawnY)
|
||||||
.attr('fill', '#FFD700')
|
.attr('fill', '#FFD700')
|
||||||
.attr('opacity', 1)
|
.attr('opacity', 1)
|
||||||
.attr('stroke', '#FFF')
|
.attr('stroke', '#FFF')
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', 2)
|
||||||
|
.style('pointer-events', 'none'); // Make dots non-interactive
|
||||||
|
|
||||||
// Trigger response dot early (after 70% of the journey)
|
// Trigger response dot early (after 70% of the journey) - only if target node is active
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.animateResponseDot(animGroup, targetNode, sourceNode);
|
if (targetNode.status && targetNode.status.toUpperCase() === 'ACTIVE') {
|
||||||
|
this.animateResponseDot(animGroup, targetNode, sourceNode);
|
||||||
|
}
|
||||||
}, 1050); // 1500ms * 0.7 = 1050ms
|
}, 1050); // 1500ms * 0.7 = 1050ms
|
||||||
|
|
||||||
// Animate the dot to the target node
|
// Animate the dot to the target node
|
||||||
@@ -1327,23 +1321,51 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animateResponseDot(animGroup, sourceNode, targetNode) {
|
animateResponseDot(animGroup, sourceNode, targetNode) {
|
||||||
// Create a response dot at the target (now source) node
|
// Calculate spawn position outside the target node boundary
|
||||||
|
const nodeRadius = this.getNodeRadius(sourceNode.status);
|
||||||
|
const spawnDistance = nodeRadius + 8; // Spawn 8px outside the node
|
||||||
|
|
||||||
|
// Calculate direction from target back to original source
|
||||||
|
const dx = targetNode.x - sourceNode.x;
|
||||||
|
const dy = targetNode.y - sourceNode.y;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
|
// Normalize direction and spawn outside the node
|
||||||
|
const normalizedX = dx / distance;
|
||||||
|
const normalizedY = dy / distance;
|
||||||
|
const spawnX = sourceNode.x + normalizedX * spawnDistance;
|
||||||
|
const spawnY = sourceNode.y + normalizedY * spawnDistance;
|
||||||
|
|
||||||
|
// Calculate target position outside the original source node
|
||||||
|
const targetNodeRadius = this.getNodeRadius(targetNode.status);
|
||||||
|
const targetDistance = targetNodeRadius + 8; // Stop 8px outside the target node
|
||||||
|
|
||||||
|
// Calculate direction from source back to target
|
||||||
|
const targetDx = sourceNode.x - targetNode.x;
|
||||||
|
const targetDy = sourceNode.y - targetNode.y;
|
||||||
|
const targetNormalizedX = targetDx / distance;
|
||||||
|
const targetNormalizedY = targetDy / distance;
|
||||||
|
const targetX = targetNode.x + targetNormalizedX * targetDistance;
|
||||||
|
const targetY = targetNode.y + targetNormalizedY * targetDistance;
|
||||||
|
|
||||||
|
// Create a response dot outside the target (now source) node
|
||||||
const responseDot = animGroup.append('circle')
|
const responseDot = animGroup.append('circle')
|
||||||
.attr('class', 'discovery-dot-response')
|
.attr('class', 'discovery-dot-response')
|
||||||
.attr('r', 5)
|
.attr('r', 5)
|
||||||
.attr('cx', sourceNode.x)
|
.attr('cx', spawnX)
|
||||||
.attr('cy', sourceNode.y)
|
.attr('cy', spawnY)
|
||||||
.attr('fill', '#4CAF50')
|
.attr('fill', '#2196F3')
|
||||||
.attr('opacity', 1)
|
.attr('opacity', 1)
|
||||||
.attr('stroke', '#FFF')
|
.attr('stroke', '#FFF')
|
||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', 2)
|
||||||
|
.style('pointer-events', 'none'); // Make dots non-interactive
|
||||||
|
|
||||||
// Animate back to the original source
|
// Animate back to outside the original source node
|
||||||
responseDot.transition()
|
responseDot.transition()
|
||||||
.duration(1000)
|
.duration(1000)
|
||||||
.ease(d3.easeCubicInOut)
|
.ease(d3.easeCubicInOut)
|
||||||
.attr('cx', targetNode.x)
|
.attr('cx', targetX)
|
||||||
.attr('cy', targetNode.y)
|
.attr('cy', targetY)
|
||||||
.attr('opacity', 0)
|
.attr('opacity', 0)
|
||||||
.remove();
|
.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -584,7 +584,9 @@ class TopologyViewModel extends ViewModel {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
lastUpdateTime: null,
|
lastUpdateTime: null,
|
||||||
selectedNode: null
|
selectedNode: null,
|
||||||
|
topologyMode: 'mesh', // 'mesh' or 'star'
|
||||||
|
starCenterNode: null // IP of the center node in star mode
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +694,7 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build enhanced graph data with actual node connections
|
// Build enhanced graph data with actual node connections
|
||||||
// Creates a star topology with the primary node at the center
|
// Creates either a mesh topology (all nodes connected) or star topology (center node to all others)
|
||||||
async buildEnhancedGraphData(members, primaryNode) {
|
async buildEnhancedGraphData(members, primaryNode) {
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const links = [];
|
const links = [];
|
||||||
@@ -730,32 +732,53 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build links - create a star topology with primary node at center
|
// Build links based on topology mode
|
||||||
// Only create links from the primary node to each member
|
const topologyMode = this.get('topologyMode') || 'mesh';
|
||||||
// The cluster data comes from the primary, so it only knows about its direct connections
|
|
||||||
if (primaryNode) {
|
if (topologyMode === 'mesh') {
|
||||||
logger.debug(`TopologyViewModel: Creating star topology with primary ${primaryNode}`);
|
// Full mesh - connect all nodes to all other nodes
|
||||||
nodes.forEach(node => {
|
logger.debug('TopologyViewModel: Creating full mesh topology');
|
||||||
// Create a link from primary to each non-primary node
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
if (node.ip !== primaryNode) {
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
const member = members.find(m => m.ip === node.ip);
|
const sourceNode = nodes[i];
|
||||||
const latency = member?.latency || this.estimateLatency(node, { ip: primaryNode });
|
const targetNode = nodes[j];
|
||||||
|
|
||||||
logger.debug(`TopologyViewModel: Creating link from ${primaryNode} to ${node.ip} (latency: ${latency}ms)`);
|
|
||||||
|
|
||||||
links.push({
|
links.push({
|
||||||
source: primaryNode,
|
source: sourceNode.id,
|
||||||
target: node.id,
|
target: targetNode.id,
|
||||||
latency: latency,
|
latency: this.estimateLatency(sourceNode, targetNode),
|
||||||
sourceNode: nodes.find(n => n.ip === primaryNode),
|
sourceNode: sourceNode,
|
||||||
targetNode: node,
|
targetNode: targetNode,
|
||||||
bidirectional: false // Primary -> Member is directional
|
bidirectional: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
logger.debug(`TopologyViewModel: Created ${links.length} links from primary node`);
|
logger.debug(`TopologyViewModel: Created ${links.length} links in mesh topology`);
|
||||||
} else {
|
} else if (topologyMode === 'star') {
|
||||||
logger.warn('TopologyViewModel: No primary node specified, cannot create links');
|
// Star topology - center node connects to all others
|
||||||
|
const centerNode = this.get('starCenterNode') || primaryNode;
|
||||||
|
|
||||||
|
if (centerNode) {
|
||||||
|
logger.debug(`TopologyViewModel: Creating star topology with center ${centerNode}`);
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.ip !== centerNode) {
|
||||||
|
const member = members.find(m => m.ip === node.ip);
|
||||||
|
const latency = member?.latency || this.estimateLatency(node, { ip: centerNode });
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
source: centerNode,
|
||||||
|
target: node.id,
|
||||||
|
latency: latency,
|
||||||
|
sourceNode: nodes.find(n => n.ip === centerNode),
|
||||||
|
targetNode: node,
|
||||||
|
bidirectional: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logger.debug(`TopologyViewModel: Created ${links.length} links from center node`);
|
||||||
|
} else {
|
||||||
|
logger.warn('TopologyViewModel: No center node specified for star topology');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, links };
|
return { nodes, links };
|
||||||
@@ -803,6 +826,36 @@ class TopologyViewModel extends ViewModel {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle topology mode or set star mode with specific center node
|
||||||
|
async setTopologyMode(mode, centerNodeIp = null) {
|
||||||
|
logger.debug(`TopologyViewModel: Setting topology mode to ${mode}`, centerNodeIp ? `with center ${centerNodeIp}` : '');
|
||||||
|
|
||||||
|
this.setMultiple({
|
||||||
|
topologyMode: mode,
|
||||||
|
starCenterNode: centerNodeIp
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rebuild the graph with new topology
|
||||||
|
await this.updateNetworkTopology();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle between mesh and star modes
|
||||||
|
async toggleTopologyMode(nodeIp) {
|
||||||
|
const currentMode = this.get('topologyMode');
|
||||||
|
const currentCenter = this.get('starCenterNode');
|
||||||
|
|
||||||
|
if (currentMode === 'mesh') {
|
||||||
|
// Switch to star mode with this node as center
|
||||||
|
await this.setTopologyMode('star', nodeIp);
|
||||||
|
} else if (currentMode === 'star' && currentCenter === nodeIp) {
|
||||||
|
// Clicking same center node - switch back to mesh
|
||||||
|
await this.setTopologyMode('mesh', null);
|
||||||
|
} else {
|
||||||
|
// Clicking different node - change star center
|
||||||
|
await this.setTopologyMode('star', nodeIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitoring View Model for cluster resource monitoring
|
// Monitoring View Model for cluster resource monitoring
|
||||||
@@ -1182,4 +1235,200 @@ class WiFiConfigViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.WiFiConfigViewModel = WiFiConfigViewModel;
|
window.WiFiConfigViewModel = WiFiConfigViewModel;
|
||||||
|
|
||||||
|
// Events View Model for websocket event visualization
|
||||||
|
class EventViewModel extends ViewModel {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setMultiple({
|
||||||
|
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||||
|
allMessages: [], // Array of all messages for live viewing
|
||||||
|
allMessagesCounter: 0, // Counter to trigger updates
|
||||||
|
selectedTopic: null, // Currently selected topic for drawer
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
lastUpdateTime: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up WebSocket listeners for real-time updates
|
||||||
|
this.setupWebSocketListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up WebSocket event listeners
|
||||||
|
setupWebSocketListeners() {
|
||||||
|
if (!window.wsClient) {
|
||||||
|
// Retry after a short delay to allow wsClient to initialize
|
||||||
|
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for all websocket messages
|
||||||
|
window.wsClient.on('message', (data) => {
|
||||||
|
const topic = data.topic || data.type;
|
||||||
|
|
||||||
|
if (topic) {
|
||||||
|
// Store the full message
|
||||||
|
const allMessages = this.get('allMessages') || [];
|
||||||
|
const newMessage = {
|
||||||
|
...data,
|
||||||
|
topic: topic,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
id: `${topic}-${Date.now()}-${Math.random()}`
|
||||||
|
};
|
||||||
|
allMessages.push(newMessage);
|
||||||
|
|
||||||
|
// Keep only the last 1000 messages to prevent memory issues
|
||||||
|
const maxMessages = 1000;
|
||||||
|
if (allMessages.length > maxMessages) {
|
||||||
|
allMessages.splice(0, allMessages.length - maxMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update messages and trigger change notification
|
||||||
|
this.set('allMessages', allMessages);
|
||||||
|
this.set('allMessagesCounter', this.get('allMessagesCounter') + 1);
|
||||||
|
|
||||||
|
this.addTopic(topic, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for connection status changes
|
||||||
|
window.wsClient.on('connected', () => {
|
||||||
|
logger.info('EventViewModel: WebSocket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.wsClient.on('disconnected', () => {
|
||||||
|
logger.debug('EventViewModel: WebSocket disconnected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get messages for a specific topic
|
||||||
|
getMessagesForTopic(topic) {
|
||||||
|
const allMessages = this.get('allMessages') || [];
|
||||||
|
return allMessages.filter(msg => {
|
||||||
|
// Handle nested events from cluster/event
|
||||||
|
if (msg.topic === 'cluster/event' && msg.data) {
|
||||||
|
try {
|
||||||
|
const parsedData = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
|
||||||
|
if (parsedData && parsedData.event) {
|
||||||
|
const fullTopic = `${msg.topic}/${parsedData.event}`;
|
||||||
|
return fullTopic === topic;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, just use the original topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msg.topic === topic;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the selected topic for drawer
|
||||||
|
setSelectedTopic(topic) {
|
||||||
|
this.set('selectedTopic', topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear selected topic
|
||||||
|
clearSelectedTopic() {
|
||||||
|
this.set('selectedTopic', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a topic (parsed by "/" separator)
|
||||||
|
addTopic(topic, data = null) {
|
||||||
|
// Get current events as a new Map to ensure change detection
|
||||||
|
const events = new Map(this.get('events'));
|
||||||
|
|
||||||
|
// Handle nested events from cluster/event
|
||||||
|
let fullTopic = topic;
|
||||||
|
if (topic === 'cluster/event' && data && data.data) {
|
||||||
|
try {
|
||||||
|
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
||||||
|
if (parsedData && parsedData.event) {
|
||||||
|
// Create nested topic chain: cluster/event/api/neopattern
|
||||||
|
fullTopic = `${topic}/${parsedData.event}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If parsing fails, just use the original topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = fullTopic.split('/').filter(p => p);
|
||||||
|
|
||||||
|
if (events.has(fullTopic)) {
|
||||||
|
// Update existing event - create new object to ensure change detection
|
||||||
|
const existing = events.get(fullTopic);
|
||||||
|
events.set(fullTopic, {
|
||||||
|
topic: existing.topic,
|
||||||
|
parts: existing.parts,
|
||||||
|
count: existing.count + 1,
|
||||||
|
firstSeen: existing.firstSeen,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
lastData: data
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new event entry
|
||||||
|
events.set(fullTopic, {
|
||||||
|
topic: fullTopic,
|
||||||
|
parts: parts,
|
||||||
|
count: 1,
|
||||||
|
firstSeen: new Date().toISOString(),
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
lastData: data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use set to trigger change notification
|
||||||
|
this.set('events', events);
|
||||||
|
this.set('lastUpdateTime', new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all topic parts (unique segments after splitting by "/")
|
||||||
|
getTopicParts() {
|
||||||
|
const events = this.get('events');
|
||||||
|
const allParts = new Set();
|
||||||
|
|
||||||
|
for (const [topic, data] of events) {
|
||||||
|
data.parts.forEach(part => allParts.add(part));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(allParts).map(part => {
|
||||||
|
// Count how many events contain this part
|
||||||
|
let count = 0;
|
||||||
|
for (const [topic, data] of events) {
|
||||||
|
if (data.parts.includes(part)) {
|
||||||
|
count += data.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { part, count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all events
|
||||||
|
clearTopics() {
|
||||||
|
this.set('events', new Map());
|
||||||
|
this.set('lastUpdateTime', new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connections between topic parts (for graph edges)
|
||||||
|
getTopicConnections() {
|
||||||
|
const topics = this.get('topics');
|
||||||
|
const connections = [];
|
||||||
|
|
||||||
|
for (const [topic, data] of topics) {
|
||||||
|
const parts = data.parts;
|
||||||
|
// Create connections between adjacent parts
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
connections.push({
|
||||||
|
source: parts[i],
|
||||||
|
target: parts[i + 1],
|
||||||
|
topic: topic,
|
||||||
|
count: data.count
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.EventViewModel = EventViewModel;
|
||||||
@@ -3,6 +3,127 @@
|
|||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
transform-origin: 50% 50%;
|
transform-origin: 50% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
MESSAGE LIST STYLES FOR EVENTS DRAWER
|
||||||
|
======================================== */
|
||||||
|
|
||||||
|
.topic-messages-container {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-header-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list-header-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-code {
|
||||||
|
background: var(--card-background);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--card-background, rgba(30, 30, 30, 0.5));
|
||||||
|
border-left: 3px solid var(--primary-color, #3498db);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-id {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-multi-indicator {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message animations - only for new messages */
|
||||||
|
.message-item-new {
|
||||||
|
animation: messageSlideIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes messageSlideIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-15px) scale(0.98);
|
||||||
|
max-height: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
max-height: 1000px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node highlight animation - only for selected nodes */
|
||||||
|
.node-highlight.highlighted {
|
||||||
|
animation: nodePulse 2s ease-in-out infinite;
|
||||||
|
filter: drop-shadow(0 0 3px rgba(255, 215, 0, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes nodePulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -559,11 +680,12 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Topology graph node interactions */
|
/* Topology graph node interactions */
|
||||||
#topology-graph-container .node {
|
#topology-graph-container .node, #events-graph-container .node {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container .node:hover circle:first-child {
|
#topology-graph-container .node:hover circle:first-child,
|
||||||
|
#events-graph-container .node:hover circle:first-child {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4335,7 +4457,7 @@ select.param-input:focus {
|
|||||||
width: 100%; /* Use full container width */
|
width: 100%; /* Use full container width */
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container {
|
#topology-graph-container, #events-graph-container {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -4351,24 +4473,27 @@ select.param-input:focus {
|
|||||||
max-height: 100%; /* Ensure it doesn't exceed parent height */
|
max-height: 100%; /* Ensure it doesn't exceed parent height */
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container svg {
|
#topology-graph-container svg, #events-graph-container svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container .loading,
|
#topology-graph-container .loading,
|
||||||
#topology-graph-container .error,
|
#topology-graph-container .error,
|
||||||
#topology-graph-container .no-data {
|
#topology-graph-container .no-data,
|
||||||
|
#events-graph-container .loading,
|
||||||
|
#events-graph-container .error,
|
||||||
|
#events-graph-container .no-data {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container .error {
|
#topology-graph-container .error, #events-graph-container .error {
|
||||||
color: var(--accent-error);
|
color: var(--accent-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container .no-data {
|
#topology-graph-container .no-data, #events-graph-container .no-data {
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user