Compare commits
1 Commits
main
...
936679f274
| Author | SHA1 | Date | |
|---|---|---|---|
| 936679f274 |
@@ -1,11 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.cursor
|
|
||||||
*.md
|
|
||||||
node_modules
|
|
||||||
README.md
|
|
||||||
docs
|
|
||||||
test
|
|
||||||
openapitools.json
|
|
||||||
*.backup
|
|
||||||
|
|
||||||
41
Dockerfile
41
Dockerfile
@@ -1,41 +0,0 @@
|
|||||||
# 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
54
Makefile
@@ -1,54 +0,0 @@
|
|||||||
.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,9 +23,6 @@ This frontend server works together with the **SPORE Gateway** (spore-gateway) b
|
|||||||

|

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

|

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

|
|
||||||

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

|

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

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Real-Time Event Visualization
|
|
||||||
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
|
|
||||||
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
|
|
||||||
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
|
|
||||||
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
|
|
||||||
|
|
||||||
### User Interactions
|
|
||||||
|
|
||||||
#### Drag to Reposition Nodes
|
|
||||||
- **Center Node**: Dragging the center (blue) node moves the entire graph together
|
|
||||||
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
|
|
||||||
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
|
|
||||||
|
|
||||||
#### Zoom Controls
|
|
||||||
- **Mouse Wheel**: Zoom in and out using the mouse wheel
|
|
||||||
- **Scale Range**: Zoom level constrained between 0.5x and 5x
|
|
||||||
- **Pan**: Click and drag the canvas to pan around the graph
|
|
||||||
|
|
||||||
#### Rearrange Layout
|
|
||||||
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
|
|
||||||
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
|
|
||||||
|
|
||||||
### Visual Elements
|
|
||||||
|
|
||||||
#### Node Types
|
|
||||||
- **Center Node** (Blue):
|
|
||||||
- Represents the central event hub
|
|
||||||
- Always visible in the graph
|
|
||||||
- Acts as the root node for all event chains
|
|
||||||
- Larger size (18px radius)
|
|
||||||
|
|
||||||
- **Topic Nodes** (Green):
|
|
||||||
- Represent individual topic segments
|
|
||||||
- Examples: `cluster`, `event`, `api`, `neopattern`
|
|
||||||
- Smaller size (14px radius)
|
|
||||||
- Positioned dynamically based on event frequency
|
|
||||||
|
|
||||||
#### Link Types
|
|
||||||
- **Center Links** (Blue):
|
|
||||||
- Connect the center node to root topic nodes
|
|
||||||
- Thicker lines (2.5px)
|
|
||||||
- Examples: center → `cluster`, center → `event`
|
|
||||||
|
|
||||||
- **Topic Links** (Green):
|
|
||||||
- Connect adjacent topics in event chains
|
|
||||||
- Line thickness proportional to event frequency
|
|
||||||
- Examples: `cluster` → `event`, `api` → `neopattern`
|
|
||||||
|
|
||||||
#### Hover Interactions
|
|
||||||
- **Link Hover**: Links become thicker and more opaque when hovered
|
|
||||||
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Component Structure
|
|
||||||
|
|
||||||
```12:37:spore-ui/public/scripts/components/EventComponent.js
|
|
||||||
// Events Component - Visualizes websocket events as a graph
|
|
||||||
class EventComponent extends Component {
|
|
||||||
constructor(container, viewModel, eventBus) {
|
|
||||||
super(container, viewModel, eventBus);
|
|
||||||
|
|
||||||
this.svg = null;
|
|
||||||
this.simulation = null;
|
|
||||||
this.zoom = null;
|
|
||||||
this.width = 0;
|
|
||||||
this.height = 0;
|
|
||||||
this.isInitialized = false;
|
|
||||||
|
|
||||||
// Center node data - will be initialized with proper coordinates later
|
|
||||||
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
|
|
||||||
|
|
||||||
// Track nodes for D3
|
|
||||||
this.graphNodes = [];
|
|
||||||
this.graphLinks = [];
|
|
||||||
|
|
||||||
// Track recent events to trigger animations
|
|
||||||
this.lastSeenEvents = new Set();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ViewModel
|
|
||||||
|
|
||||||
The `EventViewModel` manages the state and WebSocket connections:
|
|
||||||
|
|
||||||
```1241:1253:spore-ui/public/scripts/view-models.js
|
|
||||||
// Events View Model for websocket event visualization
|
|
||||||
class EventViewModel extends ViewModel {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.setMultiple({
|
|
||||||
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdateTime: null
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up WebSocket listeners for real-time updates
|
|
||||||
this.setupWebSocketListeners();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Tracking
|
|
||||||
|
|
||||||
Events are tracked with the following metadata:
|
|
||||||
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
|
|
||||||
- **Parts**: Array of topic segments split by `/`
|
|
||||||
- **Count**: Total occurrences of this event
|
|
||||||
- **First Seen**: ISO timestamp of first occurrence
|
|
||||||
- **Last Seen**: ISO timestamp of most recent occurrence
|
|
||||||
- **Last Data**: The most recent event payload
|
|
||||||
|
|
||||||
### Event Addition Logic
|
|
||||||
|
|
||||||
The view model handles nested events specially:
|
|
||||||
|
|
||||||
```1283:1329:spore-ui/public/scripts/view-models.js
|
|
||||||
// Add a topic (parsed by "/" separator)
|
|
||||||
addTopic(topic, data = null) {
|
|
||||||
// Get current events as a new Map to ensure change detection
|
|
||||||
const events = new Map(this.get('events'));
|
|
||||||
|
|
||||||
// Handle nested events from cluster/event
|
|
||||||
let fullTopic = topic;
|
|
||||||
if (topic === 'cluster/event' && data && data.data) {
|
|
||||||
try {
|
|
||||||
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
|
||||||
if (parsedData && parsedData.event) {
|
|
||||||
// Create nested topic chain: cluster/event/api/neopattern
|
|
||||||
fullTopic = `${topic}/${parsedData.event}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// If parsing fails, just use the original topic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = fullTopic.split('/').filter(p => p);
|
|
||||||
|
|
||||||
if (events.has(fullTopic)) {
|
|
||||||
// Update existing event - create new object to ensure change detection
|
|
||||||
const existing = events.get(fullTopic);
|
|
||||||
events.set(fullTopic, {
|
|
||||||
topic: existing.topic,
|
|
||||||
parts: existing.parts,
|
|
||||||
count: existing.count + 1,
|
|
||||||
firstSeen: existing.firstSeen,
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
lastData: data
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Create new event entry
|
|
||||||
events.set(fullTopic, {
|
|
||||||
topic: fullTopic,
|
|
||||||
parts: parts,
|
|
||||||
count: 1,
|
|
||||||
firstSeen: new Date().toISOString(),
|
|
||||||
lastSeen: new Date().toISOString(),
|
|
||||||
lastData: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use set to trigger change notification
|
|
||||||
this.set('events', events);
|
|
||||||
this.set('lastUpdateTime', new Date().toISOString());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graph Construction
|
|
||||||
|
|
||||||
### Node Creation
|
|
||||||
|
|
||||||
The graph construction process follows two passes:
|
|
||||||
|
|
||||||
1. **First Pass - Node Creation**:
|
|
||||||
- Creates nodes for each unique topic segment
|
|
||||||
- Preserves existing node positions if they've been dragged
|
|
||||||
- New nodes are positioned near their parent in the hierarchy
|
|
||||||
|
|
||||||
2. **Second Pass - Event Counting**:
|
|
||||||
- Counts occurrences of each topic segment
|
|
||||||
- Updates node counts to reflect event frequency
|
|
||||||
|
|
||||||
### Link Construction
|
|
||||||
|
|
||||||
Links are created as chains representing complete topic paths:
|
|
||||||
|
|
||||||
```283:342:spore-ui/public/scripts/components/EventComponent.js
|
|
||||||
// Build links as chains for each topic
|
|
||||||
// For "cluster/update", create: center -> cluster -> update
|
|
||||||
this.graphLinks = [];
|
|
||||||
const linkSet = new Set(); // Track links to avoid duplicates
|
|
||||||
|
|
||||||
if (events && events.size > 0) {
|
|
||||||
for (const [topic, data] of events) {
|
|
||||||
const parts = data.parts;
|
|
||||||
|
|
||||||
if (parts.length === 0) continue;
|
|
||||||
|
|
||||||
// Connect center to first part
|
|
||||||
const firstPart = parts[0];
|
|
||||||
const centerToFirst = `center-${firstPart}`;
|
|
||||||
if (!linkSet.has(centerToFirst)) {
|
|
||||||
this.graphLinks.push({
|
|
||||||
source: 'center',
|
|
||||||
target: firstPart,
|
|
||||||
type: 'center-link',
|
|
||||||
count: data.count,
|
|
||||||
topic: topic
|
|
||||||
});
|
|
||||||
linkSet.add(centerToFirst);
|
|
||||||
} else {
|
|
||||||
// Update count for existing link
|
|
||||||
const existingLink = this.graphLinks.find(l =>
|
|
||||||
`${l.source}-${l.target}` === centerToFirst
|
|
||||||
);
|
|
||||||
if (existingLink) {
|
|
||||||
existingLink.count += data.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect each part to the next (creating a chain)
|
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
|
||||||
const source = parts[i];
|
|
||||||
const target = parts[i + 1];
|
|
||||||
const linkKey = `${source}-${target}`;
|
|
||||||
|
|
||||||
if (!linkSet.has(linkKey)) {
|
|
||||||
this.graphLinks.push({
|
|
||||||
source: source,
|
|
||||||
target: target,
|
|
||||||
type: 'topic-link',
|
|
||||||
topic: topic,
|
|
||||||
count: data.count
|
|
||||||
});
|
|
||||||
linkSet.add(linkKey);
|
|
||||||
} else {
|
|
||||||
// Update count for existing link
|
|
||||||
const existingLink = this.graphLinks.find(l =>
|
|
||||||
`${l.source}-${l.target}` === linkKey
|
|
||||||
);
|
|
||||||
if (existingLink) {
|
|
||||||
existingLink.count += data.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Force Simulation
|
|
||||||
|
|
||||||
The D3.js force simulation provides the physics-based layout:
|
|
||||||
|
|
||||||
```103:111:spore-ui/public/scripts/components/EventComponent.js
|
|
||||||
// Initialize simulation
|
|
||||||
this.simulation = d3.forceSimulation()
|
|
||||||
.force('link', d3.forceLink().id(d => d.id).distance(100))
|
|
||||||
.force('charge', d3.forceManyBody().strength(-300))
|
|
||||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
|
||||||
.force('collision', d3.forceCollide().radius(35))
|
|
||||||
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
|
|
||||||
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
|
|
||||||
```
|
|
||||||
|
|
||||||
### Force Parameters
|
|
||||||
- **Link Force**: Maintains 100px distance between connected nodes
|
|
||||||
- **Charge Force**: -300 strength creates repulsion between nodes
|
|
||||||
- **Center Force**: Keeps the graph centered in the viewport
|
|
||||||
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
|
|
||||||
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
|
|
||||||
- **Velocity Decay**: High decay (0.4) prevents excessive movement
|
|
||||||
|
|
||||||
## Animation System
|
|
||||||
|
|
||||||
### Event Arrival Animation
|
|
||||||
|
|
||||||
When a new event arrives, a golden animation dot travels along the event chain:
|
|
||||||
|
|
||||||
```725:809:spore-ui/public/scripts/components/EventComponent.js
|
|
||||||
animateEventMessage(topic, data) {
|
|
||||||
const g = this.svg.select('g');
|
|
||||||
if (g.empty()) return;
|
|
||||||
|
|
||||||
const parts = data.parts || topic.split('/').filter(p => p);
|
|
||||||
if (parts.length === 0) return;
|
|
||||||
|
|
||||||
// Wait for the next tick to ensure nodes exist
|
|
||||||
setTimeout(() => {
|
|
||||||
// Build the chain: center -> first -> second -> ... -> last
|
|
||||||
const chain = ['center', ...parts];
|
|
||||||
|
|
||||||
// Animate along each segment of the chain
|
|
||||||
let delay = 0;
|
|
||||||
for (let i = 0; i < chain.length - 1; i++) {
|
|
||||||
const sourceId = chain[i];
|
|
||||||
const targetId = chain[i + 1];
|
|
||||||
|
|
||||||
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
|
|
||||||
const targetNode = this.graphNodes.find(n => n.id === targetId);
|
|
||||||
|
|
||||||
if (!sourceNode || !targetNode) continue;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.animateDotAlongLink(sourceNode, targetNode, g);
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
// Add delay between segments (staggered animation)
|
|
||||||
delay += 400; // Duration (300ms) + small gap (100ms)
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
animateDotAlongLink(sourceNode, targetNode, g) {
|
|
||||||
if (!sourceNode || !targetNode || !g) return;
|
|
||||||
|
|
||||||
// Calculate positions
|
|
||||||
const dx = targetNode.x - sourceNode.x;
|
|
||||||
const dy = targetNode.y - sourceNode.y;
|
|
||||||
const length = Math.sqrt(dx * dx + dy * dy);
|
|
||||||
|
|
||||||
if (length === 0) return;
|
|
||||||
|
|
||||||
// Normalize direction
|
|
||||||
const nx = dx / length;
|
|
||||||
const ny = dy / length;
|
|
||||||
|
|
||||||
// Calculate node radii
|
|
||||||
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
|
|
||||||
const targetRadius = targetNode.type === 'center' ? 18 : 14;
|
|
||||||
|
|
||||||
// Start from edge of source node
|
|
||||||
const startX = sourceNode.x + nx * sourceRadius;
|
|
||||||
const startY = sourceNode.y + ny * sourceRadius;
|
|
||||||
|
|
||||||
// End at edge of target node
|
|
||||||
const endX = targetNode.x - nx * targetRadius;
|
|
||||||
const endY = targetNode.y - ny * targetRadius;
|
|
||||||
|
|
||||||
// Create animation dot
|
|
||||||
const animationGroup = g.append('g').attr('class', 'animation-group');
|
|
||||||
|
|
||||||
const dot = animationGroup.append('circle')
|
|
||||||
.attr('r', 4)
|
|
||||||
.attr('fill', '#FFD700')
|
|
||||||
.attr('stroke', '#fff')
|
|
||||||
.attr('stroke-width', 2)
|
|
||||||
.attr('cx', startX)
|
|
||||||
.attr('cy', startY);
|
|
||||||
|
|
||||||
// Animate the dot along the path
|
|
||||||
dot.transition()
|
|
||||||
.duration(300)
|
|
||||||
.ease(d3.easeLinear)
|
|
||||||
.attr('cx', endX)
|
|
||||||
.attr('cy', endY)
|
|
||||||
.on('end', function() {
|
|
||||||
// Fade out after reaching destination
|
|
||||||
dot.transition()
|
|
||||||
.duration(100)
|
|
||||||
.style('opacity', 0)
|
|
||||||
.remove();
|
|
||||||
animationGroup.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Animation Characteristics
|
|
||||||
- **Golden Dot**: Visual indicator traveling along event chains
|
|
||||||
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
|
|
||||||
- **Duration**: 300ms per segment with 100ms delay between segments
|
|
||||||
- **Smooth Motion**: Linear easing for consistent speed
|
|
||||||
|
|
||||||
## WebSocket Integration
|
|
||||||
|
|
||||||
The Events view subscribes to all WebSocket messages through the gateway:
|
|
||||||
|
|
||||||
```1256:1280:spore-ui/public/scripts/view-models.js
|
|
||||||
// Set up WebSocket event listeners
|
|
||||||
setupWebSocketListeners() {
|
|
||||||
if (!window.wsClient) {
|
|
||||||
// Retry after a short delay to allow wsClient to initialize
|
|
||||||
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen for all websocket messages
|
|
||||||
window.wsClient.on('message', (data) => {
|
|
||||||
const topic = data.topic || data.type;
|
|
||||||
|
|
||||||
if (topic) {
|
|
||||||
this.addTopic(topic, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for connection status changes
|
|
||||||
window.wsClient.on('connected', () => {
|
|
||||||
logger.info('EventViewModel: WebSocket connected');
|
|
||||||
});
|
|
||||||
|
|
||||||
window.wsClient.on('disconnected', () => {
|
|
||||||
logger.debug('EventViewModel: WebSocket disconnected');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Event Types Tracked
|
|
||||||
|
|
||||||
All WebSocket messages are captured and displayed, including:
|
|
||||||
- **cluster/update**: Node discovery and cluster membership changes
|
|
||||||
- **cluster/event**: Arbitrary events from cluster members
|
|
||||||
- **api/tasks**: Task execution notifications
|
|
||||||
- **api/config**: Configuration updates
|
|
||||||
- **node/***: Node-specific events
|
|
||||||
- Any custom topics sent by SPORE nodes
|
|
||||||
|
|
||||||
## Empty State
|
|
||||||
|
|
||||||
When no events have been received, the view displays a helpful message:
|
|
||||||
|
|
||||||
```604:632:spore-ui/public/scripts/components/EventComponent.js
|
|
||||||
showEmptyState() {
|
|
||||||
if (!this.isInitialized || !this.svg) return;
|
|
||||||
|
|
||||||
const g = this.svg.select('g');
|
|
||||||
if (g.empty()) return;
|
|
||||||
|
|
||||||
// Remove any existing message
|
|
||||||
g.selectAll('.empty-state').remove();
|
|
||||||
|
|
||||||
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
|
|
||||||
// We need to place the text in the center of the transformed coordinate space
|
|
||||||
const transformX = -200;
|
|
||||||
const transformY = -150;
|
|
||||||
const scale = 1.4;
|
|
||||||
|
|
||||||
// Calculate centered position in transformed space
|
|
||||||
const x = ((this.width / 2) - transformX) / scale;
|
|
||||||
const y = ((this.height / 2) - transformY) / scale;
|
|
||||||
|
|
||||||
const emptyMsg = g.append('text')
|
|
||||||
.attr('class', 'empty-state')
|
|
||||||
.attr('x', x)
|
|
||||||
.attr('y', y)
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('font-size', '18px')
|
|
||||||
.attr('fill', 'var(--text-secondary)')
|
|
||||||
.attr('font-weight', '500')
|
|
||||||
.text('Waiting for websocket events...');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
|
|
||||||
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
|
|
||||||
3. **Animation**: Rapid event bursts may cause overlapping animations
|
|
||||||
|
|
||||||
### Usage Tips
|
|
||||||
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
|
|
||||||
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
|
|
||||||
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
|
|
||||||
4. **Interactive Exploration**: Hover over links to see event frequencies
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### No Events Showing
|
|
||||||
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
|
|
||||||
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
|
|
||||||
- **Browser Console**: Check for any JavaScript errors
|
|
||||||
|
|
||||||
### Graph Not Updating
|
|
||||||
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
|
|
||||||
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
|
|
||||||
|
|
||||||
### Performance Issues
|
|
||||||
- **Clear Events**: Reload the page to clear accumulated events
|
|
||||||
- **Reduce Events**: Limit the event rate in your SPORE nodes
|
|
||||||
- **Browser Settings**: Ensure hardware acceleration is enabled
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
|
|
||||||
- **SPORE Gateway**: WebSocket connection to cluster event stream
|
|
||||||
- **Component Framework**: Built on the SPORE UI component architecture
|
|
||||||
|
|
||||||
### Browser Compatibility
|
|
||||||
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
|
|
||||||
- **SVG Support**: Requires modern browser with full SVG support
|
|
||||||
- **WebSocket Support**: Native WebSocket API required
|
|
||||||
|
|
||||||
### View Structure
|
|
||||||
|
|
||||||
```254:262:spore-ui/public/index.html
|
|
||||||
<div id="events-view" class="view-content">
|
|
||||||
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
|
||||||
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
|
||||||
<div class="loading">
|
|
||||||
<div>Waiting for websocket events...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.
|
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# SPORE UI Documentation
|
|
||||||
|
|
||||||
This directory contains detailed documentation for the SPORE UI frontend application.
|
|
||||||
|
|
||||||
## Documentation Index
|
|
||||||
|
|
||||||
### Core Documentation
|
|
||||||
|
|
||||||
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
|
|
||||||
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
|
|
||||||
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
|
|
||||||
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
|
|
||||||
|
|
||||||
### Feature Documentation
|
|
||||||
|
|
||||||
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
|
|
||||||
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
|
|
||||||
|
|
||||||
## Feature Overview
|
|
||||||
|
|
||||||
### Events Feature
|
|
||||||
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
|
||||||
|
|
||||||
**Key Capabilities:**
|
|
||||||
- Real-time event tracking and visualization
|
|
||||||
- Interactive force-directed graph layout
|
|
||||||
- Event counting and frequency visualization
|
|
||||||
- Animated event flow visualization
|
|
||||||
- Topic hierarchy display
|
|
||||||
|
|
||||||
See [Events.md](./Events.md) for complete documentation.
|
|
||||||
|
|
||||||
### Framework Architecture
|
|
||||||
The SPORE UI uses a component-based architecture with:
|
|
||||||
- View Models for state management
|
|
||||||
- Components for UI rendering
|
|
||||||
- Event Bus for pub/sub communication
|
|
||||||
- API Client for backend integration
|
|
||||||
|
|
||||||
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
|
|
||||||
|
|
||||||
### Discovery System
|
|
||||||
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
|
|
||||||
|
|
||||||
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
|
|
||||||
|
|
||||||
### Topology Visualization
|
|
||||||
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
|
|
||||||
|
|
||||||
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
|
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### Most Popular Pages
|
|
||||||
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
|
|
||||||
2. [Events Feature](./Events.md) - Real-time event visualization
|
|
||||||
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
|
|
||||||
|
|
||||||
### Developer Resources
|
|
||||||
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
|
|
||||||
- Debugging: See [LOGGING.md](./LOGGING.md)
|
|
||||||
- Testing: See individual component documentation
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
When adding new features or documentation:
|
|
||||||
1. Follow the existing documentation structure
|
|
||||||
2. Include code examples and diagrams where helpful
|
|
||||||
3. Update this README when adding new documentation files
|
|
||||||
4. Maintain consistency with existing documentation style
|
|
||||||
|
|
||||||
## Related Documentation
|
|
||||||
|
|
||||||
For information about other SPORE components:
|
|
||||||
- **SPORE Gateway**: See `spore-gateway/README.md`
|
|
||||||
- **SPORE Registry**: See `spore-registry/README.md`
|
|
||||||
- **SPORE Embedded**: See `spore/README.md`
|
|
||||||
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
# Topology Component WebSocket Integration
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Enhanced the topology graph component to support real-time node additions and removals via WebSocket connections. The topology view now automatically updates when nodes join or leave the cluster without requiring manual refresh. Existing nodes update their properties (status, labels) smoothly in place without being removed and re-added.
|
|
||||||
|
|
||||||
## Changes Made
|
|
||||||
|
|
||||||
### 1. TopologyViewModel (`spore-ui/public/scripts/view-models.js`)
|
|
||||||
|
|
||||||
Added `setupWebSocketListeners()` method to the TopologyViewModel class:
|
|
||||||
|
|
||||||
- **Listens to `clusterUpdate` events**: When cluster membership changes, the topology graph is automatically rebuilt with the new node data
|
|
||||||
- **Listens to `nodeDiscovery` events**: When a new node is discovered or becomes stale, triggers a topology update
|
|
||||||
- **Listens to connection status**: Automatically refreshes topology when WebSocket reconnects
|
|
||||||
- **Async graph updates**: Rebuilds graph data asynchronously from WebSocket data to avoid blocking the UI
|
|
||||||
|
|
||||||
Enhanced `buildEnhancedGraphData()` method to preserve node state:
|
|
||||||
|
|
||||||
- **Position preservation**: Existing nodes retain their x, y coordinates across updates
|
|
||||||
- **Velocity preservation**: D3 simulation velocity (vx, vy) is maintained for smooth physics
|
|
||||||
- **Fixed position preservation**: Manually dragged nodes (fx, fy) stay in place
|
|
||||||
- **New nodes only**: Only newly discovered nodes get random initial positions
|
|
||||||
- **Result**: Nodes no longer "jump" or get removed/re-added when their properties update
|
|
||||||
|
|
||||||
### 2. TopologyGraphComponent (`spore-ui/public/scripts/components/TopologyGraphComponent.js`)
|
|
||||||
|
|
||||||
#### Added WebSocket Setup
|
|
||||||
- Added `setupWebSocketListeners()` method that calls the view model's WebSocket setup during component initialization
|
|
||||||
- Integrated into the `initialize()` lifecycle method
|
|
||||||
|
|
||||||
#### Improved Dynamic Updates (D3.js Enter/Exit Pattern)
|
|
||||||
Refactored the graph rendering to use D3's data binding patterns for smooth transitions:
|
|
||||||
|
|
||||||
- **`updateLinks()`**: Uses enter/exit pattern to add/remove links with fade transitions
|
|
||||||
- **`updateNodes()`**: Uses enter/exit pattern to add/remove nodes with fade transitions
|
|
||||||
- New nodes fade in (300ms transition)
|
|
||||||
- Removed nodes fade out (300ms transition)
|
|
||||||
- Existing nodes smoothly update their properties
|
|
||||||
- **`updateLinkLabels()`**: Dynamically updates link latency labels
|
|
||||||
- **`updateSimulation()`**: Handles D3 force simulation updates
|
|
||||||
- Creates new simulation on first render
|
|
||||||
- Updates existing simulation with new node/link data on subsequent renders
|
|
||||||
- Maintains smooth physics-based layout
|
|
||||||
- **`addLegend()`**: Fixed to prevent duplicate legend creation
|
|
||||||
|
|
||||||
#### Key Improvements
|
|
||||||
- **Incremental updates**: Instead of recreating the entire graph, only modified nodes/links are added or removed
|
|
||||||
- **Smooth animations**: 300ms fade transitions for adding/removing elements
|
|
||||||
- **In-place updates**: Existing nodes update their properties without being removed/re-added
|
|
||||||
- **Preserved interactions**: Click, hover, and drag interactions work seamlessly with dynamic updates
|
|
||||||
- **Efficient rendering**: D3's data binding with key functions ensures optimal DOM updates
|
|
||||||
- **Intelligent simulation**: Uses different alpha values (0.1 for updates, 0.3 for additions/removals) to minimize disruption
|
|
||||||
- **Drag-aware updates**: WebSocket updates are deferred while dragging and applied after drag completes
|
|
||||||
- **Uninterrupted dragging**: Drag operations are never interrupted by incoming updates
|
|
||||||
- **Rearrange button**: Convenient UI control to reset node layout and clear manual positioning
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
```
|
|
||||||
WebSocket Server (spore-ui backend)
|
|
||||||
↓ (cluster_update / node_discovery events)
|
|
||||||
WebSocketClient (api-client.js)
|
|
||||||
↓ (emits clusterUpdate / nodeDiscovery events)
|
|
||||||
TopologyViewModel.setupWebSocketListeners()
|
|
||||||
↓ (builds graph data, updates state)
|
|
||||||
TopologyGraphComponent subscriptions
|
|
||||||
↓ (renderGraph() called automatically)
|
|
||||||
├─ If dragging: queue update in pendingUpdate
|
|
||||||
└─ If not dragging: apply update immediately
|
|
||||||
D3.js enter/exit pattern
|
|
||||||
↓ (smooth visual updates)
|
|
||||||
Updated Topology Graph
|
|
||||||
```
|
|
||||||
|
|
||||||
### Simplified Update Architecture
|
|
||||||
|
|
||||||
**Core Principle**: The D3 simulation is the single source of truth for positions.
|
|
||||||
|
|
||||||
#### How It Works:
|
|
||||||
|
|
||||||
1. **Drag Deferral**:
|
|
||||||
- `isDragging` flag blocks updates during drag
|
|
||||||
- Updates queued in `pendingUpdate` and applied after drag ends
|
|
||||||
- Dragged positions saved in `draggedNodePositions` Map for persistence
|
|
||||||
|
|
||||||
2. **Position Merging** (in `updateNodes()`):
|
|
||||||
- When simulation exists: copy live positions from simulation nodes to new data
|
|
||||||
- This preserves ongoing animations and velocities
|
|
||||||
- Then apply dragged positions (if any) as overrides
|
|
||||||
- Result: Always use most current position state
|
|
||||||
|
|
||||||
3. **Smart Simulation Updates** (in `updateSimulation()`):
|
|
||||||
- **Structural changes** (nodes added/removed): restart with alpha=0.3
|
|
||||||
- **Property changes** (status, labels): DON'T restart - just update data
|
|
||||||
- Simulation continues naturally for property-only changes
|
|
||||||
- No unnecessary disruptions to ongoing animations
|
|
||||||
|
|
||||||
This ensures:
|
|
||||||
- ✅ Simulation is authoritative for positions
|
|
||||||
- ✅ No position jumping during animations
|
|
||||||
- ✅ Property updates don't disrupt node movement
|
|
||||||
- ✅ Dragged positions always respected
|
|
||||||
- ✅ Simple, clean logic with one source of truth
|
|
||||||
|
|
||||||
### WebSocket Events Handled
|
|
||||||
|
|
||||||
1. **`clusterUpdate`** (from `cluster_update` message type)
|
|
||||||
- Payload: `{ members: [...], primaryNode: string, totalNodes: number, timestamp: string }`
|
|
||||||
- Action: Rebuilds graph with current cluster state
|
|
||||||
|
|
||||||
2. **`nodeDiscovery`** (from `node_discovery` message type)
|
|
||||||
- Payload: `{ action: 'discovered' | 'stale', nodeIp: string, timestamp: string }`
|
|
||||||
- Action: Triggers topology refresh after 500ms delay
|
|
||||||
|
|
||||||
3. **`connected`** (WebSocket connection established)
|
|
||||||
- Action: Triggers topology refresh after 1000ms delay
|
|
||||||
|
|
||||||
4. **`disconnected`** (WebSocket connection lost)
|
|
||||||
- Action: Logs disconnection (no action taken)
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
1. **Real-time Updates**: Topology reflects cluster state changes immediately
|
|
||||||
2. **Smooth Transitions**: Nodes and links fade in/out gracefully
|
|
||||||
3. **Better UX**: No manual refresh needed
|
|
||||||
4. **Efficient**: Only updates changed elements, not entire graph
|
|
||||||
5. **Resilient**: Automatically refreshes on reconnection
|
|
||||||
6. **Consistent**: Uses same WebSocket infrastructure as ClusterStatusComponent
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
To test the WebSocket integration:
|
|
||||||
|
|
||||||
1. **Start the application**:
|
|
||||||
```bash
|
|
||||||
cd spore-ui
|
|
||||||
node index-standalone.js
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Open the UI** and navigate to the Topology view
|
|
||||||
|
|
||||||
3. **Add a node**: Start a new SPORE device on the network
|
|
||||||
- Watch it appear in the topology graph within seconds
|
|
||||||
- Node should fade in smoothly
|
|
||||||
|
|
||||||
4. **Remove a node**: Stop a SPORE device
|
|
||||||
- Watch it fade out from the topology graph
|
|
||||||
- Connected links should also disappear
|
|
||||||
|
|
||||||
5. **Status changes**: Change node status (active → inactive → dead)
|
|
||||||
- Node colors should update automatically
|
|
||||||
- Status indicators should change
|
|
||||||
|
|
||||||
6. **Drag during updates**:
|
|
||||||
- Start dragging a node
|
|
||||||
- While dragging, trigger a cluster update (add/remove/change another node)
|
|
||||||
- Drag should continue smoothly without interruption
|
|
||||||
- After releasing, the update should be applied immediately
|
|
||||||
- **Important**: The dragged node should stay at its final position, not revert
|
|
||||||
|
|
||||||
7. **Position persistence after drag**:
|
|
||||||
- Drag a node to a new position and release
|
|
||||||
- Trigger multiple WebSocket updates (status changes, new nodes, etc.)
|
|
||||||
- The dragged node should remain in its new position through all updates
|
|
||||||
- Only when the node is removed should its position be forgotten
|
|
||||||
|
|
||||||
8. **Update during animation**:
|
|
||||||
- Let the graph settle (simulation running, nodes animating to stable positions)
|
|
||||||
- While nodes are still moving, trigger a WebSocket update (status change)
|
|
||||||
- **Expected**: Nodes should continue their smooth animation without jumping
|
|
||||||
- **No flickering**: Positions should not snap back and forth
|
|
||||||
- Animation should feel continuous and natural
|
|
||||||
|
|
||||||
9. **Single node scenario**:
|
|
||||||
- Start with multiple nodes in the topology
|
|
||||||
- Remove nodes one by one until only one remains
|
|
||||||
- **Expected**: Single node stays visible, no "loading" message
|
|
||||||
- Graph should render correctly with just one node
|
|
||||||
- Remove the last node
|
|
||||||
- **Expected**: "No cluster members found" message appears
|
|
||||||
|
|
||||||
10. **Rearrange nodes**:
|
|
||||||
- Drag nodes to custom positions manually
|
|
||||||
- Click the "Rearrange" button in the top-left corner
|
|
||||||
- **Expected**: All nodes reset to physics-based positions
|
|
||||||
- Dragged positions cleared, simulation restarts
|
|
||||||
- Nodes animate to a clean, evenly distributed layout
|
|
||||||
|
|
||||||
11. **WebSocket reconnection**:
|
|
||||||
- Disconnect from network briefly
|
|
||||||
- Reconnect
|
|
||||||
- Topology should refresh automatically
|
|
||||||
|
|
||||||
## Technical Notes
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
- **Single Source of Truth**: D3 simulation manages all position state
|
|
||||||
- **Key Functions**: D3 data binding uses node IPs as keys to track identity
|
|
||||||
- **Transition Duration**: 300ms for fade in/out animations
|
|
||||||
|
|
||||||
### Position Management (Simplified!)
|
|
||||||
- **updateNodes()**: Copies live positions from simulation to new data before binding
|
|
||||||
- **No complex syncing**: Simulation state flows naturally to new data
|
|
||||||
- **Dragged positions**: Override via `draggedNodePositions` Map (always respected)
|
|
||||||
|
|
||||||
### Simulation Behavior
|
|
||||||
- **Structural changes** (add/remove nodes): Restart with alpha=0.3
|
|
||||||
- **Property changes** (status, labels): No restart - data updated in-place
|
|
||||||
- **Drag operations**: Simulation updates blocked entirely
|
|
||||||
- **Result**: Smooth animations for property updates, controlled restart for structure changes
|
|
||||||
|
|
||||||
### Drag Management
|
|
||||||
- **isDragging flag**: Blocks all updates during drag
|
|
||||||
- **pendingUpdate**: Queues one update, applied 50ms after drag ends
|
|
||||||
- **draggedNodePositions Map**: Persists manual positions across all updates
|
|
||||||
- **Cleanup**: Map entries removed when nodes deleted
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- **No unnecessary restarts**: Property-only updates don't disrupt simulation
|
|
||||||
- **Efficient merging**: Position data copied via Map lookup (O(n))
|
|
||||||
- **Memory efficient**: Only active nodes tracked, old entries cleaned up
|
|
||||||
- **Smooth animations**: Velocity and momentum preserved across updates
|
|
||||||
|
|
||||||
### Edge Cases Handled
|
|
||||||
- **Single node**: Graph renders correctly with just one node
|
|
||||||
- **Transient states**: Loading/no-data states don't clear existing SVG
|
|
||||||
- **Update races**: SVG preserved even if loading state triggered during render
|
|
||||||
- **Empty to non-empty**: Smooth transition from loading to first node
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
Possible improvements for future iterations:
|
|
||||||
|
|
||||||
1. **Diff-based updates**: Only rebuild graph when node/link structure actually changes
|
|
||||||
2. **Visual indicators**: Show "new node" or "leaving node" badges temporarily
|
|
||||||
3. **Connection health**: Real-time latency updates on links without full rebuild
|
|
||||||
4. **Throttling**: Debounce rapid successive updates
|
|
||||||
5. **Persistent layout**: Save and restore user-arranged topology layouts
|
|
||||||
6. **Zoom to node**: Auto-zoom to newly added nodes with animation
|
|
||||||
|
|
||||||
## Related Files
|
|
||||||
|
|
||||||
- `spore-ui/public/scripts/view-models.js` - TopologyViewModel class
|
|
||||||
- `spore-ui/public/scripts/components/TopologyGraphComponent.js` - Topology visualization component
|
|
||||||
- `spore-ui/public/scripts/api-client.js` - WebSocketClient class
|
|
||||||
- `spore-ui/index-standalone.js` - WebSocket server implementation
|
|
||||||
|
|
||||||
@@ -70,34 +70,14 @@ udpServer.on('message', (msg, rinfo) => {
|
|||||||
|
|
||||||
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
console.log(`📨 UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||||
|
|
||||||
// Extract topic by splitting on first ":"
|
if (message.startsWith('CLUSTER_HEARTBEAT:')) {
|
||||||
const parts = message.split(':', 2);
|
// Handle heartbeat messages that update member list
|
||||||
if (parts.length < 2) {
|
const hostname = message.substring('CLUSTER_HEARTBEAT:'.length);
|
||||||
console.log(`Invalid message format from ${sourceIp}:${sourcePort}: "${message}"`);
|
updateNodeFromHeartbeat(sourceIp, sourcePort, hostname);
|
||||||
return;
|
} else if (message.startsWith('NODE_UPDATE:')) {
|
||||||
}
|
// Handle node update messages that provide detailed node info
|
||||||
|
handleNodeUpdate(sourceIp, message);
|
||||||
const topic = parts[0] + ':';
|
} else if (!message.startsWith('RAW:')) {
|
||||||
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) {
|
||||||
@@ -939,7 +919,7 @@ function broadcastClusterUpdate() {
|
|||||||
// Get cluster members asynchronously
|
// Get cluster members asynchronously
|
||||||
getCurrentClusterMembers().then(members => {
|
getCurrentClusterMembers().then(members => {
|
||||||
const clusterData = {
|
const clusterData = {
|
||||||
topic: 'cluster/update',
|
type: 'cluster_update',
|
||||||
members: members,
|
members: members,
|
||||||
primaryNode: primaryNodeIp,
|
primaryNode: primaryNodeIp,
|
||||||
totalNodes: discoveredNodes.size,
|
totalNodes: discoveredNodes.size,
|
||||||
@@ -966,7 +946,7 @@ function broadcastNodeDiscovery(nodeIp, action) {
|
|||||||
if (wsClients.size === 0 || !wss) return;
|
if (wsClients.size === 0 || !wss) return;
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
topic: 'node/discovery',
|
type: 'node_discovery',
|
||||||
action: action, // 'discovered' or 'stale'
|
action: action, // 'discovered' or 'stale'
|
||||||
nodeIp: nodeIp,
|
nodeIp: nodeIp,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
@@ -1142,7 +1122,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,18 +39,6 @@
|
|||||||
</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"/>
|
||||||
@@ -66,14 +54,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<div class="random-primary-switcher">
|
|
||||||
<button class="random-primary-toggle" id="random-primary-toggle" title="Select random primary node">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
|
||||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
|
||||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="theme-switcher">
|
<div class="theme-switcher">
|
||||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
@@ -250,16 +230,6 @@
|
|||||||
</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>
|
||||||
@@ -286,7 +256,6 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -72,13 +72,6 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setPrimaryNode(ip) {
|
|
||||||
return this.request(`/api/discovery/primary/${encodeURIComponent(ip)}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { timestamp: new Date().toISOString() }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNodeStatus(ip) {
|
async getNodeStatus(ip) {
|
||||||
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||||
}
|
}
|
||||||
@@ -263,8 +256,7 @@ 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);
|
||||||
const messageTopic = data.topic || data.type;
|
logger.debug('WebSocket message type:', 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) {
|
||||||
@@ -289,21 +281,21 @@ class WebSocketClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(data) {
|
handleMessage(data) {
|
||||||
const messageTopic = data.topic || data.type;
|
switch (data.type) {
|
||||||
|
case 'cluster_update':
|
||||||
// Handler map for different WebSocket message types
|
this.emit('clusterUpdate', data);
|
||||||
const handlers = {
|
break;
|
||||||
'cluster/update': (data) => this.emit('clusterUpdate', data),
|
case 'node_discovery':
|
||||||
'node/discovery': (data) => this.emit('nodeDiscovery', data),
|
this.emit('nodeDiscovery', data);
|
||||||
'firmware/upload/status': (data) => this.emit('firmwareUploadStatus', data),
|
break;
|
||||||
'rollout/progress': (data) => this.emit('rolloutProgress', data)
|
case 'firmware_upload_status':
|
||||||
};
|
this.emit('firmwareUploadStatus', data);
|
||||||
|
break;
|
||||||
const handler = handlers[messageTopic];
|
case 'rollout_progress':
|
||||||
if (handler) {
|
this.emit('rolloutProgress', data);
|
||||||
handler(data);
|
break;
|
||||||
} else {
|
default:
|
||||||
logger.debug('Unknown WebSocket message topic:', messageTopic);
|
logger.debug('Unknown WebSocket message type:', data.type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ 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();
|
||||||
const eventsViewModel = new EventViewModel();
|
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel });
|
||||||
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) => {
|
||||||
@@ -66,7 +65,6 @@ 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
|
||||||
@@ -79,65 +77,6 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
clusterStatusComponent.mount();
|
clusterStatusComponent.mount();
|
||||||
logger.debug('App: Cluster status component initialized');
|
logger.debug('App: Cluster status component initialized');
|
||||||
|
|
||||||
// Set up random primary node button
|
|
||||||
logger.debug('App: Setting up random primary node button...');
|
|
||||||
const randomPrimaryBtn = document.getElementById('random-primary-toggle');
|
|
||||||
if (randomPrimaryBtn) {
|
|
||||||
randomPrimaryBtn.addEventListener('click', async function() {
|
|
||||||
try {
|
|
||||||
// Add spinning animation
|
|
||||||
randomPrimaryBtn.classList.add('spinning');
|
|
||||||
randomPrimaryBtn.disabled = true;
|
|
||||||
|
|
||||||
logger.debug('App: Selecting random primary node...');
|
|
||||||
await clusterViewModel.selectRandomPrimaryNode();
|
|
||||||
|
|
||||||
// Show success state briefly
|
|
||||||
logger.info('App: Random primary node selected successfully');
|
|
||||||
|
|
||||||
// Refresh topology to show new primary node connections
|
|
||||||
// Wait a bit for the backend to update, then refresh topology
|
|
||||||
setTimeout(async () => {
|
|
||||||
logger.debug('App: Refreshing topology after primary node change...');
|
|
||||||
try {
|
|
||||||
await topologyViewModel.updateNetworkTopology();
|
|
||||||
logger.debug('App: Topology refreshed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('App: Failed to refresh topology:', error);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Also refresh cluster view to update member list with new primary
|
|
||||||
setTimeout(async () => {
|
|
||||||
logger.debug('App: Refreshing cluster view after primary node change...');
|
|
||||||
try {
|
|
||||||
if (clusterViewModel.updateClusterMembers) {
|
|
||||||
await clusterViewModel.updateClusterMembers();
|
|
||||||
}
|
|
||||||
logger.debug('App: Cluster view refreshed successfully');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('App: Failed to refresh cluster view:', error);
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Remove spinning animation after delay
|
|
||||||
setTimeout(() => {
|
|
||||||
randomPrimaryBtn.classList.remove('spinning');
|
|
||||||
randomPrimaryBtn.disabled = false;
|
|
||||||
}, 1500);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('App: Failed to select random primary node:', error);
|
|
||||||
randomPrimaryBtn.classList.remove('spinning');
|
|
||||||
randomPrimaryBtn.disabled = false;
|
|
||||||
|
|
||||||
// Show error notification (could be enhanced with a toast notification)
|
|
||||||
alert('Failed to select random primary node: ' + error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
logger.debug('App: Random primary node button configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up navigation event listeners
|
// Set up navigation event listeners
|
||||||
logger.debug('App: Setting up navigation...');
|
logger.debug('App: Setting up navigation...');
|
||||||
app.setupNavigation();
|
app.setupNavigation();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -98,14 +98,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
const chips = Object.entries(labels)
|
const chips = Object.entries(labels)
|
||||||
.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('');
|
||||||
|
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>`;
|
||||||
// Add hint for node interactions
|
|
||||||
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) {
|
|
||||||
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>';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tooltipEl.innerHTML = `<div class=\"member-labels\">${chips}</div>${hint}`;
|
|
||||||
this.positionTooltip(pageX, pageY);
|
this.positionTooltip(pageX, pageY);
|
||||||
this.tooltipEl.classList.add('visible');
|
this.tooltipEl.classList.add('visible');
|
||||||
}
|
}
|
||||||
@@ -228,7 +221,6 @@ class TopologyGraphComponent extends Component {
|
|||||||
this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this));
|
this.subscribeToProperty('isLoading', this.handleLoadingState.bind(this));
|
||||||
this.subscribeToProperty('error', this.handleError.bind(this));
|
this.subscribeToProperty('error', this.handleError.bind(this));
|
||||||
this.subscribeToProperty('selectedNode', this.updateSelection.bind(this));
|
this.subscribeToProperty('selectedNode', this.updateSelection.bind(this));
|
||||||
this.subscribeToProperty('discoveryEvent', this.handleDiscoveryEvent.bind(this));
|
|
||||||
} else {
|
} else {
|
||||||
// Component not yet initialized, store for later
|
// Component not yet initialized, store for later
|
||||||
logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions');
|
logger.debug('TopologyGraphComponent: Component not initialized, storing pending subscriptions');
|
||||||
@@ -237,8 +229,7 @@ class TopologyGraphComponent extends Component {
|
|||||||
['links', this.renderGraph.bind(this)],
|
['links', this.renderGraph.bind(this)],
|
||||||
['isLoading', this.handleLoadingState.bind(this)],
|
['isLoading', this.handleLoadingState.bind(this)],
|
||||||
['error', this.handleError.bind(this)],
|
['error', this.handleError.bind(this)],
|
||||||
['selectedNode', this.updateSelection.bind(this)],
|
['selectedNode', this.updateSelection.bind(this)]
|
||||||
['discoveryEvent', this.handleDiscoveryEvent.bind(this)]
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,7 +310,6 @@ class TopologyGraphComponent extends Component {
|
|||||||
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
.style('border', '1px solid rgba(255, 255, 255, 0.1)')
|
||||||
.style('border-radius', '12px');
|
.style('border-radius', '12px');
|
||||||
|
|
||||||
|
|
||||||
// Add zoom behavior
|
// Add zoom behavior
|
||||||
this.zoom = d3.zoom()
|
this.zoom = d3.zoom()
|
||||||
.scaleExtent([0.5, 5])
|
.scaleExtent([0.5, 5])
|
||||||
@@ -506,37 +496,25 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bind data with key function for proper enter/exit
|
// Bind data with key function for proper enter/exit
|
||||||
// D3's forceLink mutates source/target from strings to objects, so we need to normalize the key
|
|
||||||
const link = linkGroup.selectAll('line')
|
const link = linkGroup.selectAll('line')
|
||||||
.data(links, d => {
|
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||||
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
||||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
||||||
// For directional links, maintain source -> target order in the key
|
|
||||||
// For bidirectional links, use consistent ordering to avoid duplicates
|
|
||||||
if (d.bidirectional) {
|
|
||||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
|
||||||
} else {
|
|
||||||
return `${sourceId}->${targetId}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove old links
|
// Remove old links
|
||||||
link.exit()
|
link.exit()
|
||||||
.transition()
|
.transition()
|
||||||
.duration(600)
|
.duration(300)
|
||||||
.ease(d3.easeCubicOut)
|
|
||||||
.style('opacity', 0)
|
.style('opacity', 0)
|
||||||
.remove();
|
.remove();
|
||||||
|
|
||||||
// Add new links
|
// Add new links
|
||||||
const linkEnter = link.enter().append('line')
|
const linkEnter = link.enter().append('line')
|
||||||
.attr('stroke-opacity', 0);
|
.attr('stroke-opacity', 0)
|
||||||
|
.attr('marker-end', null);
|
||||||
|
|
||||||
// Merge and update all links
|
// Merge and update all links
|
||||||
const linkMerge = linkEnter.merge(link)
|
const linkMerge = linkEnter.merge(link)
|
||||||
.transition()
|
.transition()
|
||||||
.duration(600)
|
.duration(300)
|
||||||
.ease(d3.easeCubicInOut)
|
|
||||||
.attr('stroke', d => this.getLinkColor(d.latency))
|
.attr('stroke', d => this.getLinkColor(d.latency))
|
||||||
.attr('stroke-opacity', 0.7)
|
.attr('stroke-opacity', 0.7)
|
||||||
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)));
|
.attr('stroke-width', d => Math.max(1, Math.min(3, d.latency / 8)));
|
||||||
@@ -617,24 +595,15 @@ class TopologyGraphComponent extends Component {
|
|||||||
nodeEnter.append('circle')
|
nodeEnter.append('circle')
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.attr('r', d => this.getNodeRadius(d.status))
|
||||||
.attr('fill', d => this.getNodeColor(d.status))
|
.attr('fill', d => this.getNodeColor(d.status))
|
||||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
.attr('stroke', '#fff')
|
||||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
.attr('stroke-width', 2);
|
||||||
|
|
||||||
// Status indicator - removed
|
// Status indicator
|
||||||
|
nodeEnter.append('circle')
|
||||||
// Primary node badge
|
.attr('r', 3)
|
||||||
const primaryBadge = nodeEnter.filter(d => d.isPrimary)
|
.attr('fill', d => this.getStatusIndicatorColor(d.status))
|
||||||
.append('g')
|
.attr('cx', -8)
|
||||||
.attr('class', 'primary-badge')
|
.attr('cy', -8);
|
||||||
.attr('transform', 'translate(0, 0)');
|
|
||||||
|
|
||||||
primaryBadge.append('text')
|
|
||||||
.text('A')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'central')
|
|
||||||
.attr('font-size', '12px')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('fill', '#000');
|
|
||||||
|
|
||||||
// Hostname
|
// Hostname
|
||||||
nodeEnter.append('text')
|
nodeEnter.append('text')
|
||||||
@@ -684,11 +653,12 @@ class TopologyGraphComponent extends Component {
|
|||||||
.transition()
|
.transition()
|
||||||
.duration(300)
|
.duration(300)
|
||||||
.attr('r', d => this.getNodeRadius(d.status))
|
.attr('r', d => this.getNodeRadius(d.status))
|
||||||
.attr('fill', d => this.getNodeColor(d.status))
|
.attr('fill', d => this.getNodeColor(d.status));
|
||||||
.attr('stroke', d => d.isPrimary ? '#FFD700' : '#fff')
|
|
||||||
.attr('stroke-width', d => d.isPrimary ? 3 : 2);
|
|
||||||
|
|
||||||
// Update status indicator - removed
|
nodeMerge.select('circle:nth-child(2)')
|
||||||
|
.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);
|
||||||
@@ -706,31 +676,6 @@ class TopologyGraphComponent extends Component {
|
|||||||
.duration(300)
|
.duration(300)
|
||||||
.attr('fill', d => this.getNodeColor(d.status));
|
.attr('fill', d => this.getNodeColor(d.status));
|
||||||
|
|
||||||
// Update primary badge for existing nodes
|
|
||||||
// Remove badge from nodes that are no longer primary
|
|
||||||
nodeMerge.filter(d => !d.isPrimary)
|
|
||||||
.select('.primary-badge')
|
|
||||||
.remove();
|
|
||||||
|
|
||||||
// Add badge to nodes that became primary (if they don't already have one)
|
|
||||||
nodeMerge.filter(d => d.isPrimary)
|
|
||||||
.each(function(d) {
|
|
||||||
const nodeGroup = d3.select(this);
|
|
||||||
if (nodeGroup.select('.primary-badge').empty()) {
|
|
||||||
const badge = nodeGroup.append('g')
|
|
||||||
.attr('class', 'primary-badge')
|
|
||||||
.attr('transform', 'translate(0, 0)');
|
|
||||||
|
|
||||||
badge.append('text')
|
|
||||||
.text('A')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'central')
|
|
||||||
.attr('font-size', '12px')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('fill', '#000');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fade in only new nodes (not existing ones)
|
// Fade in only new nodes (not existing ones)
|
||||||
nodeEnter.transition()
|
nodeEnter.transition()
|
||||||
.duration(300)
|
.duration(300)
|
||||||
@@ -744,43 +689,13 @@ class TopologyGraphComponent extends Component {
|
|||||||
|
|
||||||
// Add interactions
|
// Add interactions
|
||||||
this.nodeSelection
|
this.nodeSelection
|
||||||
.on('click', async (event, d) => {
|
.on('click', (event, d) => {
|
||||||
event.stopPropagation();
|
this.viewModel.selectNode(d.id);
|
||||||
|
this.updateSelection(d.id);
|
||||||
// Check if we're toggling back to mesh (clicking same center node in star mode)
|
if (this.isDesktop()) {
|
||||||
const currentMode = this.viewModel.get('topologyMode');
|
this.openDrawerForNode(d);
|
||||||
const currentCenter = this.viewModel.get('starCenterNode');
|
|
||||||
const togglingToMesh = currentMode === 'star' && currentCenter === d.ip;
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
try {
|
|
||||||
logger.info(`TopologyGraphComponent: Switching primary to ${d.ip}`);
|
|
||||||
await this.viewModel.switchToPrimaryNode(d.ip);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('TopologyGraphComponent: Failed to switch primary:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open or close drawer based on mode toggle
|
|
||||||
if (togglingToMesh) {
|
|
||||||
// Switching back to mesh - close drawer
|
|
||||||
this.viewModel.clearSelection();
|
|
||||||
if (this.isDesktop()) {
|
|
||||||
this.closeDrawer();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Switching to star or changing center - open drawer
|
this.showMemberCardOverlay(d);
|
||||||
this.viewModel.selectNode(d.id);
|
|
||||||
this.updateSelection(d.id);
|
|
||||||
if (this.isDesktop()) {
|
|
||||||
this.openDrawerForNode(d);
|
|
||||||
} else {
|
|
||||||
this.showMemberCardOverlay(d);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('mouseover', (event, d) => {
|
.on('mouseover', (event, d) => {
|
||||||
@@ -801,33 +716,15 @@ 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()) {
|
||||||
linkLabelGroup = svgGroup.append('g').attr('class', 'link-label-group graph-element');
|
linkLabelGroup = svgGroup.append('g').attr('class', 'link-label-group graph-element');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bind data with same key function as updateLinks for consistency
|
// Bind data
|
||||||
const linkLabels = linkLabelGroup.selectAll('text')
|
const linkLabels = linkLabelGroup.selectAll('text')
|
||||||
.data(links, d => {
|
.data(links, d => `${d.source.id || d.source}-${d.target.id || d.target}`);
|
||||||
const sourceId = typeof d.source === 'object' ? d.source.id : d.source;
|
|
||||||
const targetId = typeof d.target === 'object' ? d.target.id : d.target;
|
|
||||||
return sourceId < targetId ? `${sourceId}-${targetId}` : `${targetId}-${sourceId}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove old labels
|
// Remove old labels
|
||||||
linkLabels.exit()
|
linkLabels.exit()
|
||||||
@@ -901,72 +798,18 @@ class TopologyGraphComponent extends Component {
|
|||||||
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
const currentNodeIds = new Set(currentNodes.map(n => n.id));
|
||||||
const newNodeIds = new Set(nodes.map(n => n.id));
|
const newNodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
|
||||||
// Check if nodes changed
|
const isStructuralChange = currentNodes.length !== nodes.length ||
|
||||||
const nodesChanged = currentNodes.length !== nodes.length ||
|
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
||||||
[...newNodeIds].some(id => !currentNodeIds.has(id)) ||
|
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
||||||
[...currentNodeIds].some(id => !newNodeIds.has(id));
|
|
||||||
|
|
||||||
// Check if links changed (compare link keys)
|
|
||||||
const currentLinks = this.simulation.force('link').links();
|
|
||||||
const currentLinkKeys = new Set(currentLinks.map(l => {
|
|
||||||
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
|
|
||||||
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
||||||
return `${sourceId}->${targetId}`;
|
|
||||||
}));
|
|
||||||
const newLinkKeys = new Set(links.map(l => {
|
|
||||||
const sourceId = typeof l.source === 'object' ? l.source.id : l.source;
|
|
||||||
const targetId = typeof l.target === 'object' ? l.target.id : l.target;
|
|
||||||
return `${sourceId}->${targetId}`;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const linksChanged = currentLinks.length !== links.length ||
|
|
||||||
[...newLinkKeys].some(key => !currentLinkKeys.has(key)) ||
|
|
||||||
[...currentLinkKeys].some(key => !newLinkKeys.has(key));
|
|
||||||
|
|
||||||
const isStructuralChange = nodesChanged || linksChanged;
|
|
||||||
|
|
||||||
// Update simulation data
|
// Update simulation data
|
||||||
this.simulation.nodes(nodes);
|
this.simulation.nodes(nodes);
|
||||||
this.simulation.force('link').links(links);
|
this.simulation.force('link').links(links);
|
||||||
|
|
||||||
if (isStructuralChange) {
|
if (isStructuralChange) {
|
||||||
if (linksChanged && !nodesChanged) {
|
// Structural change: restart with moderate alpha
|
||||||
// Only links changed (e.g., primary node switch)
|
logger.debug('TopologyGraphComponent: Structural change detected, restarting simulation');
|
||||||
// Keep nodes in place, just update link positions
|
this.simulation.alpha(0.3).restart();
|
||||||
logger.debug('TopologyGraphComponent: Link structure changed (primary switch), keeping node positions');
|
|
||||||
|
|
||||||
// Stop the simulation completely
|
|
||||||
this.simulation.stop();
|
|
||||||
|
|
||||||
// Trigger a single tick to update link positions with the new data
|
|
||||||
// This ensures D3's forceLink properly connects sources and targets
|
|
||||||
this.simulation.tick();
|
|
||||||
|
|
||||||
// Manually update the visual positions immediately
|
|
||||||
if (this.linkSelection) {
|
|
||||||
this.linkSelection
|
|
||||||
.attr('x1', d => d.source.x)
|
|
||||||
.attr('y1', d => d.source.y)
|
|
||||||
.attr('x2', d => d.target.x)
|
|
||||||
.attr('y2', d => d.target.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.linkLabelSelection) {
|
|
||||||
this.linkLabelSelection
|
|
||||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
|
||||||
.attr('y', d => (d.source.y + d.target.y) / 2 - 5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep simulation stopped - nodes won't move
|
|
||||||
} else if (nodesChanged) {
|
|
||||||
// Nodes added/removed: use moderate restart
|
|
||||||
logger.debug('TopologyGraphComponent: Node structure changed, moderate restart');
|
|
||||||
this.simulation.alpha(0.3).restart();
|
|
||||||
} else {
|
|
||||||
// Just links changed with same nodes
|
|
||||||
logger.debug('TopologyGraphComponent: Link structure changed, restarting simulation');
|
|
||||||
this.simulation.alpha(0.2).restart();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Property-only change: just update data, no restart needed
|
// Property-only change: just update data, no restart needed
|
||||||
// The simulation keeps running at its current alpha
|
// The simulation keeps running at its current alpha
|
||||||
@@ -977,8 +820,10 @@ class TopologyGraphComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addLegend(svgGroup) {
|
addLegend(svgGroup) {
|
||||||
// Remove existing legend to recreate with current mode
|
// Only add legend if it doesn't exist
|
||||||
svgGroup.select('.legend-group').remove();
|
if (!svgGroup.select('.legend-group').empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const legend = svgGroup.append('g')
|
const legend = svgGroup.append('g')
|
||||||
.attr('class', 'legend-group graph-element')
|
.attr('class', 'legend-group graph-element')
|
||||||
@@ -986,8 +831,8 @@ class TopologyGraphComponent extends Component {
|
|||||||
.style('opacity', '0');
|
.style('opacity', '0');
|
||||||
|
|
||||||
legend.append('rect')
|
legend.append('rect')
|
||||||
.attr('width', 420)
|
.attr('width', 320)
|
||||||
.attr('height', 140)
|
.attr('height', 120)
|
||||||
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
.attr('fill', 'rgba(0, 0, 0, 0.7)')
|
||||||
.attr('rx', 8)
|
.attr('rx', 8)
|
||||||
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
.attr('stroke', 'rgba(255, 255, 255, 0.2)')
|
||||||
@@ -1058,61 +903,6 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('font-size', '12px')
|
.attr('font-size', '12px')
|
||||||
.attr('fill', '#ecf0f1');
|
.attr('fill', '#ecf0f1');
|
||||||
});
|
});
|
||||||
|
|
||||||
const topologyLegend = legend.append('g')
|
|
||||||
.attr('transform', 'translate(280, 20)');
|
|
||||||
|
|
||||||
topologyLegend.append('text')
|
|
||||||
.text('Topology:')
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', 0)
|
|
||||||
.attr('font-size', '14px')
|
|
||||||
.attr('font-weight', '600')
|
|
||||||
.attr('fill', '#ecf0f1');
|
|
||||||
|
|
||||||
// Primary node indicator
|
|
||||||
const primaryBadge = topologyLegend.append('g')
|
|
||||||
.attr('transform', 'translate(0, 20)');
|
|
||||||
|
|
||||||
primaryBadge.append('circle')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('fill', '#FFD700')
|
|
||||||
.attr('stroke', '#FFF')
|
|
||||||
.attr('stroke-width', 1);
|
|
||||||
|
|
||||||
primaryBadge.append('text')
|
|
||||||
.text('A')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('dominant-baseline', 'central')
|
|
||||||
.attr('font-size', '8px')
|
|
||||||
.attr('font-weight', 'bold')
|
|
||||||
.attr('fill', '#000');
|
|
||||||
|
|
||||||
topologyLegend.append('text')
|
|
||||||
.text('Primary Node')
|
|
||||||
.attr('x', 15)
|
|
||||||
.attr('y', 24)
|
|
||||||
.attr('font-size', '12px')
|
|
||||||
.attr('fill', '#ecf0f1');
|
|
||||||
|
|
||||||
// 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')
|
|
||||||
.text(modeText)
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', 50)
|
|
||||||
.attr('font-size', '12px')
|
|
||||||
.attr('fill', '#ecf0f1');
|
|
||||||
|
|
||||||
topologyLegend.append('text')
|
|
||||||
.text(modeDesc)
|
|
||||||
.attr('x', 0)
|
|
||||||
.attr('y', 65)
|
|
||||||
.attr('font-size', '10px')
|
|
||||||
.attr('fill', 'rgba(236, 240, 241, 0.7)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeRadius(status) {
|
getNodeRadius(status) {
|
||||||
@@ -1235,148 +1025,22 @@ class TopologyGraphComponent extends Component {
|
|||||||
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
|
.attr('stroke', d => d.id === selectedNodeId ? '#2196F3' : '#fff');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDiscoveryEvent(discoveryEvent) {
|
|
||||||
if (!discoveryEvent || !discoveryEvent.nodeIp) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit discovery dots from the discovered node to all other nodes
|
|
||||||
this.emitDiscoveryDots(discoveryEvent.nodeIp);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitDiscoveryDots(sourceNodeIp) {
|
|
||||||
if (!this.svg || !this.isInitialized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = this.viewModel.get('nodes') || [];
|
|
||||||
const sourceNode = nodes.find(n => n.ip === sourceNodeIp);
|
|
||||||
|
|
||||||
if (!sourceNode || !sourceNode.x || !sourceNode.y) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the main graph group (that has the transform applied)
|
|
||||||
const mainGroup = this.svg.select('g');
|
|
||||||
|
|
||||||
// Get or create animation group inside the main transformed group
|
|
||||||
let animGroup = mainGroup.select('.discovery-animation-group');
|
|
||||||
if (animGroup.empty()) {
|
|
||||||
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
|
|
||||||
nodes.forEach(targetNode => {
|
|
||||||
if (targetNode.ip !== sourceNodeIp && targetNode.x && targetNode.y) {
|
|
||||||
this.animateDiscoveryDot(animGroup, sourceNode, targetNode);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
animateDiscoveryDot(animGroup, sourceNode, targetNode) {
|
|
||||||
// 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')
|
|
||||||
.attr('class', 'discovery-dot')
|
|
||||||
.attr('r', 6)
|
|
||||||
.attr('cx', spawnX)
|
|
||||||
.attr('cy', spawnY)
|
|
||||||
.attr('fill', '#FFD700')
|
|
||||||
.attr('opacity', 1)
|
|
||||||
.attr('stroke', '#FFF')
|
|
||||||
.attr('stroke-width', 2)
|
|
||||||
.style('pointer-events', 'none'); // Make dots non-interactive
|
|
||||||
|
|
||||||
// Trigger response dot early (after 70% of the journey) - only if target node is active
|
|
||||||
setTimeout(() => {
|
|
||||||
if (targetNode.status && targetNode.status.toUpperCase() === 'ACTIVE') {
|
|
||||||
this.animateResponseDot(animGroup, targetNode, sourceNode);
|
|
||||||
}
|
|
||||||
}, 1050); // 1500ms * 0.7 = 1050ms
|
|
||||||
|
|
||||||
// Animate the dot to the target node
|
|
||||||
dot.transition()
|
|
||||||
.duration(1500)
|
|
||||||
.ease(d3.easeCubicInOut)
|
|
||||||
.attr('cx', targetNode.x)
|
|
||||||
.attr('cy', targetNode.y)
|
|
||||||
.attr('opacity', 0)
|
|
||||||
.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
animateResponseDot(animGroup, sourceNode, targetNode) {
|
|
||||||
// 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')
|
|
||||||
.attr('class', 'discovery-dot-response')
|
|
||||||
.attr('r', 5)
|
|
||||||
.attr('cx', spawnX)
|
|
||||||
.attr('cy', spawnY)
|
|
||||||
.attr('fill', '#2196F3')
|
|
||||||
.attr('opacity', 1)
|
|
||||||
.attr('stroke', '#FFF')
|
|
||||||
.attr('stroke-width', 2)
|
|
||||||
.style('pointer-events', 'none'); // Make dots non-interactive
|
|
||||||
|
|
||||||
// Animate back to outside the original source node
|
|
||||||
responseDot.transition()
|
|
||||||
.duration(1000)
|
|
||||||
.ease(d3.easeCubicInOut)
|
|
||||||
.attr('cx', targetX)
|
|
||||||
.attr('cy', targetY)
|
|
||||||
.attr('opacity', 0)
|
|
||||||
.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: This method is deprecated and should not be used
|
|
||||||
// The topology graph is now entirely websocket-driven
|
|
||||||
// Refresh button was removed and all updates come from websocket events
|
|
||||||
handleRefresh() {
|
handleRefresh() {
|
||||||
logger.warn('TopologyGraphComponent: handleRefresh called - this method is deprecated');
|
logger.debug('TopologyGraphComponent: handleRefresh called');
|
||||||
logger.warn('TopologyGraphComponent: Topology updates should come from websocket events only');
|
|
||||||
// No-op - do not make API calls
|
if (!this.isInitialized) {
|
||||||
|
logger.debug('TopologyGraphComponent: Component not yet initialized, ensuring initialization...');
|
||||||
|
this.ensureInitialized().then(() => {
|
||||||
|
// Refresh after initialization
|
||||||
|
this.viewModel.updateNetworkTopology();
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error('TopologyGraphComponent: Failed to initialize for refresh:', error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('TopologyGraphComponent: Calling updateNetworkTopology...');
|
||||||
|
this.viewModel.updateNetworkTopology();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLoadingState(isLoading) {
|
handleLoadingState(isLoading) {
|
||||||
|
|||||||
@@ -584,9 +584,7 @@ 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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,14 +601,13 @@ class TopologyViewModel extends ViewModel {
|
|||||||
|
|
||||||
// Update topology from WebSocket data
|
// Update topology from WebSocket data
|
||||||
if (data.members && Array.isArray(data.members)) {
|
if (data.members && Array.isArray(data.members)) {
|
||||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members, primary: ${data.primaryNode}`);
|
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members`);
|
||||||
|
|
||||||
// Build enhanced graph data from updated members with primary node info
|
// Build enhanced graph data from updated members
|
||||||
this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => {
|
this.buildEnhancedGraphData(data.members).then(({ nodes, links }) => {
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
links: links,
|
links: links,
|
||||||
primaryNode: data.primaryNode,
|
|
||||||
lastUpdateTime: data.timestamp || new Date().toISOString()
|
lastUpdateTime: data.timestamp || new Date().toISOString()
|
||||||
});
|
});
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -624,24 +621,27 @@ class TopologyViewModel extends ViewModel {
|
|||||||
// Listen for node discovery events
|
// Listen for node discovery events
|
||||||
window.wsClient.on('nodeDiscovery', (data) => {
|
window.wsClient.on('nodeDiscovery', (data) => {
|
||||||
logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data);
|
logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data);
|
||||||
|
|
||||||
// Trigger animation for 'discovered' or 'active' actions (node is alive)
|
if (data.action === 'discovered') {
|
||||||
// Skip animation for 'stale' or 'inactive' actions
|
// A new node was discovered - trigger a topology update
|
||||||
if (data.action === 'discovered' || data.action === 'active') {
|
setTimeout(() => {
|
||||||
// Emit discovery animation event
|
this.updateNetworkTopology();
|
||||||
this.set('discoveryEvent', {
|
}, 500);
|
||||||
nodeIp: data.nodeIp,
|
} else if (data.action === 'stale') {
|
||||||
timestamp: data.timestamp || new Date().toISOString(),
|
// A node became stale - trigger a topology update
|
||||||
action: data.action
|
setTimeout(() => {
|
||||||
});
|
this.updateNetworkTopology();
|
||||||
|
}, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for connection status changes
|
// Listen for connection status changes
|
||||||
window.wsClient.on('connected', () => {
|
window.wsClient.on('connected', () => {
|
||||||
logger.debug('TopologyViewModel: WebSocket connected');
|
logger.debug('TopologyViewModel: WebSocket connected');
|
||||||
// Connection restored - the server will send a clusterUpdate event shortly
|
// Trigger an immediate update when connection is restored
|
||||||
// No need to make an API call, just wait for the websocket data
|
setTimeout(() => {
|
||||||
|
this.updateNetworkTopology();
|
||||||
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.wsClient.on('disconnected', () => {
|
window.wsClient.on('disconnected', () => {
|
||||||
@@ -650,11 +650,9 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update network topology data
|
// Update network topology data
|
||||||
// NOTE: This method makes an API call and should only be used for initial load
|
|
||||||
// All subsequent updates should come from websocket events (clusterUpdate)
|
|
||||||
async updateNetworkTopology() {
|
async updateNetworkTopology() {
|
||||||
try {
|
try {
|
||||||
logger.debug('TopologyViewModel: updateNetworkTopology called (API call)');
|
logger.debug('TopologyViewModel: updateNetworkTopology called');
|
||||||
|
|
||||||
this.set('isLoading', true);
|
this.set('isLoading', true);
|
||||||
this.set('error', null);
|
this.set('error', null);
|
||||||
@@ -663,24 +661,14 @@ class TopologyViewModel extends ViewModel {
|
|||||||
const response = await window.apiClient.getClusterMembers();
|
const response = await window.apiClient.getClusterMembers();
|
||||||
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
logger.debug('TopologyViewModel: Got cluster members response:', response);
|
||||||
|
|
||||||
// Get discovery info to find the primary node
|
|
||||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
|
||||||
logger.debug('TopologyViewModel: Got discovery info:', discoveryInfo);
|
|
||||||
|
|
||||||
const members = response.members || [];
|
const members = response.members || [];
|
||||||
const primaryNode = discoveryInfo.primaryNode || null;
|
|
||||||
|
|
||||||
logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`);
|
|
||||||
|
|
||||||
// Build enhanced graph data with actual node connections
|
// Build enhanced graph data with actual node connections
|
||||||
const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode);
|
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||||
|
|
||||||
logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`);
|
|
||||||
|
|
||||||
this.batchUpdate({
|
this.batchUpdate({
|
||||||
nodes: nodes,
|
nodes: nodes,
|
||||||
links: links,
|
links: links,
|
||||||
primaryNode: primaryNode,
|
|
||||||
lastUpdateTime: new Date().toISOString()
|
lastUpdateTime: new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -694,10 +682,10 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build enhanced graph data with actual node connections
|
// Build enhanced graph data with actual node connections
|
||||||
// Creates either a mesh topology (all nodes connected) or star topology (center node to all others)
|
async buildEnhancedGraphData(members) {
|
||||||
async buildEnhancedGraphData(members, primaryNode) {
|
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
const links = [];
|
const links = [];
|
||||||
|
const nodeConnections = new Map();
|
||||||
|
|
||||||
// Get existing nodes to preserve their positions
|
// Get existing nodes to preserve their positions
|
||||||
const existingNodes = this.get('nodes') || [];
|
const existingNodes = this.get('nodes') || [];
|
||||||
@@ -707,7 +695,6 @@ class TopologyViewModel extends ViewModel {
|
|||||||
members.forEach((member, index) => {
|
members.forEach((member, index) => {
|
||||||
if (member && member.ip) {
|
if (member && member.ip) {
|
||||||
const existingNode = existingNodeMap.get(member.ip);
|
const existingNode = existingNodeMap.get(member.ip);
|
||||||
const isPrimary = member.ip === primaryNode;
|
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: member.ip,
|
id: member.ip,
|
||||||
@@ -715,7 +702,6 @@ class TopologyViewModel extends ViewModel {
|
|||||||
ip: member.ip,
|
ip: member.ip,
|
||||||
status: member.status || 'UNKNOWN',
|
status: member.status || 'UNKNOWN',
|
||||||
latency: member.latency || 0,
|
latency: member.latency || 0,
|
||||||
isPrimary: isPrimary,
|
|
||||||
// Preserve both legacy 'resources' and preferred 'labels'
|
// Preserve both legacy 'resources' and preferred 'labels'
|
||||||
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||||
resources: member.resources || {},
|
resources: member.resources || {},
|
||||||
@@ -732,53 +718,70 @@ class TopologyViewModel extends ViewModel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build links based on topology mode
|
// Try to get cluster members from each node to build actual connections
|
||||||
const topologyMode = this.get('topologyMode') || 'mesh';
|
for (const node of nodes) {
|
||||||
|
try {
|
||||||
|
const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip);
|
||||||
|
if (nodeResponse && nodeResponse.members) {
|
||||||
|
nodeConnections.set(node.ip, nodeResponse.members);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
|
||||||
|
// Continue with other nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (topologyMode === 'mesh') {
|
// Build links based on actual connections
|
||||||
// Full mesh - connect all nodes to all other nodes
|
for (const [sourceIp, sourceMembers] of nodeConnections) {
|
||||||
logger.debug('TopologyViewModel: Creating full mesh topology');
|
for (const targetMember of sourceMembers) {
|
||||||
|
if (targetMember.ip && targetMember.ip !== sourceIp) {
|
||||||
|
// Check if we already have this link
|
||||||
|
const existingLink = links.find(link =>
|
||||||
|
(link.source === sourceIp && link.target === targetMember.ip) ||
|
||||||
|
(link.source === targetMember.ip && link.target === sourceIp)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingLink) {
|
||||||
|
const sourceNode = nodes.find(n => n.id === sourceIp);
|
||||||
|
const targetNode = nodes.find(n => n.id === targetMember.ip);
|
||||||
|
|
||||||
|
if (sourceNode && targetNode) {
|
||||||
|
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
source: sourceIp,
|
||||||
|
target: targetMember.ip,
|
||||||
|
latency: latency,
|
||||||
|
sourceNode: sourceNode,
|
||||||
|
targetNode: targetNode,
|
||||||
|
bidirectional: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no actual connections found, create a basic mesh
|
||||||
|
if (links.length === 0) {
|
||||||
|
logger.debug('TopologyViewModel: No actual connections found, creating basic mesh');
|
||||||
for (let i = 0; i < nodes.length; i++) {
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
for (let j = i + 1; j < nodes.length; j++) {
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
const sourceNode = nodes[i];
|
const sourceNode = nodes[i];
|
||||||
const targetNode = nodes[j];
|
const targetNode = nodes[j];
|
||||||
|
|
||||||
|
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
|
||||||
|
|
||||||
links.push({
|
links.push({
|
||||||
source: sourceNode.id,
|
source: sourceNode.id,
|
||||||
target: targetNode.id,
|
target: targetNode.id,
|
||||||
latency: this.estimateLatency(sourceNode, targetNode),
|
latency: estimatedLatency,
|
||||||
sourceNode: sourceNode,
|
sourceNode: sourceNode,
|
||||||
targetNode: targetNode,
|
targetNode: targetNode,
|
||||||
bidirectional: true
|
bidirectional: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.debug(`TopologyViewModel: Created ${links.length} links in mesh topology`);
|
|
||||||
} else if (topologyMode === 'star') {
|
|
||||||
// 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 };
|
||||||
@@ -801,61 +804,6 @@ class TopologyViewModel extends ViewModel {
|
|||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.set('selectedNode', null);
|
this.set('selectedNode', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to a specific node as the new primary
|
|
||||||
async switchToPrimaryNode(nodeIp) {
|
|
||||||
try {
|
|
||||||
logger.debug(`TopologyViewModel: Switching primary node to ${nodeIp}`);
|
|
||||||
const result = await window.apiClient.setPrimaryNode(nodeIp);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
logger.info(`TopologyViewModel: Successfully switched primary to ${nodeIp}`);
|
|
||||||
|
|
||||||
// Update topology after a short delay to allow backend to update
|
|
||||||
setTimeout(async () => {
|
|
||||||
logger.debug('TopologyViewModel: Refreshing topology after primary switch...');
|
|
||||||
await this.updateNetworkTopology();
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} else {
|
|
||||||
throw new Error(result.message || 'Failed to switch primary node');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('TopologyViewModel: Failed to switch primary node:', 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
|
||||||
@@ -1235,200 +1183,4 @@ 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,127 +3,6 @@
|
|||||||
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;
|
||||||
@@ -189,7 +68,6 @@ button:disabled {
|
|||||||
/* === Icon-Only Button Style (Minimal) === */
|
/* === Icon-Only Button Style (Minimal) === */
|
||||||
.btn-icon,
|
.btn-icon,
|
||||||
.theme-toggle,
|
.theme-toggle,
|
||||||
.random-primary-toggle,
|
|
||||||
.burger-btn,
|
.burger-btn,
|
||||||
.primary-node-refresh,
|
.primary-node-refresh,
|
||||||
.filter-pill-remove,
|
.filter-pill-remove,
|
||||||
@@ -218,7 +96,6 @@ button:disabled {
|
|||||||
|
|
||||||
.btn-icon:hover,
|
.btn-icon:hover,
|
||||||
.theme-toggle:hover,
|
.theme-toggle:hover,
|
||||||
.random-primary-toggle:hover,
|
|
||||||
.burger-btn:hover,
|
.burger-btn:hover,
|
||||||
.primary-node-refresh:hover,
|
.primary-node-refresh:hover,
|
||||||
.filter-pill-remove:hover,
|
.filter-pill-remove:hover,
|
||||||
@@ -650,45 +527,6 @@ p {
|
|||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.random-primary-toggle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-primary-toggle svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-primary-toggle:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-primary-toggle:hover svg {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-primary-toggle.spinning svg {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.random-primary-toggle:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Topology graph node interactions */
|
|
||||||
#topology-graph-container .node, #events-graph-container .node {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
#topology-graph-container .node:hover circle:first-child,
|
|
||||||
#events-graph-container .node:hover circle:first-child {
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -4457,7 +4295,7 @@ select.param-input:focus {
|
|||||||
width: 100%; /* Use full container width */
|
width: 100%; /* Use full container width */
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container, #events-graph-container {
|
#topology-graph-container {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -4473,27 +4311,24 @@ 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, #events-graph-container svg {
|
#topology-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, #events-graph-container .error {
|
#topology-graph-container .error {
|
||||||
color: var(--accent-error);
|
color: var(--accent-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
#topology-graph-container .no-data, #events-graph-container .no-data {
|
#topology-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