Compare commits
32 Commits
07be307035
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 09052cbfc2 | |||
| 2a7d170824 | |||
| cbe13f1d84 | |||
| 65b491640b | |||
| f6dc4e8bf3 | |||
| 359d681c72 | |||
| 62badbc692 | |||
| 698a150162 | |||
| 741cc48ce5 | |||
| 74473cbc26 | |||
| b4bd459d27 | |||
| fa6777a042 | |||
| ce836e7636 | |||
| 21f1029805 | |||
| ad268a7c13 | |||
| 61f8c8aa2a | |||
| 531ddbee85 | |||
| c6949c36c1 | |||
| cabc08de29 | |||
| 4fa8d96656 | |||
| cdb42c459a | |||
| 7def7bce81 | |||
| aa7467e1ca | |||
| 30d88d6884 | |||
| 6ed42f9c90 | |||
| 85802c68db | |||
| a7018f53f3 | |||
| 8de77e225d | |||
| 56e54e0b31 | |||
| d166b0b634 | |||
| b6b55c0a6f | |||
| f73dd4d0e9 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
*.md
|
||||
node_modules
|
||||
README.md
|
||||
docs
|
||||
test
|
||||
openapitools.json
|
||||
*.backup
|
||||
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install wget for health checks
|
||||
RUN apk --no-cache add wget
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy application files
|
||||
COPY package*.json ./
|
||||
COPY index.js index-standalone.js ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
# Create non-root user (let Alpine assign available GID/UID)
|
||||
RUN addgroup spore && \
|
||||
adduser -D -s /bin/sh -G spore spore && \
|
||||
chown -R spore:spore /app
|
||||
|
||||
USER spore
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the application
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
54
Makefile
Normal file
54
Makefile
Normal file
@@ -0,0 +1,54 @@
|
||||
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
# Build the application (if needed)
|
||||
build: install
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
node index.js
|
||||
|
||||
# Start in development mode
|
||||
dev:
|
||||
node index.js
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
rm -f package-lock.json
|
||||
|
||||
# Docker variables
|
||||
DOCKER_REGISTRY ?=
|
||||
IMAGE_NAME = wirelos/spore-ui
|
||||
IMAGE_TAG ?= latest
|
||||
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Run Docker container
|
||||
docker-run:
|
||||
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
|
||||
|
||||
# Push Docker image
|
||||
docker-push:
|
||||
docker push $(FULL_IMAGE_NAME)
|
||||
|
||||
# Build multiarch Docker image
|
||||
docker-build-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
# Push multiarch Docker image (if not pushed during build)
|
||||
docker-push-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
207
README.md
207
README.md
@@ -1,182 +1,117 @@
|
||||
# SPORE UI
|
||||
# SPORE UI Frontend
|
||||
|
||||
Zero-configuration web interface for monitoring and managing SPORE embedded systems.
|
||||
Frontend web interface for monitoring and managing SPORE embedded systems. Now works in conjunction with the SPORE Gateway backend service.
|
||||
|
||||
## Architecture
|
||||
|
||||
This frontend server works together with the **SPORE Gateway** (spore-gateway) backend service:
|
||||
|
||||
- **spore-ui**: Serves the static frontend files and provides the user interface
|
||||
- **spore-gateway**: Handles UDP node discovery, API endpoints, and WebSocket connections
|
||||
|
||||
## Features
|
||||
|
||||
- **🌐 Cluster Monitoring**: Real-time view of all cluster members with auto-discovery
|
||||
- **🌐 Cluster Monitoring**: Real-time view of all cluster members via spore-gateway
|
||||
- **📊 Node Details**: Detailed system information including running tasks and available endpoints
|
||||
- **🚀 OTA**: Clusterwide over-the-air firmware updates
|
||||
- **📱 Responsive**: Works on all devices and screen sizes
|
||||
- **🖥️ Terminal**: Terminal for interacting with a node's WebSocket
|
||||
- **🔗 Gateway Integration**: Seamlessly connects to spore-gateway for all backend functionality
|
||||
|
||||
## Screenshots
|
||||
### Cluster
|
||||

|
||||

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

|
||||

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

|
||||

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

|
||||

|
||||
### Firmware
|
||||

|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
1. **Install dependencies**: `npm install`
|
||||
2. **Start the server**: `npm start`
|
||||
3. **Open in browser**: `http://localhost:3001`
|
||||
2. **Start spore-gateway**: `./spore-gateway` (in the spore-gateway directory)
|
||||
3. **Start frontend server**: `npm start`
|
||||
|
||||
## API Endpoints
|
||||
### Access
|
||||
- **Frontend UI**: `http://localhost:3000`
|
||||
- **API Backend**: spore-gateway runs on port 3001
|
||||
- **WebSocket**: Connects to spore-gateway on port 3001
|
||||
|
||||
- **`/`** - Main UI page
|
||||
- **`/api/cluster/members`** - Get cluster member information
|
||||
- **`/api/tasks/status`** - Get task status
|
||||
- **`/api/node/status`** - Get system status
|
||||
- **`/api/node/status/:ip`** - Get status from specific node
|
||||
## API Integration
|
||||
|
||||
The frontend automatically connects to the spore-gateway for:
|
||||
|
||||
- **Cluster Discovery**: `/api/discovery/*` endpoints
|
||||
- **Node Management**: `/api/node/*` endpoints
|
||||
- **Task Monitoring**: `/api/tasks/*` endpoints
|
||||
- **Real-time Updates**: WebSocket connections via `/ws`
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Backend**: Express.js, Node.js
|
||||
- **Backend Integration**: Express.js server connecting to spore-gateway
|
||||
- **Frontend**: Vanilla JavaScript, CSS3, HTML5
|
||||
- **Framework**: Custom component-based architecture
|
||||
- **API**: SPORE Embedded System API
|
||||
- **API**: SPORE Embedded System API via spore-gateway
|
||||
- **Design**: Glassmorphism, CSS Grid, Flexbox
|
||||
|
||||
## UDP Auto Discovery
|
||||
## Development
|
||||
|
||||
The backend now includes automatic UDP discovery for SPORE nodes on the network. This eliminates the need for hardcoded IP addresses and provides a self-healing, scalable solution for managing SPORE clusters.
|
||||
|
||||
### 🚀 How It Works
|
||||
|
||||
1. **UDP Server**: The backend listens on port 4210 for UDP messages
|
||||
2. **Discovery Message**: Nodes send `CLUSTER_DISCOVERY` messages to broadcast address `255.255.255.255:4210`
|
||||
3. **Auto Configuration**: When a discovery message is received, the source IP is automatically used to configure the SporeApiClient
|
||||
4. **Dynamic Updates**: The system automatically switches to the most recently seen node as the primary connection
|
||||
5. **Health Monitoring**: Continuous monitoring of node availability with automatic failover
|
||||
|
||||
### 📡 Discovery Protocol
|
||||
|
||||
- **Port**: 4210 (configurable via `UDP_PORT` constant)
|
||||
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
|
||||
- **Broadcast**: `255.255.255.255:4210`
|
||||
- **Protocol**: UDP broadcast listening
|
||||
- **Auto-binding**: Automatically binds to the specified port on startup
|
||||
|
||||
### 🔧 Setup Instructions
|
||||
|
||||
#### Backend Setup
|
||||
```bash
|
||||
# Start the backend server
|
||||
npm start
|
||||
|
||||
# The server will automatically:
|
||||
# - Start HTTP server on port 3001
|
||||
# - Start UDP discovery server on port 4210
|
||||
# - Wait for CLUSTER_DISCOVERY messages
|
||||
### File Structure
|
||||
```
|
||||
spore-ui/
|
||||
├── public/ # Static frontend files
|
||||
│ ├── index.html # Main HTML page
|
||||
│ ├── scripts/ # JavaScript components
|
||||
│ └── styles/ # CSS stylesheets
|
||||
├── index.js # Simple static file server
|
||||
└── package.json # Node.js dependencies
|
||||
```
|
||||
|
||||
#### Node Configuration
|
||||
SPORE nodes should send discovery messages periodically:
|
||||
### Key Changes
|
||||
- **Simplified Backend**: Now only serves static files
|
||||
- **Gateway Integration**: All API calls go through spore-gateway
|
||||
- **WebSocket Proxy**: Real-time updates via spore-gateway
|
||||
- **UDP Discovery**: Handled by spore-gateway service
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Frontend not connecting to gateway**
|
||||
```bash
|
||||
# Recommended: Send every 30-60 seconds
|
||||
# Message format: "CLUSTER_DISCOVERY"
|
||||
# Target: 255.255.255.255:4210
|
||||
```
|
||||
|
||||
### 🌐 Discovery Endpoints
|
||||
|
||||
#### Discovery Management
|
||||
- `GET /api/discovery/nodes` - View all discovered nodes and current status
|
||||
- `POST /api/discovery/refresh` - Manually trigger discovery refresh
|
||||
- `POST /api/discovery/primary/:ip` - Manually set a specific node as primary
|
||||
- `POST /api/discovery/random-primary` - Randomly select a new primary node
|
||||
|
||||
#### Health Monitoring
|
||||
- `GET /api/health` - Comprehensive health check including discovery status
|
||||
|
||||
### 🧪 Testing & Development
|
||||
|
||||
#### Test Scripts
|
||||
```bash
|
||||
# Send discovery messages to test the system
|
||||
npm run test-discovery broadcast
|
||||
|
||||
# Send to specific IP
|
||||
npm run test-discovery 192.168.1.100
|
||||
|
||||
# Send multiple messages
|
||||
npm run test-discovery broadcast 5
|
||||
|
||||
# Test random primary node selection
|
||||
npm run test-random-selection
|
||||
|
||||
# Monitor discovery in real-time
|
||||
npm run demo-discovery
|
||||
```
|
||||
|
||||
#### Manual Testing
|
||||
```bash
|
||||
# Check discovery status
|
||||
curl http://localhost:3001/api/discovery/nodes
|
||||
|
||||
# Check health
|
||||
# Check if spore-gateway is running
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Manual refresh
|
||||
curl -X POST http://localhost:3001/api/discovery/refresh
|
||||
|
||||
# Random primary selection
|
||||
curl -X POST http://localhost:3001/api/discovery/random-primary
|
||||
|
||||
# Set specific primary
|
||||
curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100
|
||||
# Verify gateway health
|
||||
# Should return gateway health status
|
||||
```
|
||||
|
||||
### 🔍 Troubleshooting
|
||||
|
||||
#### Common Issues
|
||||
|
||||
**No Nodes Discovered**
|
||||
**WebSocket connection issues**
|
||||
```bash
|
||||
# Check if backend is running
|
||||
curl http://localhost:3001/api/health
|
||||
# Check WebSocket endpoint
|
||||
curl http://localhost:3001/api/test/websocket
|
||||
|
||||
# Verify UDP port is open
|
||||
netstat -tulpn | grep 4210
|
||||
|
||||
# Send test discovery message
|
||||
npm run test-discovery broadcast
|
||||
# Verify gateway WebSocket server is running
|
||||
```
|
||||
|
||||
**UDP Port Already in Use**
|
||||
**No cluster data**
|
||||
```bash
|
||||
# Check for conflicting processes
|
||||
netstat -tulpn | grep 4210
|
||||
|
||||
# Kill conflicting processes or change port in code
|
||||
# Restart backend server
|
||||
```
|
||||
|
||||
**Client Not Initialized**
|
||||
```bash
|
||||
# Check discovery status
|
||||
# Check gateway discovery status
|
||||
curl http://localhost:3001/api/discovery/nodes
|
||||
|
||||
# Verify nodes are sending discovery messages
|
||||
# Check network connectivity
|
||||
# Verify SPORE nodes are sending heartbeat messages
|
||||
```
|
||||
|
||||
#### Debug Commands
|
||||
```bash
|
||||
# Check discovery status
|
||||
curl http://localhost:3001/api/discovery/nodes
|
||||
## Architecture Benefits
|
||||
|
||||
# Check health
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Manual refresh
|
||||
curl -X POST http://localhost:3001/api/discovery/refresh
|
||||
|
||||
# Set primary node
|
||||
curl -X POST http://localhost:3001/api/discovery/primary/192.168.1.100
|
||||
```
|
||||
1. **Separation of Concerns**: Frontend handles UI, gateway handles backend logic
|
||||
2. **Scalability**: Gateway can handle multiple frontend instances
|
||||
3. **Maintainability**: Clear separation between presentation and business logic
|
||||
4. **Performance**: Gateway can optimize API calls and caching
|
||||
5. **Reliability**: Gateway provides failover and health monitoring
|
||||
|
||||
BIN
assets/events-messages.png
Normal file
BIN
assets/events-messages.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 453 KiB |
BIN
assets/events.png
Normal file
BIN
assets/events.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 372 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 343 KiB |
@@ -8,12 +8,12 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
|
||||
|
||||
### 1. UDP Discovery Server
|
||||
- **Port**: 4210 (configurable via `UDP_PORT` constant)
|
||||
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_MESSAGE` constant)
|
||||
- **Message**: `CLUSTER_HEARTBEAT` (configurable via `HEARTBEAT_MESSAGE` constant)
|
||||
- **Protocol**: UDP broadcast listening
|
||||
- **Auto-binding**: Automatically binds to the specified port on startup
|
||||
|
||||
### 2. Dynamic Node Management
|
||||
- **Automatic Discovery**: Nodes are discovered when they send `CLUSTER_DISCOVERY` messages
|
||||
- **Automatic Discovery**: Nodes are discovered when they send `CLUSTER_HEARTBEAT` messages
|
||||
- **Primary Node Selection**: The most recently seen node becomes the primary connection
|
||||
- **Stale Node Cleanup**: Nodes not seen for 5+ minutes are automatically removed
|
||||
- **Health Monitoring**: Continuous monitoring of node availability
|
||||
@@ -45,14 +45,14 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
|
||||
```
|
||||
1. Backend starts and binds UDP server to port 4210
|
||||
2. HTTP server starts on port 3001
|
||||
3. System waits for CLUSTER_DISCOVERY messages
|
||||
3. System waits for CLUSTER_HEARTBEAT messages
|
||||
4. When messages arrive, nodes are automatically discovered
|
||||
5. SporeApiClient is configured with the first discovered node
|
||||
```
|
||||
|
||||
### 2. Discovery Process
|
||||
```
|
||||
1. Node sends "CLUSTER_DISCOVERY" to 255.255.255.255:4210
|
||||
1. Node sends "CLUSTER_HEARTBEAT:hostname" to 255.255.255.255:4210
|
||||
2. Backend receives message and extracts source IP
|
||||
3. Node is added to discovered nodes list
|
||||
4. If no primary node exists, this becomes the primary
|
||||
@@ -71,11 +71,11 @@ The backend has been successfully updated to implement UDP auto discovery, elimi
|
||||
|
||||
### Environment Variables
|
||||
- `PORT`: HTTP server port (default: 3001)
|
||||
- `UDP_PORT`: UDP discovery port (default: 4210)
|
||||
- `UDP_PORT`: UDP heartbeat port (default: 4210)
|
||||
|
||||
### Constants (in index.js)
|
||||
- `UDP_PORT`: Discovery port (currently 4210)
|
||||
- `DISCOVERY_MESSAGE`: Expected message (currently "CLUSTER_DISCOVERY")
|
||||
- `UDP_PORT`: Heartbeat port (currently 4210)
|
||||
- `HEARTBEAT_MESSAGE`: Expected message (currently "CLUSTER_HEARTBEAT")
|
||||
- Stale timeout: 5 minutes (configurable in `cleanupStaleNodes()`)
|
||||
- Health check interval: 5 seconds (configurable in `setInterval`)
|
||||
|
||||
|
||||
518
docs/Events.md
Normal file
518
docs/Events.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Events Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Real-Time Event Visualization
|
||||
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
|
||||
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
|
||||
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
|
||||
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
|
||||
|
||||
### User Interactions
|
||||
|
||||
#### Drag to Reposition Nodes
|
||||
- **Center Node**: Dragging the center (blue) node moves the entire graph together
|
||||
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
|
||||
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
|
||||
|
||||
#### Zoom Controls
|
||||
- **Mouse Wheel**: Zoom in and out using the mouse wheel
|
||||
- **Scale Range**: Zoom level constrained between 0.5x and 5x
|
||||
- **Pan**: Click and drag the canvas to pan around the graph
|
||||
|
||||
#### Rearrange Layout
|
||||
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
|
||||
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
|
||||
|
||||
### Visual Elements
|
||||
|
||||
#### Node Types
|
||||
- **Center Node** (Blue):
|
||||
- Represents the central event hub
|
||||
- Always visible in the graph
|
||||
- Acts as the root node for all event chains
|
||||
- Larger size (18px radius)
|
||||
|
||||
- **Topic Nodes** (Green):
|
||||
- Represent individual topic segments
|
||||
- Examples: `cluster`, `event`, `api`, `neopattern`
|
||||
- Smaller size (14px radius)
|
||||
- Positioned dynamically based on event frequency
|
||||
|
||||
#### Link Types
|
||||
- **Center Links** (Blue):
|
||||
- Connect the center node to root topic nodes
|
||||
- Thicker lines (2.5px)
|
||||
- Examples: center → `cluster`, center → `event`
|
||||
|
||||
- **Topic Links** (Green):
|
||||
- Connect adjacent topics in event chains
|
||||
- Line thickness proportional to event frequency
|
||||
- Examples: `cluster` → `event`, `api` → `neopattern`
|
||||
|
||||
#### Hover Interactions
|
||||
- **Link Hover**: Links become thicker and more opaque when hovered
|
||||
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
|
||||
```12:37:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Events Component - Visualizes websocket events as a graph
|
||||
class EventComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Center node data - will be initialized with proper coordinates later
|
||||
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
|
||||
|
||||
// Track nodes for D3
|
||||
this.graphNodes = [];
|
||||
this.graphLinks = [];
|
||||
|
||||
// Track recent events to trigger animations
|
||||
this.lastSeenEvents = new Set();
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
The `EventViewModel` manages the state and WebSocket connections:
|
||||
|
||||
```1241:1253:spore-ui/public/scripts/view-models.js
|
||||
// Events View Model for websocket event visualization
|
||||
class EventViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null
|
||||
});
|
||||
|
||||
// Set up WebSocket listeners for real-time updates
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
```
|
||||
|
||||
### Event Tracking
|
||||
|
||||
Events are tracked with the following metadata:
|
||||
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
|
||||
- **Parts**: Array of topic segments split by `/`
|
||||
- **Count**: Total occurrences of this event
|
||||
- **First Seen**: ISO timestamp of first occurrence
|
||||
- **Last Seen**: ISO timestamp of most recent occurrence
|
||||
- **Last Data**: The most recent event payload
|
||||
|
||||
### Event Addition Logic
|
||||
|
||||
The view model handles nested events specially:
|
||||
|
||||
```1283:1329:spore-ui/public/scripts/view-models.js
|
||||
// Add a topic (parsed by "/" separator)
|
||||
addTopic(topic, data = null) {
|
||||
// Get current events as a new Map to ensure change detection
|
||||
const events = new Map(this.get('events'));
|
||||
|
||||
// Handle nested events from cluster/event
|
||||
let fullTopic = topic;
|
||||
if (topic === 'cluster/event' && data && data.data) {
|
||||
try {
|
||||
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
||||
if (parsedData && parsedData.event) {
|
||||
// Create nested topic chain: cluster/event/api/neopattern
|
||||
fullTopic = `${topic}/${parsedData.event}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, just use the original topic
|
||||
}
|
||||
}
|
||||
|
||||
const parts = fullTopic.split('/').filter(p => p);
|
||||
|
||||
if (events.has(fullTopic)) {
|
||||
// Update existing event - create new object to ensure change detection
|
||||
const existing = events.get(fullTopic);
|
||||
events.set(fullTopic, {
|
||||
topic: existing.topic,
|
||||
parts: existing.parts,
|
||||
count: existing.count + 1,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
} else {
|
||||
// Create new event entry
|
||||
events.set(fullTopic, {
|
||||
topic: fullTopic,
|
||||
parts: parts,
|
||||
count: 1,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
}
|
||||
|
||||
// Use set to trigger change notification
|
||||
this.set('events', events);
|
||||
this.set('lastUpdateTime', new Date().toISOString());
|
||||
}
|
||||
```
|
||||
|
||||
## Graph Construction
|
||||
|
||||
### Node Creation
|
||||
|
||||
The graph construction process follows two passes:
|
||||
|
||||
1. **First Pass - Node Creation**:
|
||||
- Creates nodes for each unique topic segment
|
||||
- Preserves existing node positions if they've been dragged
|
||||
- New nodes are positioned near their parent in the hierarchy
|
||||
|
||||
2. **Second Pass - Event Counting**:
|
||||
- Counts occurrences of each topic segment
|
||||
- Updates node counts to reflect event frequency
|
||||
|
||||
### Link Construction
|
||||
|
||||
Links are created as chains representing complete topic paths:
|
||||
|
||||
```283:342:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Build links as chains for each topic
|
||||
// For "cluster/update", create: center -> cluster -> update
|
||||
this.graphLinks = [];
|
||||
const linkSet = new Set(); // Track links to avoid duplicates
|
||||
|
||||
if (events && events.size > 0) {
|
||||
for (const [topic, data] of events) {
|
||||
const parts = data.parts;
|
||||
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
// Connect center to first part
|
||||
const firstPart = parts[0];
|
||||
const centerToFirst = `center-${firstPart}`;
|
||||
if (!linkSet.has(centerToFirst)) {
|
||||
this.graphLinks.push({
|
||||
source: 'center',
|
||||
target: firstPart,
|
||||
type: 'center-link',
|
||||
count: data.count,
|
||||
topic: topic
|
||||
});
|
||||
linkSet.add(centerToFirst);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === centerToFirst
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect each part to the next (creating a chain)
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const source = parts[i];
|
||||
const target = parts[i + 1];
|
||||
const linkKey = `${source}-${target}`;
|
||||
|
||||
if (!linkSet.has(linkKey)) {
|
||||
this.graphLinks.push({
|
||||
source: source,
|
||||
target: target,
|
||||
type: 'topic-link',
|
||||
topic: topic,
|
||||
count: data.count
|
||||
});
|
||||
linkSet.add(linkKey);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === linkKey
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Force Simulation
|
||||
|
||||
The D3.js force simulation provides the physics-based layout:
|
||||
|
||||
```103:111:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Initialize simulation
|
||||
this.simulation = d3.forceSimulation()
|
||||
.force('link', d3.forceLink().id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(35))
|
||||
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
|
||||
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
|
||||
```
|
||||
|
||||
### Force Parameters
|
||||
- **Link Force**: Maintains 100px distance between connected nodes
|
||||
- **Charge Force**: -300 strength creates repulsion between nodes
|
||||
- **Center Force**: Keeps the graph centered in the viewport
|
||||
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
|
||||
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
|
||||
- **Velocity Decay**: High decay (0.4) prevents excessive movement
|
||||
|
||||
## Animation System
|
||||
|
||||
### Event Arrival Animation
|
||||
|
||||
When a new event arrives, a golden animation dot travels along the event chain:
|
||||
|
||||
```725:809:spore-ui/public/scripts/components/EventComponent.js
|
||||
animateEventMessage(topic, data) {
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
const parts = data.parts || topic.split('/').filter(p => p);
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// Wait for the next tick to ensure nodes exist
|
||||
setTimeout(() => {
|
||||
// Build the chain: center -> first -> second -> ... -> last
|
||||
const chain = ['center', ...parts];
|
||||
|
||||
// Animate along each segment of the chain
|
||||
let delay = 0;
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const sourceId = chain[i];
|
||||
const targetId = chain[i + 1];
|
||||
|
||||
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
|
||||
const targetNode = this.graphNodes.find(n => n.id === targetId);
|
||||
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
setTimeout(() => {
|
||||
this.animateDotAlongLink(sourceNode, targetNode, g);
|
||||
}, delay);
|
||||
|
||||
// Add delay between segments (staggered animation)
|
||||
delay += 400; // Duration (300ms) + small gap (100ms)
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
animateDotAlongLink(sourceNode, targetNode, g) {
|
||||
if (!sourceNode || !targetNode || !g) return;
|
||||
|
||||
// Calculate positions
|
||||
const dx = targetNode.x - sourceNode.x;
|
||||
const dy = targetNode.y - sourceNode.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length === 0) return;
|
||||
|
||||
// Normalize direction
|
||||
const nx = dx / length;
|
||||
const ny = dy / length;
|
||||
|
||||
// Calculate node radii
|
||||
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
|
||||
const targetRadius = targetNode.type === 'center' ? 18 : 14;
|
||||
|
||||
// Start from edge of source node
|
||||
const startX = sourceNode.x + nx * sourceRadius;
|
||||
const startY = sourceNode.y + ny * sourceRadius;
|
||||
|
||||
// End at edge of target node
|
||||
const endX = targetNode.x - nx * targetRadius;
|
||||
const endY = targetNode.y - ny * targetRadius;
|
||||
|
||||
// Create animation dot
|
||||
const animationGroup = g.append('g').attr('class', 'animation-group');
|
||||
|
||||
const dot = animationGroup.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('cx', startX)
|
||||
.attr('cy', startY);
|
||||
|
||||
// Animate the dot along the path
|
||||
dot.transition()
|
||||
.duration(300)
|
||||
.ease(d3.easeLinear)
|
||||
.attr('cx', endX)
|
||||
.attr('cy', endY)
|
||||
.on('end', function() {
|
||||
// Fade out after reaching destination
|
||||
dot.transition()
|
||||
.duration(100)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
animationGroup.remove();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Characteristics
|
||||
- **Golden Dot**: Visual indicator traveling along event chains
|
||||
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
|
||||
- **Duration**: 300ms per segment with 100ms delay between segments
|
||||
- **Smooth Motion**: Linear easing for consistent speed
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
The Events view subscribes to all WebSocket messages through the gateway:
|
||||
|
||||
```1256:1280:spore-ui/public/scripts/view-models.js
|
||||
// Set up WebSocket event listeners
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
// Retry after a short delay to allow wsClient to initialize
|
||||
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for all websocket messages
|
||||
window.wsClient.on('message', (data) => {
|
||||
const topic = data.topic || data.type;
|
||||
|
||||
if (topic) {
|
||||
this.addTopic(topic, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.info('EventViewModel: WebSocket connected');
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('EventViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Event Types Tracked
|
||||
|
||||
All WebSocket messages are captured and displayed, including:
|
||||
- **cluster/update**: Node discovery and cluster membership changes
|
||||
- **cluster/event**: Arbitrary events from cluster members
|
||||
- **api/tasks**: Task execution notifications
|
||||
- **api/config**: Configuration updates
|
||||
- **node/***: Node-specific events
|
||||
- Any custom topics sent by SPORE nodes
|
||||
|
||||
## Empty State
|
||||
|
||||
When no events have been received, the view displays a helpful message:
|
||||
|
||||
```604:632:spore-ui/public/scripts/components/EventComponent.js
|
||||
showEmptyState() {
|
||||
if (!this.isInitialized || !this.svg) return;
|
||||
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
// Remove any existing message
|
||||
g.selectAll('.empty-state').remove();
|
||||
|
||||
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
|
||||
// We need to place the text in the center of the transformed coordinate space
|
||||
const transformX = -200;
|
||||
const transformY = -150;
|
||||
const scale = 1.4;
|
||||
|
||||
// Calculate centered position in transformed space
|
||||
const x = ((this.width / 2) - transformX) / scale;
|
||||
const y = ((this.height / 2) - transformY) / scale;
|
||||
|
||||
const emptyMsg = g.append('text')
|
||||
.attr('class', 'empty-state')
|
||||
.attr('x', x)
|
||||
.attr('y', y)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '18px')
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-weight', '500')
|
||||
.text('Waiting for websocket events...');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Performance Considerations
|
||||
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
|
||||
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
|
||||
3. **Animation**: Rapid event bursts may cause overlapping animations
|
||||
|
||||
### Usage Tips
|
||||
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
|
||||
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
|
||||
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
|
||||
4. **Interactive Exploration**: Hover over links to see event frequencies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Events Showing
|
||||
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
|
||||
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
|
||||
- **Browser Console**: Check for any JavaScript errors
|
||||
|
||||
### Graph Not Updating
|
||||
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
|
||||
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
|
||||
|
||||
### Performance Issues
|
||||
- **Clear Events**: Reload the page to clear accumulated events
|
||||
- **Reduce Events**: Limit the event rate in your SPORE nodes
|
||||
- **Browser Settings**: Ensure hardware acceleration is enabled
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Dependencies
|
||||
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
|
||||
- **SPORE Gateway**: WebSocket connection to cluster event stream
|
||||
- **Component Framework**: Built on the SPORE UI component architecture
|
||||
|
||||
### Browser Compatibility
|
||||
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
|
||||
- **SVG Support**: Requires modern browser with full SVG support
|
||||
- **WebSocket Support**: Native WebSocket API required
|
||||
|
||||
### View Structure
|
||||
|
||||
```254:262:spore-ui/public/index.html
|
||||
<div id="events-view" class="view-content">
|
||||
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||
<div class="loading">
|
||||
<div>Waiting for websocket events...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.
|
||||
|
||||
169
docs/FIRMWARE_REGISTRY_INTEGRATION.md
Normal file
169
docs/FIRMWARE_REGISTRY_INTEGRATION.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# Firmware Registry Integration
|
||||
|
||||
This document describes the integration of the SPORE Registry into the SPORE UI, replacing the previous firmware upload functionality with a comprehensive CRUD interface for managing firmware in the registry.
|
||||
|
||||
## Overview
|
||||
|
||||
The firmware view has been completely redesigned to provide:
|
||||
|
||||
- **Registry Management**: Full CRUD operations for firmware in the SPORE Registry
|
||||
- **Search & Filter**: Search firmware by name, version, or labels
|
||||
- **Drawer Forms**: Add/edit forms displayed in the existing drawer component
|
||||
- **Real-time Status**: Registry connection status indicator
|
||||
- **Download Support**: Direct download of firmware binaries
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **FirmwareComponent** (`FirmwareComponent.js`)
|
||||
- Main component for the firmware registry interface
|
||||
- Handles CRUD operations and UI interactions
|
||||
- Manages registry connection status
|
||||
|
||||
2. **FirmwareFormComponent** (`FirmwareFormComponent.js`)
|
||||
- Form component for add/edit operations
|
||||
- Used within the drawer component
|
||||
- Handles metadata and file uploads
|
||||
|
||||
3. **API Client Extensions** (`api-client.js`)
|
||||
- New registry API methods added to existing ApiClient
|
||||
- Auto-detection of registry server URL
|
||||
- Support for multipart form data uploads
|
||||
|
||||
### API Integration
|
||||
|
||||
The integration uses the SPORE Registry API endpoints:
|
||||
|
||||
- `GET /health` - Health check
|
||||
- `GET /firmware` - List firmware with optional filtering
|
||||
- `POST /firmware` - Upload firmware with metadata
|
||||
- `GET /firmware/{name}/{version}` - Download firmware binary
|
||||
|
||||
### Registry Server Configuration
|
||||
|
||||
The registry server is expected to run on:
|
||||
- **Localhost**: `http://localhost:8080`
|
||||
- **Remote**: `http://{hostname}:8080`
|
||||
|
||||
The UI automatically detects the appropriate URL based on the current hostname.
|
||||
|
||||
## Features
|
||||
|
||||
### Firmware Management
|
||||
|
||||
- **Add Firmware**: Upload new firmware with metadata and labels
|
||||
- **Edit Firmware**: Modify existing firmware (requires new file upload)
|
||||
- **Download Firmware**: Direct download of firmware binaries
|
||||
- **Delete Firmware**: Remove firmware from registry (not yet implemented in API)
|
||||
|
||||
### Search & Filtering
|
||||
|
||||
- **Text Search**: Search by firmware name, version, or label values
|
||||
- **Real-time Filtering**: Results update as you type
|
||||
- **Label Display**: Visual display of firmware labels with color coding
|
||||
|
||||
### User Interface
|
||||
|
||||
- **Card Layout**: Clean card-based layout for firmware entries
|
||||
- **Action Buttons**: Edit, download, and delete actions for each firmware
|
||||
- **Status Indicators**: Registry connection status with visual feedback
|
||||
- **Loading States**: Proper loading indicators during operations
|
||||
- **Error Handling**: User-friendly error messages and notifications
|
||||
|
||||
### Form Interface
|
||||
|
||||
- **Drawer Integration**: Forms open in the existing drawer component
|
||||
- **Metadata Fields**: Name, version, and custom labels
|
||||
- **File Upload**: Drag-and-drop or click-to-upload file selection
|
||||
- **Label Management**: Add/remove key-value label pairs
|
||||
- **Validation**: Client-side validation with helpful error messages
|
||||
|
||||
## Usage
|
||||
|
||||
### Adding Firmware
|
||||
|
||||
1. Click the "Add Firmware" button in the header
|
||||
2. Fill in the firmware name and version
|
||||
3. Select a firmware file (.bin or .hex)
|
||||
4. Add optional labels (key-value pairs)
|
||||
5. Click "Upload Firmware"
|
||||
|
||||
### Editing Firmware
|
||||
|
||||
1. Click the edit button on any firmware card
|
||||
2. Modify the metadata (name and version are read-only)
|
||||
3. Upload a new firmware file
|
||||
4. Update labels as needed
|
||||
5. Click "Update Firmware"
|
||||
|
||||
### Downloading Firmware
|
||||
|
||||
1. Click the download button on any firmware card
|
||||
2. The firmware binary will be downloaded automatically
|
||||
|
||||
### Searching Firmware
|
||||
|
||||
1. Use the search box to filter firmware
|
||||
2. Search by name, version, or label values
|
||||
3. Results update in real-time
|
||||
|
||||
## Testing
|
||||
|
||||
A test suite is provided to verify the registry integration:
|
||||
|
||||
```bash
|
||||
cd spore-ui/test
|
||||
node registry-integration-test.js
|
||||
```
|
||||
|
||||
The test suite verifies:
|
||||
- Registry health check
|
||||
- List firmware functionality
|
||||
- Upload firmware functionality
|
||||
- Download firmware functionality
|
||||
|
||||
## Configuration
|
||||
|
||||
### Registry Server
|
||||
|
||||
Ensure the SPORE Registry server is running on port 8080:
|
||||
|
||||
```bash
|
||||
cd spore-registry
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### UI Configuration
|
||||
|
||||
The UI automatically detects the registry server URL. No additional configuration is required.
|
||||
|
||||
## Error Handling
|
||||
|
||||
The integration includes comprehensive error handling:
|
||||
|
||||
- **Connection Errors**: Clear indication when registry is unavailable
|
||||
- **Upload Errors**: Detailed error messages for upload failures
|
||||
- **Validation Errors**: Client-side validation with helpful messages
|
||||
- **Network Errors**: Graceful handling of network timeouts and failures
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned improvements include:
|
||||
|
||||
- **Delete Functionality**: Complete delete operation when API supports it
|
||||
- **Bulk Operations**: Select multiple firmware for bulk operations
|
||||
- **Version History**: View and manage firmware version history
|
||||
- **Deployment Integration**: Deploy firmware directly to nodes from registry
|
||||
- **Advanced Filtering**: Filter by date, size, or other metadata
|
||||
|
||||
## Migration Notes
|
||||
|
||||
The previous firmware upload functionality has been completely replaced. The new interface provides:
|
||||
|
||||
- Better organization with the registry
|
||||
- Improved user experience with search and filtering
|
||||
- Consistent UI patterns with the rest of the application
|
||||
- Better error handling and user feedback
|
||||
|
||||
All existing firmware functionality is now handled through the registry interface.
|
||||
78
docs/README.md
Normal file
78
docs/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# SPORE UI Documentation
|
||||
|
||||
This directory contains detailed documentation for the SPORE UI frontend application.
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Documentation
|
||||
|
||||
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
|
||||
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
|
||||
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
|
||||
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
|
||||
|
||||
### Feature Documentation
|
||||
|
||||
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
|
||||
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Events Feature
|
||||
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||
|
||||
**Key Capabilities:**
|
||||
- Real-time event tracking and visualization
|
||||
- Interactive force-directed graph layout
|
||||
- Event counting and frequency visualization
|
||||
- Animated event flow visualization
|
||||
- Topic hierarchy display
|
||||
|
||||
See [Events.md](./Events.md) for complete documentation.
|
||||
|
||||
### Framework Architecture
|
||||
The SPORE UI uses a component-based architecture with:
|
||||
- View Models for state management
|
||||
- Components for UI rendering
|
||||
- Event Bus for pub/sub communication
|
||||
- API Client for backend integration
|
||||
|
||||
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
|
||||
|
||||
### Discovery System
|
||||
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
|
||||
|
||||
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
|
||||
|
||||
### Topology Visualization
|
||||
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
|
||||
|
||||
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Most Popular Pages
|
||||
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
|
||||
2. [Events Feature](./Events.md) - Real-time event visualization
|
||||
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
|
||||
|
||||
### Developer Resources
|
||||
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
|
||||
- Debugging: See [LOGGING.md](./LOGGING.md)
|
||||
- Testing: See individual component documentation
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features or documentation:
|
||||
1. Follow the existing documentation structure
|
||||
2. Include code examples and diagrams where helpful
|
||||
3. Update this README when adding new documentation files
|
||||
4. Maintain consistency with existing documentation style
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For information about other SPORE components:
|
||||
- **SPORE Gateway**: See `spore-gateway/README.md`
|
||||
- **SPORE Registry**: See `spore-registry/README.md`
|
||||
- **SPORE Embedded**: See `spore/README.md`
|
||||
|
||||
248
docs/TOPOLOGY_WEBSOCKET_UPDATE.md
Normal file
248
docs/TOPOLOGY_WEBSOCKET_UPDATE.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 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
|
||||
|
||||
1184
index-standalone.js
Normal file
1184
index-standalone.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 728 B |
@@ -18,7 +18,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">
|
||||
<a href="/cluster" class="nav-tab active" data-view="cluster">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<circle cx="8" cy="10" r="1.5"/>
|
||||
@@ -27,8 +27,8 @@
|
||||
<path d="M9 11l3 3M9 11l6-3"/>
|
||||
</svg>
|
||||
Cluster
|
||||
</button>
|
||||
<button class="nav-tab" data-view="topology">
|
||||
</a>
|
||||
<a href="/topology" class="nav-tab" data-view="topology">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<circle cx="12" cy="4" r="1.6"/>
|
||||
<circle cx="19" cy="9" r="1.6"/>
|
||||
@@ -38,22 +38,42 @@
|
||||
<path d="M12 4L16 18M16 18L5 9M5 9L19 9M19 9L8 18M8 18L12 4"/>
|
||||
</svg>
|
||||
Topology
|
||||
</button>
|
||||
<button class="nav-tab" data-view="monitoring">
|
||||
</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">
|
||||
<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"/>
|
||||
</svg>
|
||||
Monitoring
|
||||
</button>
|
||||
<button class="nav-tab" data-view="firmware">
|
||||
</a>
|
||||
<a href="/firmware" class="nav-tab" data-view="firmware">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
|
||||
<path d="M12 8v8"/>
|
||||
</svg>
|
||||
Firmware
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<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">
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -103,6 +123,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster-header-right">
|
||||
<button class="config-btn" id="config-wifi-btn" title="Configure WiFi settings for visible nodes">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
Config
|
||||
</button>
|
||||
<button class="deploy-btn" id="deploy-firmware-btn" title="Deploy firmware to visible nodes">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px;">
|
||||
<path d="M12 16V4"/>
|
||||
@@ -140,77 +167,51 @@
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<div id="firmware-container">
|
||||
<div class="firmware-overview">
|
||||
<div class="firmware-actions">
|
||||
<div class="action-group">
|
||||
<h3>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
|
||||
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
|
||||
<path d="M12 8v8"/>
|
||||
</svg>
|
||||
Firmware Update
|
||||
</h3>
|
||||
<div class="firmware-upload-compact">
|
||||
<div class="compact-upload-row">
|
||||
<div class="file-upload-area">
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span class="radio-custom"></span>
|
||||
<span class="target-label">All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option specific-node-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span class="radio-custom"></span>
|
||||
<span class="target-label">Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="target-option by-label-option">
|
||||
<input type="radio" name="target-type" value="labels">
|
||||
<span class="radio-custom"></span>
|
||||
<span class="target-label">By Label</span>
|
||||
<select id="label-select" class="label-select"
|
||||
style="min-width: 220px; display: inline-block; vertical-align: middle;">
|
||||
<option value="">Select a label...</option>
|
||||
</select>
|
||||
<div id="selected-labels-container" class="selected-labels"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex"
|
||||
style="display: none;">
|
||||
<button class="upload-btn-compact"
|
||||
onclick="document.getElementById('global-firmware-file').click()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:6px; vertical-align: -2px;">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
Choose File
|
||||
</button>
|
||||
<span class="file-info" id="file-info">No file selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
|
||||
<path d="M12 16V4"/>
|
||||
<path d="M8 8l4-4 4 4"/>
|
||||
<path d="M20 16v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2"/>
|
||||
</svg>
|
||||
Deploy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="firmware-header">
|
||||
<div class="firmware-search">
|
||||
<div class="search-input-wrapper">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" id="firmware-search" placeholder="Search firmware by name, version, or labels (e.g., '1.0.0 base')...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="firmware-nodes-list" id="firmware-nodes-list">
|
||||
<!-- Nodes will be populated here -->
|
||||
<div class="header-actions">
|
||||
<div id="registry-status" class="registry-status">
|
||||
<span class="status-indicator disconnected">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
Registry Disconnected
|
||||
</span>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware list">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="add-btn" id="add-firmware-btn" title="Add new firmware">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Firmware
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="firmware-content">
|
||||
<div id="firmware-list-container" class="firmware-list-container">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading firmware...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,6 +250,16 @@
|
||||
</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>
|
||||
|
||||
<script src="./vendor/d3.v7.min.js"></script>
|
||||
@@ -265,13 +276,17 @@
|
||||
<script src="./scripts/components/ClusterMembersComponent.js"></script>
|
||||
<script src="./scripts/components/OverlayDialogComponent.js"></script>
|
||||
<script src="./scripts/components/FirmwareComponent.js"></script>
|
||||
<script src="./scripts/components/FirmwareFormComponent.js"></script>
|
||||
<script src="./scripts/components/FirmwareUploadComponent.js"></script>
|
||||
<script src="./scripts/components/RolloutComponent.js"></script>
|
||||
<script src="./scripts/components/WiFiConfigComponent.js"></script>
|
||||
<!-- Container/view components after their deps -->
|
||||
<script src="./scripts/components/FirmwareViewComponent.js"></script>
|
||||
<script src="./scripts/components/ClusterViewComponent.js"></script>
|
||||
<script src="./scripts/components/ClusterStatusComponent.js"></script>
|
||||
<script src="./scripts/components/TopologyGraphComponent.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/theme-manager.js"></script>
|
||||
<script src="./scripts/app.js"></script>
|
||||
|
||||
@@ -72,6 +72,13 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setPrimaryNode(ip) {
|
||||
return this.request(`/api/discovery/primary/${encodeURIComponent(ip)}`, {
|
||||
method: 'POST',
|
||||
body: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||
}
|
||||
@@ -136,6 +143,69 @@ class ApiClient {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Registry API methods - now proxied through gateway
|
||||
async getRegistryHealth() {
|
||||
return this.request('/api/registry/health', { method: 'GET' });
|
||||
}
|
||||
|
||||
async uploadFirmwareToRegistry(metadata, firmwareFile) {
|
||||
const formData = new FormData();
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
formData.append('firmware', firmwareFile);
|
||||
|
||||
return this.request('/api/registry/firmware', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
isForm: true,
|
||||
headers: {}
|
||||
});
|
||||
}
|
||||
|
||||
async updateFirmwareMetadata(name, version, metadata) {
|
||||
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
|
||||
method: 'PUT',
|
||||
body: metadata
|
||||
});
|
||||
}
|
||||
|
||||
async listFirmwareFromRegistry(name = null, version = null) {
|
||||
const query = {};
|
||||
if (name) query.name = name;
|
||||
if (version) query.version = version;
|
||||
|
||||
const queryString = Object.keys(query).length ? '?' + new URLSearchParams(query).toString() : '';
|
||||
return this.request(`/api/registry/firmware${queryString}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async downloadFirmwareFromRegistry(name, version) {
|
||||
const response = await fetch(`${this.baseURL}/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Registry download failed: ${errorText}`);
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
async deleteFirmwareFromRegistry(name, version) {
|
||||
return this.request(`/api/registry/firmware/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
// Rollout API methods
|
||||
async getClusterNodeVersions() {
|
||||
return this.request('/api/cluster/node/versions', { method: 'GET' });
|
||||
}
|
||||
|
||||
async startRollout(rolloutData) {
|
||||
return this.request('/api/rollout', {
|
||||
method: 'POST',
|
||||
body: rolloutData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
@@ -159,9 +229,9 @@ class WebSocketClient {
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
this.wsUrl = `${wsProtocol}//localhost:3001`;
|
||||
this.wsUrl = `${wsProtocol}//localhost:3001/ws`;
|
||||
} else {
|
||||
this.wsUrl = `${wsProtocol}//${currentHost}:3001`;
|
||||
this.wsUrl = `${wsProtocol}//${currentHost}:3001/ws`;
|
||||
}
|
||||
|
||||
logger.debug('WebSocket Client initialized with URL:', this.wsUrl);
|
||||
@@ -193,7 +263,8 @@ class WebSocketClient {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logger.debug('WebSocket message received:', data);
|
||||
logger.debug('WebSocket message type:', data.type);
|
||||
const messageTopic = data.topic || data.type;
|
||||
logger.debug('WebSocket message topic:', messageTopic);
|
||||
this.emit('message', data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
@@ -218,15 +289,21 @@ class WebSocketClient {
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
switch (data.type) {
|
||||
case 'cluster_update':
|
||||
this.emit('clusterUpdate', data);
|
||||
break;
|
||||
case 'node_discovery':
|
||||
this.emit('nodeDiscovery', data);
|
||||
break;
|
||||
default:
|
||||
logger.debug('Unknown WebSocket message type:', data.type);
|
||||
const messageTopic = data.topic || data.type;
|
||||
|
||||
// Handler map for different WebSocket message types
|
||||
const handlers = {
|
||||
'cluster/update': (data) => this.emit('clusterUpdate', data),
|
||||
'node/discovery': (data) => this.emit('nodeDiscovery', data),
|
||||
'firmware/upload/status': (data) => this.emit('firmwareUploadStatus', data),
|
||||
'rollout/progress': (data) => this.emit('rolloutProgress', data)
|
||||
};
|
||||
|
||||
const handler = handlers[messageTopic];
|
||||
if (handler) {
|
||||
handler(data);
|
||||
} else {
|
||||
logger.debug('Unknown WebSocket message topic:', messageTopic);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
|
||||
const topologyViewModel = new TopologyViewModel();
|
||||
const monitoringViewModel = new MonitoringViewModel();
|
||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel });
|
||||
const eventsViewModel = new EventViewModel();
|
||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, clusterFirmwareViewModel, topologyViewModel, monitoringViewModel, eventsViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
@@ -65,6 +66,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
app.registerRoute('monitoring', MonitoringViewComponent, 'monitoring-view', monitoringViewModel);
|
||||
app.registerRoute('events', EventComponent, 'events-view', eventsViewModel);
|
||||
logger.debug('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Initialize cluster status component for header badge
|
||||
@@ -77,6 +79,65 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
clusterStatusComponent.mount();
|
||||
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
|
||||
logger.debug('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
@@ -53,6 +53,9 @@ class ClusterViewComponent extends Component {
|
||||
// Set up deploy button event listener
|
||||
this.setupDeployButton();
|
||||
|
||||
// Set up config button event listener
|
||||
this.setupConfigButton();
|
||||
|
||||
// Initialize overlay dialog
|
||||
this.initializeOverlayDialog();
|
||||
|
||||
@@ -112,6 +115,23 @@ class ClusterViewComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
setupConfigButton() {
|
||||
logger.debug('ClusterViewComponent: Setting up config button...');
|
||||
|
||||
const configBtn = this.findElement('#config-wifi-btn');
|
||||
logger.debug('ClusterViewComponent: Found config button:', !!configBtn, configBtn);
|
||||
|
||||
if (configBtn) {
|
||||
logger.debug('ClusterViewComponent: Adding click event listener to config button');
|
||||
this.addEventListener(configBtn, 'click', this.handleConfig.bind(this));
|
||||
logger.debug('ClusterViewComponent: Event listener added successfully');
|
||||
} else {
|
||||
logger.error('ClusterViewComponent: Config button not found!');
|
||||
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
|
||||
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
|
||||
}
|
||||
}
|
||||
|
||||
initializeOverlayDialog() {
|
||||
// Create overlay container if it doesn't exist
|
||||
let overlayContainer = document.getElementById('cluster-overlay-dialog');
|
||||
@@ -160,6 +180,28 @@ class ClusterViewComponent extends Component {
|
||||
this.openFirmwareUploadDrawer(filteredMembers);
|
||||
}
|
||||
|
||||
async handleConfig() {
|
||||
logger.debug('ClusterViewComponent: Config button clicked, opening WiFi config drawer...');
|
||||
|
||||
// Get current filtered members from cluster members component
|
||||
const filteredMembers = this.clusterMembersComponent ? this.clusterMembersComponent.getFilteredMembers() : [];
|
||||
|
||||
if (!filteredMembers || filteredMembers.length === 0) {
|
||||
this.showConfirmationDialog({
|
||||
title: 'No Nodes Available',
|
||||
message: 'No nodes available for WiFi configuration. Please ensure cluster members are loaded and visible.',
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Open drawer with WiFi configuration interface
|
||||
this.openWiFiConfigDrawer(filteredMembers);
|
||||
}
|
||||
|
||||
openFirmwareUploadDrawer(targetNodes) {
|
||||
logger.debug('ClusterViewComponent: Opening firmware upload drawer for', targetNodes.length, 'nodes');
|
||||
|
||||
@@ -255,6 +297,81 @@ class ClusterViewComponent extends Component {
|
||||
}, true); // Hide terminal button for firmware upload
|
||||
}
|
||||
|
||||
openWiFiConfigDrawer(targetNodes) {
|
||||
logger.debug('ClusterViewComponent: Opening WiFi config drawer for', targetNodes.length, 'nodes');
|
||||
|
||||
// Get display name for drawer title
|
||||
const nodeCount = targetNodes.length;
|
||||
const displayName = `Configuration - ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`;
|
||||
|
||||
// Open drawer with content callback (hide terminal button for WiFi config)
|
||||
this.clusterMembersComponent.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
||||
// Create WiFi config view model and component
|
||||
const wifiConfigVM = new WiFiConfigViewModel();
|
||||
wifiConfigVM.setTargetNodes(targetNodes);
|
||||
|
||||
// Create HTML for WiFi configuration interface
|
||||
contentContainer.innerHTML = `
|
||||
<div class="wifi-config-drawer">
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-header">
|
||||
<button class="tab-button active" data-tab="wifi">WiFi</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="wifi-tab">
|
||||
<div class="wifi-config-section">
|
||||
<div class="wifi-form">
|
||||
<div class="form-group">
|
||||
<label for="wifi-ssid">SSID (Network Name)</label>
|
||||
<input type="text" id="wifi-ssid" placeholder="Enter WiFi network name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wifi-password">Password</label>
|
||||
<input type="password" id="wifi-password" placeholder="Enter WiFi password" required>
|
||||
</div>
|
||||
|
||||
<div class="wifi-divider"></div>
|
||||
|
||||
<div class="affected-nodes-info">
|
||||
<div class="nodes-count">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="margin-right:6px; vertical-align: -2px;">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
Affected Nodes: <span id="affected-nodes-count">${targetNodes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wifi-actions">
|
||||
<button class="config-btn" id="apply-wifi-config" disabled>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wifi-progress-container">
|
||||
<!-- Progress will be shown here during configuration -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create and mount WiFi config component
|
||||
const wifiConfigComponent = new WiFiConfigComponent(contentContainer, wifiConfigVM, this.eventBus);
|
||||
setActiveComponent(wifiConfigComponent);
|
||||
wifiConfigComponent.mount();
|
||||
|
||||
}, null, () => {
|
||||
// Close callback - clear any config state
|
||||
logger.debug('ClusterViewComponent: WiFi config drawer closed');
|
||||
}, true); // Hide terminal button for WiFi config
|
||||
}
|
||||
|
||||
async handleRefresh() {
|
||||
logger.debug('ClusterViewComponent: Refresh button clicked, performing full refresh...');
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
(function(){
|
||||
// Simple readiness flag once all component constructors are present
|
||||
function allReady(){
|
||||
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent);
|
||||
return !!(window.PrimaryNodeComponent && window.ClusterMembersComponent && window.NodeDetailsComponent && window.FirmwareComponent && window.FirmwareFormComponent && window.ClusterViewComponent && window.FirmwareViewComponent && window.TopologyGraphComponent && window.MemberCardOverlayComponent && window.ClusterStatusComponent && window.DrawerComponent && window.WiFiConfigComponent);
|
||||
}
|
||||
window.waitForComponentsReady = function(timeoutMs = 5000){
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
1128
public/scripts/components/EventComponent.js
Normal file
1128
public/scripts/components/EventComponent.js
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
354
public/scripts/components/FirmwareFormComponent.js
Normal file
354
public/scripts/components/FirmwareFormComponent.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// Firmware Form Component for add/edit operations in drawer
|
||||
class FirmwareFormComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
this.firmwareData = null;
|
||||
this.onSaveCallback = null;
|
||||
this.onCancelCallback = null;
|
||||
this.isEditMode = false;
|
||||
}
|
||||
|
||||
setFirmwareData(firmwareData) {
|
||||
this.firmwareData = firmwareData;
|
||||
this.isEditMode = !!firmwareData;
|
||||
}
|
||||
|
||||
setOnSaveCallback(callback) {
|
||||
this.onSaveCallback = callback;
|
||||
}
|
||||
|
||||
setOnCancelCallback(callback) {
|
||||
this.onCancelCallback = callback;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Submit button
|
||||
const submitBtn = this.findElement('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
this.addEventListener(submitBtn, 'click', this.handleSubmit.bind(this));
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = this.findElement('#cancel-btn');
|
||||
if (cancelBtn) {
|
||||
this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this));
|
||||
}
|
||||
|
||||
// File input
|
||||
const fileInput = this.findElement('#firmware-file');
|
||||
if (fileInput) {
|
||||
this.addEventListener(fileInput, 'change', this.handleFileSelect.bind(this));
|
||||
}
|
||||
|
||||
// Labels management
|
||||
this.setupLabelsManagement();
|
||||
}
|
||||
|
||||
setupLabelsManagement() {
|
||||
// Add label button
|
||||
const addLabelBtn = this.findElement('#add-label-btn');
|
||||
if (addLabelBtn) {
|
||||
this.addEventListener(addLabelBtn, 'click', this.addLabel.bind(this));
|
||||
}
|
||||
|
||||
// Remove label buttons (delegated event handling)
|
||||
const labelsContainer = this.findElement('#labels-container');
|
||||
if (labelsContainer) {
|
||||
this.addEventListener(labelsContainer, 'click', (e) => {
|
||||
const removeBtn = e.target.closest('.remove-label-btn');
|
||||
if (removeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const key = removeBtn.getAttribute('data-label-key');
|
||||
if (key) {
|
||||
this.removeLabel(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
this.render();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = this.container;
|
||||
if (!container) return;
|
||||
|
||||
const labels = this.firmwareData?.labels || {};
|
||||
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
||||
`<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="firmware-form">
|
||||
<div class="form-group">
|
||||
<label for="firmware-name">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firmware-name"
|
||||
name="name"
|
||||
value="${this.firmwareData?.name || ''}"
|
||||
placeholder="e.g., base, neopattern, relay"
|
||||
${this.isEditMode ? 'readonly' : ''}
|
||||
>
|
||||
<small class="form-help">${this.isEditMode ? 'Name cannot be changed after creation' : 'Unique identifier for the firmware'}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="firmware-version">Version *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firmware-version"
|
||||
name="version"
|
||||
value="${this.firmwareData?.version || ''}"
|
||||
placeholder="e.g., 1.0.0, 2.1.3"
|
||||
${this.isEditMode ? 'readonly' : ''}
|
||||
>
|
||||
<small class="form-help">${this.isEditMode ? 'Version cannot be changed after creation' : 'Semantic version (e.g., 1.0.0)'}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="firmware-file">Firmware File *</label>
|
||||
<div class="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
id="firmware-file"
|
||||
name="firmware"
|
||||
accept=".bin,.hex"
|
||||
>
|
||||
<label for="firmware-file" class="file-input-label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<path d="M14 2v6h6"/>
|
||||
</svg>
|
||||
<span id="file-name">${this.isEditMode ? 'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' : 'Choose firmware file...'}</span>
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-help">${this.isEditMode ? 'Select a new firmware file to update, or leave empty to update metadata only' : 'Binary firmware file (.bin or .hex)'}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Labels</label>
|
||||
<div class="labels-section">
|
||||
<div class="add-label-controls">
|
||||
<input type="text" id="label-key" placeholder="Key" class="label-key-input">
|
||||
<span class="label-separator">=</span>
|
||||
<input type="text" id="label-value" placeholder="Value" class="label-value-input">
|
||||
<button type="button" id="add-label-btn" class="add-label-btn">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Label
|
||||
</button>
|
||||
</div>
|
||||
<div id="labels-container" class="labels-container">
|
||||
${labelsHTML}
|
||||
</div>
|
||||
</div>
|
||||
<small class="form-help">Key-value pairs for categorizing firmware (e.g., platform: esp32, app: base)</small>
|
||||
</div>
|
||||
|
||||
<div class="firmware-actions">
|
||||
<button type="button" id="cancel-btn" class="config-btn">Cancel</button>
|
||||
<button type="submit" class="config-btn">
|
||||
${this.isEditMode ? 'Update Firmware' : 'Upload Firmware'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
const fileNameSpan = this.findElement('#file-name');
|
||||
|
||||
if (file) {
|
||||
fileNameSpan.textContent = file.name;
|
||||
} else {
|
||||
fileNameSpan.textContent = this.isEditMode ?
|
||||
'Current file: ' + (this.firmwareData?.name || 'unknown') + '.bin' :
|
||||
'Choose firmware file...';
|
||||
}
|
||||
}
|
||||
|
||||
addLabel() {
|
||||
const keyInput = this.findElement('#label-key');
|
||||
const valueInput = this.findElement('#label-value');
|
||||
const labelsContainer = this.findElement('#labels-container');
|
||||
|
||||
const key = keyInput.value.trim();
|
||||
const value = valueInput.value.trim();
|
||||
|
||||
if (!key || !value) {
|
||||
this.showError('Please enter both key and value for the label');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
const existingLabel = labelsContainer.querySelector(`[data-key="${this.escapeHtml(key)}"]`);
|
||||
if (existingLabel) {
|
||||
this.showError('A label with this key already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the label
|
||||
const labelHTML = `
|
||||
<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button type="button" class="remove-label-btn" data-label-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
labelsContainer.insertAdjacentHTML('beforeend', labelHTML);
|
||||
|
||||
// Clear inputs
|
||||
keyInput.value = '';
|
||||
valueInput.value = '';
|
||||
}
|
||||
|
||||
removeLabel(key) {
|
||||
const removeBtn = this.findElement(`.remove-label-btn[data-label-key="${this.escapeHtml(key)}"]`);
|
||||
if (removeBtn) {
|
||||
const labelItem = removeBtn.closest('.label-item');
|
||||
if (labelItem) {
|
||||
labelItem.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
const nameInput = this.findElement('#firmware-name');
|
||||
const versionInput = this.findElement('#firmware-version');
|
||||
const firmwareFile = this.findElement('#firmware-file').files[0];
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
const version = versionInput.value.trim();
|
||||
|
||||
if (!name || !version) {
|
||||
this.showError('Name and version are required');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only require file for new uploads, not for edit mode when keeping existing file
|
||||
if (!this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) {
|
||||
this.showError('Please select a firmware file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect labels
|
||||
const labels = {};
|
||||
const labelItems = this.findAllElements('.label-item');
|
||||
labelItems.forEach(item => {
|
||||
const key = item.querySelector('.label-key').textContent;
|
||||
const value = item.querySelector('.label-value').textContent;
|
||||
labels[key] = value;
|
||||
});
|
||||
|
||||
// Prepare metadata
|
||||
const metadata = {
|
||||
name,
|
||||
version,
|
||||
labels
|
||||
};
|
||||
|
||||
// Handle upload vs metadata-only update
|
||||
if (this.isEditMode && (!firmwareFile || firmwareFile.size === 0)) {
|
||||
// Metadata-only update
|
||||
await window.apiClient.updateFirmwareMetadata(name, version, metadata);
|
||||
} else {
|
||||
// Full upload (new firmware or edit with new file)
|
||||
await window.apiClient.uploadFirmwareToRegistry(metadata, firmwareFile);
|
||||
}
|
||||
|
||||
this.showSuccess(this.isEditMode ? 'Firmware updated successfully' : 'Firmware uploaded successfully');
|
||||
|
||||
if (this.onSaveCallback) {
|
||||
this.onSaveCallback();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Firmware upload failed:', error);
|
||||
this.showError('Upload failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
if (this.onCancelCallback) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showNotification(message, 'error');
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
this.showNotification(message, 'success');
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
// Remove any existing notifications
|
||||
const existing = this.findElement('.form-notification');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `form-notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
this.container.insertBefore(notification, this.container.firstChild);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
window.FirmwareFormComponent = FirmwareFormComponent;
|
||||
@@ -17,12 +17,15 @@ class FirmwareUploadComponent extends Component {
|
||||
if (firmwareFile) {
|
||||
this.addEventListener(firmwareFile, 'change', this.handleFileSelect.bind(this));
|
||||
}
|
||||
|
||||
|
||||
// Setup deploy button
|
||||
const deployBtn = this.findElement('#deploy-btn');
|
||||
if (deployBtn) {
|
||||
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
|
||||
}
|
||||
|
||||
// Setup WebSocket listener for real-time firmware upload status
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
@@ -35,6 +38,213 @@ class FirmwareUploadComponent extends Component {
|
||||
this.subscribeToProperty('uploadResults', this.updateUploadResults.bind(this));
|
||||
}
|
||||
|
||||
setupWebSocketListeners() {
|
||||
// Listen for real-time firmware upload status updates
|
||||
window.wsClient.on('firmwareUploadStatus', (data) => {
|
||||
this.handleFirmwareUploadStatus(data);
|
||||
});
|
||||
}
|
||||
|
||||
handleFirmwareUploadStatus(data) {
|
||||
const { nodeIp, status, filename, fileSize, timestamp } = data;
|
||||
|
||||
logger.debug('FirmwareUploadComponent: Firmware upload status received:', { nodeIp, status, filename });
|
||||
|
||||
// Check if there's currently an upload in progress
|
||||
const isUploading = this.viewModel.get('isUploading');
|
||||
if (!isUploading) {
|
||||
logger.debug('FirmwareUploadComponent: No active upload, ignoring status update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the target node item for this node
|
||||
const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
|
||||
if (!targetNodeItem) {
|
||||
logger.debug('FirmwareUploadComponent: No target node item found for node:', nodeIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the status display based on the received status
|
||||
const statusElement = targetNodeItem.querySelector('.status-indicator');
|
||||
|
||||
if (statusElement) {
|
||||
let displayStatus = status;
|
||||
let statusClass = '';
|
||||
|
||||
logger.debug(`FirmwareUploadComponent: Updating status for node ${nodeIp}: ${status} -> ${displayStatus}`);
|
||||
|
||||
switch (status) {
|
||||
case 'uploading':
|
||||
displayStatus = 'Uploading...';
|
||||
statusClass = 'uploading';
|
||||
break;
|
||||
case 'completed':
|
||||
displayStatus = 'Completed';
|
||||
statusClass = 'success';
|
||||
logger.debug(`FirmwareUploadComponent: Node ${nodeIp} marked as completed`);
|
||||
break;
|
||||
case 'failed':
|
||||
displayStatus = 'Failed';
|
||||
statusClass = 'error';
|
||||
break;
|
||||
default:
|
||||
displayStatus = status;
|
||||
break;
|
||||
}
|
||||
|
||||
statusElement.textContent = displayStatus;
|
||||
statusElement.className = `status-indicator ${statusClass}`;
|
||||
}
|
||||
|
||||
// Update overall progress if we have multiple nodes
|
||||
this.updateOverallProgressFromStatus();
|
||||
|
||||
// Check if all uploads are complete and finalize results
|
||||
this.checkAndFinalizeUploadResults();
|
||||
}
|
||||
|
||||
updateOverallProgressFromStatus() {
|
||||
const targetNodeItems = Array.from(this.findAllElements('.target-node-item'));
|
||||
if (targetNodeItems.length <= 1) {
|
||||
return; // Only update for multi-node uploads
|
||||
}
|
||||
|
||||
let completedCount = 0;
|
||||
let failedCount = 0;
|
||||
let uploadingCount = 0;
|
||||
|
||||
targetNodeItems.forEach(item => {
|
||||
const statusElement = item.querySelector('.status-indicator');
|
||||
if (statusElement) {
|
||||
const status = statusElement.textContent;
|
||||
if (status === 'Completed') {
|
||||
completedCount++;
|
||||
} else if (status === 'Failed') {
|
||||
failedCount++;
|
||||
} else if (status === 'Uploading...') {
|
||||
uploadingCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalNodes = targetNodeItems.length;
|
||||
const successfulUploads = completedCount;
|
||||
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
|
||||
|
||||
// Update overall progress bar
|
||||
const progressBar = this.findElement('#overall-progress-bar');
|
||||
const progressText = this.findElement('.progress-text');
|
||||
|
||||
if (progressBar && progressText) {
|
||||
progressBar.style.width = `${successPercentage}%`;
|
||||
|
||||
// Update progress bar color based on completion
|
||||
if (successPercentage === 100) {
|
||||
progressBar.style.backgroundColor = '#4ade80';
|
||||
} else if (successPercentage > 50) {
|
||||
progressBar.style.backgroundColor = '#60a5fa';
|
||||
} else {
|
||||
progressBar.style.backgroundColor = '#fbbf24';
|
||||
}
|
||||
|
||||
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
|
||||
}
|
||||
|
||||
// Update progress summary
|
||||
const progressSummary = this.findElement('#progress-summary');
|
||||
if (progressSummary) {
|
||||
if (failedCount > 0) {
|
||||
progressSummary.innerHTML = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading, ${failedCount} failed)</span>`;
|
||||
} else if (uploadingCount > 0) {
|
||||
progressSummary.innerHTML = `<span>${window.icon('info', { width: 14, height: 14 })} Upload in progress... (${uploadingCount} uploading)</span>`;
|
||||
} else if (completedCount === totalNodes) {
|
||||
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkAndFinalizeUploadResults() {
|
||||
const targetNodeItems = Array.from(this.findAllElements('.target-node-item'));
|
||||
if (targetNodeItems.length === 0) return;
|
||||
|
||||
// Check if all uploads are complete (either completed or failed)
|
||||
let allComplete = true;
|
||||
let hasAnyCompleted = false;
|
||||
let hasAnyFailed = false;
|
||||
let uploadingCount = 0;
|
||||
|
||||
const statuses = [];
|
||||
targetNodeItems.forEach(item => {
|
||||
const statusElement = item.querySelector('.status-indicator');
|
||||
if (statusElement) {
|
||||
const status = statusElement.textContent;
|
||||
statuses.push(status);
|
||||
|
||||
if (status !== 'Completed' && status !== 'Failed') {
|
||||
allComplete = false;
|
||||
if (status === 'Uploading...') {
|
||||
uploadingCount++;
|
||||
}
|
||||
}
|
||||
if (status === 'Completed') {
|
||||
hasAnyCompleted = true;
|
||||
}
|
||||
if (status === 'Failed') {
|
||||
hasAnyFailed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug('FirmwareUploadComponent: Upload status check:', {
|
||||
totalItems: targetNodeItems.length,
|
||||
allComplete,
|
||||
uploadingCount,
|
||||
hasAnyCompleted,
|
||||
hasAnyFailed,
|
||||
statuses
|
||||
});
|
||||
|
||||
// If all uploads are complete, finalize the results
|
||||
if (allComplete) {
|
||||
logger.debug('FirmwareUploadComponent: All firmware uploads complete, finalizing results');
|
||||
|
||||
// Generate results based on current status
|
||||
const results = targetNodeItems.map(item => {
|
||||
const nodeIp = item.getAttribute('data-node-ip');
|
||||
const nodeName = item.querySelector('.node-name')?.textContent || nodeIp;
|
||||
const statusElement = item.querySelector('.status-indicator');
|
||||
const status = statusElement?.textContent || 'Unknown';
|
||||
|
||||
return {
|
||||
nodeIp: nodeIp,
|
||||
hostname: nodeName,
|
||||
success: status === 'Completed',
|
||||
error: status === 'Failed' ? 'Upload failed' : undefined,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
// Update the header and summary to show final results
|
||||
this.displayUploadResults(results);
|
||||
|
||||
// Hide the progress overlay since upload is complete
|
||||
this.hideProgressOverlay();
|
||||
|
||||
// Now that all uploads are truly complete (confirmed via websocket), mark upload as complete
|
||||
this.viewModel.completeUpload();
|
||||
|
||||
// Reset upload state after a short delay to allow user to see results and re-enable deploy button
|
||||
setTimeout(() => {
|
||||
this.viewModel.resetUploadState();
|
||||
logger.debug('FirmwareUploadComponent: Upload state reset, deploy button should be re-enabled');
|
||||
}, 5000);
|
||||
} else if (uploadingCount > 0) {
|
||||
logger.debug(`FirmwareUploadComponent: ${uploadingCount} uploads still in progress, not finalizing yet`);
|
||||
} else {
|
||||
logger.debug('FirmwareUploadComponent: Some uploads may have unknown status, but not finalizing yet');
|
||||
}
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
|
||||
@@ -135,24 +345,23 @@ class FirmwareUploadComponent extends Component {
|
||||
async performDeployment(file, targetNodes) {
|
||||
try {
|
||||
this.viewModel.startUpload();
|
||||
|
||||
|
||||
// Show progress overlay to block UI interactions
|
||||
this.showProgressOverlay();
|
||||
|
||||
|
||||
// Show upload progress area
|
||||
this.showUploadProgress(file, targetNodes);
|
||||
|
||||
|
||||
// Start batch upload
|
||||
const results = await this.performBatchUpload(file, targetNodes);
|
||||
|
||||
// Display results
|
||||
this.displayUploadResults(results);
|
||||
|
||||
// Reset interface after successful upload
|
||||
this.viewModel.resetUploadState();
|
||||
|
||||
|
||||
// NOTE: Don't display results or reset state here!
|
||||
// The upload state should remain active until websocket confirms completion
|
||||
// Status updates and finalization happen via websocket messages in checkAndFinalizeUploadResults()
|
||||
logger.debug('FirmwareUploadComponent: Firmware upload HTTP requests completed, waiting for websocket status updates');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Firmware deployment failed:', error);
|
||||
logger.error('FirmwareUploadComponent: Firmware deployment failed:', error);
|
||||
this.showConfirmationDialog({
|
||||
title: 'Deployment Failed',
|
||||
message: `Deployment failed: ${error.message}`,
|
||||
@@ -161,7 +370,7 @@ class FirmwareUploadComponent extends Component {
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
} finally {
|
||||
// Only complete upload on error
|
||||
this.viewModel.completeUpload();
|
||||
this.hideProgressOverlay();
|
||||
}
|
||||
@@ -171,26 +380,28 @@ class FirmwareUploadComponent extends Component {
|
||||
const results = [];
|
||||
const totalNodes = nodes.length;
|
||||
let successfulUploads = 0;
|
||||
|
||||
|
||||
// Initialize all nodes as uploading first
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const nodeIp = node.ip;
|
||||
|
||||
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
const nodeIp = node.ip;
|
||||
|
||||
try {
|
||||
// Update progress
|
||||
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Uploading...');
|
||||
|
||||
// Upload to this node
|
||||
// Upload to this node (HTTP call just initiates the upload)
|
||||
const result = await this.performSingleUpload(file, nodeIp);
|
||||
|
||||
// Don't immediately mark as completed - wait for websocket status
|
||||
logger.debug(`FirmwareUploadComponent: Firmware upload initiated for node ${nodeIp}, waiting for completion status via websocket`);
|
||||
results.push(result);
|
||||
successfulUploads++;
|
||||
|
||||
// Update progress
|
||||
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Completed');
|
||||
this.updateOverallProgress(successfulUploads, totalNodes);
|
||||
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upload to node ${nodeIp}:`, error);
|
||||
logger.error(`FirmwareUploadComponent: Failed to upload to node ${nodeIp}:`, error);
|
||||
const errorResult = {
|
||||
nodeIp: nodeIp,
|
||||
hostname: node.hostname || nodeIp,
|
||||
@@ -199,33 +410,38 @@ class FirmwareUploadComponent extends Component {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
results.push(errorResult);
|
||||
|
||||
// Update progress
|
||||
|
||||
// For HTTP errors, we can immediately mark as failed since the upload didn't start
|
||||
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
|
||||
this.updateOverallProgress(successfulUploads, totalNodes);
|
||||
}
|
||||
|
||||
|
||||
// Small delay between uploads
|
||||
if (i < nodes.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async performSingleUpload(file, nodeIp) {
|
||||
try {
|
||||
const result = await window.apiClient.uploadFirmware(file, nodeIp);
|
||||
|
||||
|
||||
// IMPORTANT: This HTTP response is just an acknowledgment that the gateway received the file
|
||||
// The actual firmware processing happens asynchronously on the device
|
||||
// Status updates will come via WebSocket messages, NOT from this HTTP response
|
||||
logger.debug(`FirmwareUploadComponent: HTTP acknowledgment received for ${nodeIp}:`, result);
|
||||
logger.debug(`FirmwareUploadComponent: This does NOT mean upload is complete - waiting for WebSocket status updates`);
|
||||
|
||||
return {
|
||||
nodeIp: nodeIp,
|
||||
hostname: nodeIp,
|
||||
success: true,
|
||||
httpAcknowledged: true, // Changed from 'success' to make it clear this is just HTTP ack
|
||||
result: result,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`Upload to ${nodeIp} failed: ${error.message}`);
|
||||
}
|
||||
@@ -260,7 +476,7 @@ class FirmwareUploadComponent extends Component {
|
||||
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
|
||||
</div>
|
||||
<div class="progress-summary" id="progress-summary">
|
||||
<span>Status: Preparing upload...</span>
|
||||
<span>Status: Upload in progress...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -283,7 +499,7 @@ class FirmwareUploadComponent extends Component {
|
||||
<span class="node-ip">${node.ip}</span>
|
||||
</div>
|
||||
<div class="node-status">
|
||||
<span class="status-indicator pending">Pending...</span>
|
||||
<span class="status-indicator uploading">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
@@ -315,12 +531,12 @@ class FirmwareUploadComponent extends Component {
|
||||
updateOverallProgress(successfulUploads, totalNodes) {
|
||||
const progressBar = this.findElement('#overall-progress-bar');
|
||||
const progressText = this.findElement('.progress-text');
|
||||
|
||||
|
||||
if (progressBar && progressText) {
|
||||
const successPercentage = Math.round((successfulUploads / totalNodes) * 100);
|
||||
progressBar.style.width = `${successPercentage}%`;
|
||||
progressText.textContent = `${successfulUploads}/${totalNodes} Successful (${successPercentage}%)`;
|
||||
|
||||
|
||||
// Update progress bar color based on completion
|
||||
if (successPercentage === 100) {
|
||||
progressBar.style.backgroundColor = '#4ade80';
|
||||
@@ -329,16 +545,10 @@ class FirmwareUploadComponent extends Component {
|
||||
} else {
|
||||
progressBar.style.backgroundColor = '#fbbf24';
|
||||
}
|
||||
|
||||
// Update progress summary for single-node uploads
|
||||
const progressSummary = this.findElement('#progress-summary');
|
||||
if (progressSummary && totalNodes === 1) {
|
||||
if (successfulUploads === 1) {
|
||||
progressSummary.innerHTML = '<span>Status: Upload completed successfully</span>';
|
||||
} else if (successfulUploads === 0) {
|
||||
progressSummary.innerHTML = '<span>Status: Upload failed</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Don't update progress summary here for single-node uploads
|
||||
// The summary should only be updated via websocket status updates
|
||||
// This prevents premature "completed successfully" messages
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,8 +636,6 @@ class FirmwareUploadComponent extends Component {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
this.updateDeployButton();
|
||||
}
|
||||
|
||||
updateUploadProgress() {
|
||||
|
||||
@@ -6,11 +6,11 @@ class FirmwareViewComponent extends Component {
|
||||
logger.debug('FirmwareViewComponent: Constructor called');
|
||||
logger.debug('FirmwareViewComponent: Container:', container);
|
||||
|
||||
const firmwareContainer = this.findElement('#firmware-container');
|
||||
logger.debug('FirmwareViewComponent: Firmware container found:', !!firmwareContainer);
|
||||
// Pass the entire firmware view container to the FirmwareComponent
|
||||
logger.debug('FirmwareViewComponent: Using entire container for FirmwareComponent');
|
||||
|
||||
this.firmwareComponent = new FirmwareComponent(
|
||||
firmwareContainer,
|
||||
container,
|
||||
viewModel,
|
||||
eventBus
|
||||
);
|
||||
@@ -26,9 +26,6 @@ class FirmwareViewComponent extends Component {
|
||||
// Mount sub-component
|
||||
this.firmwareComponent.mount();
|
||||
|
||||
// Update available nodes
|
||||
this.updateAvailableNodes();
|
||||
|
||||
logger.debug('FirmwareViewComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
@@ -67,16 +64,4 @@ class FirmwareViewComponent extends Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
async updateAvailableNodes() {
|
||||
try {
|
||||
logger.debug('FirmwareViewComponent: updateAvailableNodes called');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
const nodes = response.members || [];
|
||||
logger.debug('FirmwareViewComponent: Got nodes:', nodes);
|
||||
this.viewModel.updateAvailableNodes(nodes);
|
||||
logger.debug('FirmwareViewComponent: Available nodes updated in view model');
|
||||
} catch (error) {
|
||||
logger.error('Failed to update available nodes:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -709,62 +709,47 @@ class NodeDetailsComponent extends Component {
|
||||
const labels = nodeStatus?.labels || {};
|
||||
const labelsArray = Object.entries(labels);
|
||||
|
||||
const labelsHTML = labelsArray.map(([key, value]) => `
|
||||
<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button class="label-remove-btn" data-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
let html = `
|
||||
<div class="labels-section">
|
||||
<div class="labels-header">
|
||||
<h3>Node Labels</h3>
|
||||
<p class="labels-description">Manage custom labels for this node. Labels help organize and identify nodes in your cluster.</p>
|
||||
</div>
|
||||
|
||||
<div class="labels-list">
|
||||
`;
|
||||
|
||||
if (labelsArray.length === 0) {
|
||||
html += `
|
||||
<div class="no-labels">
|
||||
<div>No labels configured</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">Add labels to organize and identify this node</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
labelsArray.forEach(([key, value]) => {
|
||||
html += `
|
||||
<div class="label-item" data-key="${this.escapeHtml(key)}">
|
||||
<div class="label-content">
|
||||
<span class="label-key">${this.escapeHtml(key)}</span>
|
||||
<span class="label-separator">=</span>
|
||||
<span class="label-value">${this.escapeHtml(value)}</span>
|
||||
</div>
|
||||
<button class="label-remove-btn" data-key="${this.escapeHtml(key)}" title="Remove label">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="add-label-controls">
|
||||
<input type="text" id="label-key" placeholder="Key" class="label-key-input" maxlength="50">
|
||||
<span class="label-separator">=</span>
|
||||
<input type="text" id="label-value" placeholder="Value" class="label-value-input" maxlength="100">
|
||||
<button class="add-label-btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
Add Label
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="add-label-section">
|
||||
<div class="add-label-form">
|
||||
<div class="form-group">
|
||||
<label for="label-key">Key:</label>
|
||||
<input type="text" id="label-key" placeholder="e.g., environment" maxlength="50">
|
||||
<div class="labels-list">
|
||||
${labelsArray.length === 0 ? `
|
||||
<div class="no-labels">
|
||||
<div>No labels configured</div>
|
||||
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">Add labels to organize and identify this node</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="label-value">Value:</label>
|
||||
<input type="text" id="label-value" placeholder="e.g., production" maxlength="100">
|
||||
</div>
|
||||
<button class="add-label-btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Add Label
|
||||
</button>
|
||||
</div>
|
||||
` : labelsHTML}
|
||||
</div>
|
||||
|
||||
<div class="labels-actions">
|
||||
@@ -1004,7 +989,6 @@ class NodeDetailsComponent extends Component {
|
||||
const summaryHTML = summary ? `
|
||||
<div class="tasks-summary">
|
||||
<div class="tasks-summary-left">
|
||||
<div class="summary-icon">${window.icon('file', { width: 16, height: 16 })}</div>
|
||||
<div>
|
||||
<div class="summary-title">Tasks Overview</div>
|
||||
<div class="summary-subtitle">System task management and monitoring</div>
|
||||
|
||||
@@ -9,6 +9,8 @@ class OverlayDialogComponent extends Component {
|
||||
this.message = '';
|
||||
this.confirmText = 'Yes';
|
||||
this.cancelText = 'No';
|
||||
this.confirmClass = 'overlay-dialog-btn-confirm';
|
||||
this.showCloseButton = true;
|
||||
}
|
||||
|
||||
mount() {
|
||||
@@ -38,6 +40,8 @@ class OverlayDialogComponent extends Component {
|
||||
message = 'Are you sure you want to proceed?',
|
||||
confirmText = 'Yes',
|
||||
cancelText = 'No',
|
||||
confirmClass = 'overlay-dialog-btn-confirm',
|
||||
showCloseButton = true,
|
||||
onConfirm = null,
|
||||
onCancel = null
|
||||
} = options;
|
||||
@@ -46,53 +50,74 @@ class OverlayDialogComponent extends Component {
|
||||
this.message = message;
|
||||
this.confirmText = confirmText;
|
||||
this.cancelText = cancelText;
|
||||
this.confirmClass = confirmClass;
|
||||
this.showCloseButton = showCloseButton;
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
|
||||
this.render();
|
||||
this.container.classList.add('visible');
|
||||
|
||||
// Add visible class with small delay for animation
|
||||
setTimeout(() => {
|
||||
this.container.classList.add('visible');
|
||||
}, 10);
|
||||
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.container.classList.remove('visible');
|
||||
this.isVisible = false;
|
||||
|
||||
// Call cancel callback if provided
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.isVisible = false;
|
||||
|
||||
// Call cancel callback if provided
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
this.onCancel = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
handleConfirm() {
|
||||
this.hide();
|
||||
this.container.classList.remove('visible');
|
||||
|
||||
// Call confirm callback if provided
|
||||
if (this.onConfirm) {
|
||||
this.onConfirm();
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.isVisible = false;
|
||||
|
||||
// Call confirm callback if provided
|
||||
if (this.onConfirm) {
|
||||
this.onConfirm();
|
||||
this.onConfirm = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container.innerHTML = `
|
||||
<div class="overlay-dialog-content">
|
||||
<div class="overlay-dialog-header">
|
||||
<h3 class="overlay-dialog-title">${this.title}</h3>
|
||||
<button class="overlay-dialog-close" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h3 class="overlay-dialog-title">${this.escapeHtml(this.title)}</h3>
|
||||
${this.showCloseButton ? `
|
||||
<button class="overlay-dialog-close" type="button" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="overlay-dialog-body">
|
||||
<div class="overlay-dialog-message">${this.message}</div>
|
||||
<p class="overlay-dialog-message">${this.message}</p>
|
||||
</div>
|
||||
<div class="overlay-dialog-footer">
|
||||
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
|
||||
${this.cancelText}
|
||||
</button>
|
||||
<button class="overlay-dialog-btn overlay-dialog-btn-confirm" type="button">
|
||||
${this.confirmText}
|
||||
${this.cancelText ? `
|
||||
<button class="overlay-dialog-btn overlay-dialog-btn-cancel" type="button">
|
||||
${this.escapeHtml(this.cancelText)}
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="overlay-dialog-btn ${this.confirmClass}" type="button">
|
||||
${this.escapeHtml(this.confirmText)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,7 +126,7 @@ class OverlayDialogComponent extends Component {
|
||||
// Add event listeners to buttons
|
||||
const closeBtn = this.container.querySelector('.overlay-dialog-close');
|
||||
const cancelBtn = this.container.querySelector('.overlay-dialog-btn-cancel');
|
||||
const confirmBtn = this.container.querySelector('.overlay-dialog-btn-confirm');
|
||||
const confirmBtn = this.container.querySelector(`.${this.confirmClass}`);
|
||||
|
||||
if (closeBtn) {
|
||||
this.addEventListener(closeBtn, 'click', () => this.hide());
|
||||
@@ -116,6 +141,13 @@ class OverlayDialogComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
// Clean up event listeners
|
||||
this.removeAllEventListeners();
|
||||
@@ -124,3 +156,68 @@ class OverlayDialogComponent extends Component {
|
||||
super.unmount();
|
||||
}
|
||||
}
|
||||
|
||||
// Static utility methods for easy usage without mounting
|
||||
OverlayDialogComponent.show = function(options) {
|
||||
// Create a temporary container
|
||||
const container = document.createElement('div');
|
||||
container.className = 'overlay-dialog';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Create component instance
|
||||
const dialog = new OverlayDialogComponent(container, null, null);
|
||||
|
||||
// Override hide to clean up container
|
||||
const originalHide = dialog.hide.bind(dialog);
|
||||
dialog.hide = function() {
|
||||
originalHide();
|
||||
setTimeout(() => {
|
||||
if (container.parentNode) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
|
||||
// Override handleConfirm to clean up container
|
||||
const originalHandleConfirm = dialog.handleConfirm.bind(dialog);
|
||||
dialog.handleConfirm = function() {
|
||||
originalHandleConfirm();
|
||||
setTimeout(() => {
|
||||
if (container.parentNode) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
|
||||
dialog.mount();
|
||||
dialog.show(options);
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
// Convenience method for confirmation dialogs
|
||||
OverlayDialogComponent.confirm = function(options) {
|
||||
return OverlayDialogComponent.show({
|
||||
...options,
|
||||
confirmClass: options.confirmClass || 'overlay-dialog-btn-confirm'
|
||||
});
|
||||
};
|
||||
|
||||
// Convenience method for danger/delete confirmations
|
||||
OverlayDialogComponent.danger = function(options) {
|
||||
return OverlayDialogComponent.show({
|
||||
...options,
|
||||
confirmClass: 'overlay-dialog-btn-danger'
|
||||
});
|
||||
};
|
||||
|
||||
// Convenience method for alerts
|
||||
OverlayDialogComponent.alert = function(message, title = 'Notice') {
|
||||
return OverlayDialogComponent.show({
|
||||
title,
|
||||
message,
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
showCloseButton: false
|
||||
});
|
||||
};
|
||||
|
||||
271
public/scripts/components/RolloutComponent.js
Normal file
271
public/scripts/components/RolloutComponent.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// Rollout Component - Shows rollout panel with matching nodes and starts rollout
|
||||
class RolloutComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('RolloutComponent: Constructor called');
|
||||
|
||||
this.rolloutData = null;
|
||||
this.matchingNodes = [];
|
||||
this.onRolloutCallback = null;
|
||||
this.onCancelCallback = null;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Rollout button
|
||||
const rolloutBtn = this.findElement('#rollout-confirm-btn');
|
||||
if (rolloutBtn) {
|
||||
this.addEventListener(rolloutBtn, 'click', this.handleRollout.bind(this));
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = this.findElement('#rollout-cancel-btn');
|
||||
if (cancelBtn) {
|
||||
this.addEventListener(cancelBtn, 'click', this.handleCancel.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
// Start rollout - hide labels and show status indicators
|
||||
startRollout() {
|
||||
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
|
||||
nodeItems.forEach(item => {
|
||||
const labelsDiv = item.querySelector('.rollout-node-labels');
|
||||
const statusDiv = item.querySelector('.status-indicator');
|
||||
|
||||
if (labelsDiv && statusDiv) {
|
||||
labelsDiv.style.display = 'none';
|
||||
statusDiv.style.display = 'block';
|
||||
statusDiv.textContent = 'Ready';
|
||||
statusDiv.className = 'status-indicator ready';
|
||||
}
|
||||
});
|
||||
|
||||
// Disable the confirm button
|
||||
const confirmBtn = this.findElement('#rollout-confirm-btn');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.textContent = 'Rollout in Progress...';
|
||||
}
|
||||
}
|
||||
|
||||
// Update status for a specific node
|
||||
updateNodeStatus(nodeIp, status) {
|
||||
const nodeItem = this.container.querySelector(`[data-node-ip="${nodeIp}"]`);
|
||||
if (!nodeItem) return;
|
||||
|
||||
const statusDiv = nodeItem.querySelector('.status-indicator');
|
||||
if (!statusDiv) return;
|
||||
|
||||
let displayStatus = status;
|
||||
let statusClass = '';
|
||||
|
||||
switch (status) {
|
||||
case 'updating_labels':
|
||||
displayStatus = 'Updating Labels...';
|
||||
statusClass = 'uploading';
|
||||
break;
|
||||
case 'uploading':
|
||||
displayStatus = 'Uploading...';
|
||||
statusClass = 'uploading';
|
||||
break;
|
||||
case 'completed':
|
||||
displayStatus = 'Completed';
|
||||
statusClass = 'success';
|
||||
break;
|
||||
case 'failed':
|
||||
displayStatus = 'Failed';
|
||||
statusClass = 'error';
|
||||
break;
|
||||
default:
|
||||
displayStatus = status;
|
||||
statusClass = 'pending';
|
||||
}
|
||||
|
||||
statusDiv.textContent = displayStatus;
|
||||
statusDiv.className = `status-indicator ${statusClass}`;
|
||||
}
|
||||
|
||||
// Check if rollout is complete
|
||||
isRolloutComplete() {
|
||||
const statusIndicators = this.container.querySelectorAll('.status-indicator');
|
||||
for (const indicator of statusIndicators) {
|
||||
const status = indicator.textContent.toLowerCase();
|
||||
if (status !== 'completed' && status !== 'failed') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reset to initial state (show labels, hide status indicators)
|
||||
resetRolloutState() {
|
||||
const nodeItems = this.container.querySelectorAll('.rollout-node-item');
|
||||
nodeItems.forEach(item => {
|
||||
const labelsDiv = item.querySelector('.rollout-node-labels');
|
||||
const statusDiv = item.querySelector('.status-indicator');
|
||||
|
||||
if (labelsDiv && statusDiv) {
|
||||
labelsDiv.style.display = 'block';
|
||||
statusDiv.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Re-enable the confirm button
|
||||
const confirmBtn = this.findElement('#rollout-confirm-btn');
|
||||
if (confirmBtn) {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = `Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
|
||||
logger.debug('RolloutComponent: Mounting...');
|
||||
|
||||
this.render();
|
||||
|
||||
logger.debug('RolloutComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
setRolloutData(name, version, labels, matchingNodes) {
|
||||
this.rolloutData = { name, version, labels };
|
||||
this.matchingNodes = matchingNodes;
|
||||
}
|
||||
|
||||
setOnRolloutCallback(callback) {
|
||||
this.onRolloutCallback = callback;
|
||||
}
|
||||
|
||||
setOnCancelCallback(callback) {
|
||||
this.onCancelCallback = callback;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.rolloutData) {
|
||||
this.container.innerHTML = '<div class="error">No rollout data available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, version, labels } = this.rolloutData;
|
||||
|
||||
// Render labels as chips
|
||||
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
||||
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
||||
).join('');
|
||||
|
||||
// Render matching nodes
|
||||
const nodesHTML = this.matchingNodes.map(node => {
|
||||
const nodeLabelsHTML = Object.entries(node.labels || {}).map(([key, value]) =>
|
||||
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="rollout-node-item" data-node-ip="${node.ip}">
|
||||
<div class="rollout-node-info">
|
||||
<div class="rollout-node-ip">${this.escapeHtml(node.ip)}</div>
|
||||
<div class="rollout-node-version">Version: ${this.escapeHtml(node.version)}</div>
|
||||
</div>
|
||||
<div class="rollout-node-status">
|
||||
<div class="rollout-node-labels">
|
||||
${nodeLabelsHTML}
|
||||
</div>
|
||||
<div class="status-indicator ready" style="display: none;">Ready</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.container.innerHTML = `
|
||||
<div class="rollout-panel">
|
||||
<div class="rollout-header">
|
||||
<p>Deploy firmware to matching cluster nodes</p>
|
||||
</div>
|
||||
|
||||
<div class="rollout-firmware-info">
|
||||
<div class="rollout-firmware-name">${this.escapeHtml(name)}</div>
|
||||
<div class="rollout-firmware-version">Version: ${this.escapeHtml(version)}</div>
|
||||
<div class="rollout-firmware-labels">
|
||||
${labelsHTML}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rollout-matching-nodes">
|
||||
<h4>Matching Nodes (${this.matchingNodes.length})</h4>
|
||||
<div class="rollout-nodes-list">
|
||||
${nodesHTML}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rollout-warning">
|
||||
<div class="warning-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="warning-text">
|
||||
<strong>Warning:</strong> This will update firmware on ${this.matchingNodes.length} node${this.matchingNodes.length !== 1 ? 's' : ''}.
|
||||
The rollout process cannot be cancelled once started.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rollout-actions">
|
||||
<button id="rollout-cancel-btn" class="refresh-btn">Cancel</button>
|
||||
<button id="rollout-confirm-btn" class="deploy-btn" ${this.matchingNodes.length === 0 ? 'disabled' : ''}>
|
||||
Rollout to ${this.matchingNodes.length} Node${this.matchingNodes.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
handleRollout() {
|
||||
if (!this.onRolloutCallback || this.matchingNodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeCount = this.matchingNodes.length;
|
||||
const nodePlural = nodeCount !== 1 ? 's' : '';
|
||||
const { name, version } = this.rolloutData;
|
||||
|
||||
// Show confirmation dialog
|
||||
OverlayDialogComponent.confirm({
|
||||
title: 'Confirm Firmware Rollout',
|
||||
message: `Are you sure you want to deploy firmware <strong>${this.escapeHtml(name)}</strong> version <strong>${this.escapeHtml(version)}</strong> to <strong>${nodeCount} node${nodePlural}</strong>?<br><br>The rollout process cannot be cancelled once started. All nodes will be updated and rebooted.`,
|
||||
confirmText: `Rollout to ${nodeCount} Node${nodePlural}`,
|
||||
cancelText: 'Cancel',
|
||||
onConfirm: () => {
|
||||
// Send the firmware info and matching nodes directly
|
||||
const rolloutData = {
|
||||
firmware: {
|
||||
name: this.rolloutData.name,
|
||||
version: this.rolloutData.version,
|
||||
labels: this.rolloutData.labels
|
||||
},
|
||||
nodes: this.matchingNodes
|
||||
};
|
||||
|
||||
this.onRolloutCallback(rolloutData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
if (this.onCancelCallback) {
|
||||
this.onCancelCallback();
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
window.RolloutComponent = RolloutComponent;
|
||||
File diff suppressed because it is too large
Load Diff
315
public/scripts/components/WiFiConfigComponent.js
Normal file
315
public/scripts/components/WiFiConfigComponent.js
Normal file
@@ -0,0 +1,315 @@
|
||||
// WiFi Configuration Component
|
||||
class WiFiConfigComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('WiFiConfigComponent: Constructor called');
|
||||
logger.debug('WiFiConfigComponent: Container:', container);
|
||||
|
||||
// Track form state
|
||||
this.formValid = false;
|
||||
}
|
||||
|
||||
mount() {
|
||||
logger.debug('WiFiConfigComponent: Mounting...');
|
||||
super.mount();
|
||||
|
||||
this.setupFormValidation();
|
||||
this.setupApplyButton();
|
||||
this.setupProgressDisplay();
|
||||
|
||||
// Initial validation to ensure button starts disabled
|
||||
this.validateForm();
|
||||
|
||||
logger.debug('WiFiConfigComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
setupFormValidation() {
|
||||
logger.debug('WiFiConfigComponent: Setting up form validation...');
|
||||
|
||||
const ssidInput = this.findElement('#wifi-ssid');
|
||||
const passwordInput = this.findElement('#wifi-password');
|
||||
const applyBtn = this.findElement('#apply-wifi-config');
|
||||
|
||||
if (!ssidInput || !passwordInput || !applyBtn) {
|
||||
logger.error('WiFiConfigComponent: Required form elements not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add input event listeners
|
||||
this.addEventListener(ssidInput, 'input', this.validateForm.bind(this));
|
||||
this.addEventListener(passwordInput, 'input', this.validateForm.bind(this));
|
||||
|
||||
// Initial validation
|
||||
this.validateForm();
|
||||
}
|
||||
|
||||
setupApplyButton() {
|
||||
logger.debug('WiFiConfigComponent: Setting up apply button...');
|
||||
|
||||
const applyBtn = this.findElement('#apply-wifi-config');
|
||||
|
||||
if (applyBtn) {
|
||||
this.addEventListener(applyBtn, 'click', this.handleApply.bind(this));
|
||||
logger.debug('WiFiConfigComponent: Apply button event listener added');
|
||||
} else {
|
||||
logger.error('WiFiConfigComponent: Apply button not found');
|
||||
}
|
||||
}
|
||||
|
||||
setupProgressDisplay() {
|
||||
logger.debug('WiFiConfigComponent: Setting up progress display...');
|
||||
|
||||
// Subscribe to view model changes
|
||||
this.viewModel.subscribe('isConfiguring', (isConfiguring) => {
|
||||
this.updateApplyButton(isConfiguring);
|
||||
});
|
||||
|
||||
this.viewModel.subscribe('configProgress', (progress) => {
|
||||
this.updateProgressDisplay(progress);
|
||||
});
|
||||
|
||||
this.viewModel.subscribe('configResults', (results) => {
|
||||
this.updateResultsDisplay(results);
|
||||
});
|
||||
}
|
||||
|
||||
validateForm() {
|
||||
logger.debug('WiFiConfigComponent: Validating form...');
|
||||
|
||||
const ssidInput = this.findElement('#wifi-ssid');
|
||||
const passwordInput = this.findElement('#wifi-password');
|
||||
const applyBtn = this.findElement('#apply-wifi-config');
|
||||
|
||||
if (!ssidInput || !passwordInput || !applyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ssid = ssidInput.value.trim();
|
||||
const password = passwordInput.value.trim();
|
||||
|
||||
this.formValid = ssid.length > 0 && password.length > 0;
|
||||
|
||||
// Update apply button state
|
||||
applyBtn.disabled = !this.formValid;
|
||||
|
||||
// Update view model
|
||||
this.viewModel.setCredentials(ssid, password);
|
||||
|
||||
logger.debug('WiFiConfigComponent: Form validation complete. Valid:', this.formValid);
|
||||
}
|
||||
|
||||
updateApplyButton(isConfiguring) {
|
||||
logger.debug('WiFiConfigComponent: Updating apply button. Configuring:', isConfiguring);
|
||||
|
||||
const applyBtn = this.findElement('#apply-wifi-config');
|
||||
|
||||
if (!applyBtn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isConfiguring) {
|
||||
applyBtn.disabled = true;
|
||||
applyBtn.classList.add('loading');
|
||||
applyBtn.innerHTML = `Apply`;
|
||||
} else {
|
||||
applyBtn.disabled = !this.formValid;
|
||||
applyBtn.classList.remove('loading');
|
||||
applyBtn.innerHTML = `Apply`;
|
||||
}
|
||||
}
|
||||
|
||||
updateProgressDisplay(progress) {
|
||||
logger.debug('WiFiConfigComponent: Updating progress display:', progress);
|
||||
|
||||
const progressContainer = this.findElement('#wifi-progress-container');
|
||||
|
||||
if (!progressContainer || !progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { current, total, status } = progress;
|
||||
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
|
||||
progressContainer.innerHTML = `
|
||||
<div class="upload-progress-info">
|
||||
<div class="overall-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="wifi-progress-bar" style="width: ${percentage}%; background-color: #60a5fa;"></div>
|
||||
</div>
|
||||
<span class="progress-text">${current}/${total} Configured (${percentage}%)</span>
|
||||
</div>
|
||||
<div class="progress-summary" id="wifi-progress-summary">
|
||||
<span>Status: ${status}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showProgressBar(totalNodes) {
|
||||
logger.debug('WiFiConfigComponent: Showing initial progress bar for', totalNodes, 'nodes');
|
||||
|
||||
const progressContainer = this.findElement('#wifi-progress-container');
|
||||
|
||||
if (!progressContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
progressContainer.innerHTML = `
|
||||
<div class="upload-progress-info">
|
||||
<div class="overall-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="wifi-progress-bar" style="width: 0%; background-color: #60a5fa;"></div>
|
||||
</div>
|
||||
<span class="progress-text">0/${totalNodes} Configured (0%)</span>
|
||||
</div>
|
||||
<div class="progress-summary" id="wifi-progress-summary">
|
||||
<span>Status: Preparing configuration...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateResultsDisplay(results) {
|
||||
logger.debug('WiFiConfigComponent: Updating results display:', results);
|
||||
|
||||
const progressContainer = this.findElement('#wifi-progress-container');
|
||||
|
||||
if (!progressContainer || !results || results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resultsHtml = results.map(result => `
|
||||
<div class="result-item ${result.success ? 'success' : 'error'}">
|
||||
<div class="result-node">
|
||||
<span class="node-name">${result.node.hostname || result.node.ip}</span>
|
||||
<span class="node-ip">${result.node.ip}</span>
|
||||
</div>
|
||||
<div class="result-status">
|
||||
<span class="status-indicator ${result.success ? 'success' : 'error'}">
|
||||
${result.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
${result.error ? `<div class="result-error">${this.escapeHtml(result.error)}</div>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Append results to existing progress container
|
||||
const existingProgress = progressContainer.querySelector('.upload-progress-info');
|
||||
if (existingProgress) {
|
||||
existingProgress.innerHTML += `
|
||||
<div class="results-section">
|
||||
<div class="results-list">
|
||||
${resultsHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async handleApply() {
|
||||
logger.debug('WiFiConfigComponent: Apply button clicked');
|
||||
|
||||
if (!this.formValid) {
|
||||
logger.warn('WiFiConfigComponent: Form is not valid, cannot apply');
|
||||
return;
|
||||
}
|
||||
|
||||
const ssid = this.findElement('#wifi-ssid').value.trim();
|
||||
const password = this.findElement('#wifi-password').value.trim();
|
||||
const targetNodes = this.viewModel.get('targetNodes');
|
||||
|
||||
logger.debug('WiFiConfigComponent: Applying WiFi config to', targetNodes.length, 'nodes');
|
||||
logger.debug('WiFiConfigComponent: SSID:', ssid);
|
||||
|
||||
// Start configuration
|
||||
this.viewModel.startConfiguration();
|
||||
|
||||
// Show initial progress bar
|
||||
this.showProgressBar(targetNodes.length);
|
||||
|
||||
try {
|
||||
// Update progress
|
||||
this.viewModel.updateConfigProgress(0, targetNodes.length, 'Starting configuration...');
|
||||
|
||||
// Apply configuration to each node
|
||||
for (let i = 0; i < targetNodes.length; i++) {
|
||||
const node = targetNodes[i];
|
||||
|
||||
try {
|
||||
logger.debug('WiFiConfigComponent: Configuring node:', node.ip);
|
||||
|
||||
// Update progress
|
||||
this.viewModel.updateConfigProgress(i + 1, targetNodes.length, `Configuring ${node.hostname || node.ip}...`);
|
||||
|
||||
// Make API call to configure WiFi
|
||||
const result = await this.configureNodeWiFi(node, ssid, password);
|
||||
|
||||
// Add successful result
|
||||
this.viewModel.addConfigResult({
|
||||
node,
|
||||
success: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
logger.debug('WiFiConfigComponent: Successfully configured node:', node.ip);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WiFiConfigComponent: Failed to configure node:', node.ip, error);
|
||||
|
||||
// Add failed result
|
||||
this.viewModel.addConfigResult({
|
||||
node,
|
||||
success: false,
|
||||
error: error.message || 'Configuration failed'
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between requests to avoid overwhelming the nodes
|
||||
if (i < targetNodes.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
}
|
||||
|
||||
// Complete configuration
|
||||
this.viewModel.updateConfigProgress(targetNodes.length, targetNodes.length, 'Configuration complete');
|
||||
this.viewModel.completeConfiguration();
|
||||
|
||||
logger.debug('WiFiConfigComponent: WiFi configuration completed');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('WiFiConfigComponent: WiFi configuration failed:', error);
|
||||
this.viewModel.completeConfiguration();
|
||||
}
|
||||
}
|
||||
|
||||
async configureNodeWiFi(node, ssid, password) {
|
||||
logger.debug('WiFiConfigComponent: Configuring WiFi for node:', node.ip);
|
||||
|
||||
const response = await window.apiClient.callEndpoint({
|
||||
ip: node.ip,
|
||||
method: 'POST',
|
||||
uri: '/api/network/wifi/config',
|
||||
params: [
|
||||
{ name: 'ssid', value: ssid, location: 'body' },
|
||||
{ name: 'password', value: password, location: 'body' }
|
||||
]
|
||||
});
|
||||
|
||||
// Check if the API call was successful based on the response structure
|
||||
if (!response || response.status !== 200) {
|
||||
const errorMessage = response?.data?.message || response?.error || 'Failed to configure WiFi';
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
unmount() {
|
||||
logger.debug('WiFiConfigComponent: Unmounting...');
|
||||
super.unmount();
|
||||
logger.debug('WiFiConfigComponent: Unmounted');
|
||||
}
|
||||
}
|
||||
|
||||
window.WiFiConfigComponent = WiFiConfigComponent;
|
||||
@@ -647,7 +647,7 @@ class App {
|
||||
}
|
||||
|
||||
// Navigate to a route
|
||||
navigateTo(routeName) {
|
||||
navigateTo(routeName, updateUrl = true) {
|
||||
// Check cooldown period
|
||||
const now = Date.now();
|
||||
if (now - this.lastNavigationTime < this.navigationCooldown) {
|
||||
@@ -670,10 +670,45 @@ class App {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update URL if requested
|
||||
if (updateUrl) {
|
||||
this.updateURL(routeName);
|
||||
}
|
||||
|
||||
this.lastNavigationTime = now;
|
||||
this.performNavigation(routeName);
|
||||
}
|
||||
|
||||
// Update browser URL
|
||||
updateURL(routeName) {
|
||||
const url = `/${routeName}`;
|
||||
if (window.location.pathname !== url) {
|
||||
window.history.pushState({ route: routeName }, '', url);
|
||||
logger.debug(`App: Updated URL to ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get route from current URL
|
||||
getRouteFromURL() {
|
||||
const path = window.location.pathname;
|
||||
// Remove leading slash and use as route name
|
||||
const routeName = path.substring(1) || 'cluster'; // default to cluster
|
||||
return routeName;
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
handlePopState(event) {
|
||||
if (event.state && event.state.route) {
|
||||
logger.debug(`App: Handling popstate for route '${event.state.route}'`);
|
||||
this.navigateTo(event.state.route, false); // Don't update URL again
|
||||
} else {
|
||||
// Fallback: parse URL
|
||||
const routeName = this.getRouteFromURL();
|
||||
logger.debug(`App: Handling popstate, navigating to '${routeName}'`);
|
||||
this.navigateTo(routeName, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the actual navigation
|
||||
async performNavigation(routeName) {
|
||||
this.navigationInProgress = true;
|
||||
@@ -848,12 +883,27 @@ class App {
|
||||
|
||||
// Setup navigation
|
||||
setupNavigation() {
|
||||
// Intercept navigation link clicks
|
||||
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.preventDefault(); // Prevent default link behavior
|
||||
const routeName = tab.dataset.view;
|
||||
this.navigateTo(routeName);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (e) => this.handlePopState(e));
|
||||
|
||||
// Navigate to route based on current URL on initial load
|
||||
const initialRoute = this.getRouteFromURL();
|
||||
logger.debug(`App: Initial route from URL: '${initialRoute}'`);
|
||||
|
||||
// Set initial history state
|
||||
window.history.replaceState({ route: initialRoute }, '', `/${initialRoute}`);
|
||||
|
||||
// Navigate to initial route
|
||||
this.navigateTo(initialRoute, false); // Don't update URL since we just set it
|
||||
}
|
||||
|
||||
// Clean up cached components (call when app is shutting down)
|
||||
|
||||
@@ -584,14 +584,77 @@ class TopologyViewModel extends ViewModel {
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
selectedNode: null
|
||||
selectedNode: null,
|
||||
topologyMode: 'mesh', // 'mesh' or 'star'
|
||||
starCenterNode: null // IP of the center node in star mode
|
||||
});
|
||||
}
|
||||
|
||||
// Set up WebSocket event listeners for real-time topology updates
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
logger.warn('TopologyViewModel: WebSocket client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for cluster updates
|
||||
window.wsClient.on('clusterUpdate', (data) => {
|
||||
logger.debug('TopologyViewModel: Received WebSocket cluster update:', data);
|
||||
|
||||
// Update topology from WebSocket data
|
||||
if (data.members && Array.isArray(data.members)) {
|
||||
logger.debug(`TopologyViewModel: Updating topology with ${data.members.length} members, primary: ${data.primaryNode}`);
|
||||
|
||||
// Build enhanced graph data from updated members with primary node info
|
||||
this.buildEnhancedGraphData(data.members, data.primaryNode).then(({ nodes, links }) => {
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
primaryNode: data.primaryNode,
|
||||
lastUpdateTime: data.timestamp || new Date().toISOString()
|
||||
});
|
||||
}).catch(error => {
|
||||
logger.error('TopologyViewModel: Failed to build graph data from websocket update:', error);
|
||||
});
|
||||
} else {
|
||||
logger.warn('TopologyViewModel: Received cluster update but no valid members array:', data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for node discovery events
|
||||
window.wsClient.on('nodeDiscovery', (data) => {
|
||||
logger.debug('TopologyViewModel: Received WebSocket node discovery event:', data);
|
||||
|
||||
// Trigger animation for 'discovered' or 'active' actions (node is alive)
|
||||
// Skip animation for 'stale' or 'inactive' actions
|
||||
if (data.action === 'discovered' || data.action === 'active') {
|
||||
// Emit discovery animation event
|
||||
this.set('discoveryEvent', {
|
||||
nodeIp: data.nodeIp,
|
||||
timestamp: data.timestamp || new Date().toISOString(),
|
||||
action: data.action
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.debug('TopologyViewModel: WebSocket connected');
|
||||
// Connection restored - the server will send a clusterUpdate event shortly
|
||||
// No need to make an API call, just wait for the websocket data
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('TopologyViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
|
||||
// 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() {
|
||||
try {
|
||||
logger.debug('TopologyViewModel: updateNetworkTopology called');
|
||||
logger.debug('TopologyViewModel: updateNetworkTopology called (API call)');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
@@ -600,14 +663,24 @@ class TopologyViewModel extends ViewModel {
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
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 primaryNode = discoveryInfo.primaryNode || null;
|
||||
|
||||
logger.debug(`TopologyViewModel: Building graph with ${members.length} members, primary: ${primaryNode}`);
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members, primaryNode);
|
||||
|
||||
logger.debug(`TopologyViewModel: Built graph with ${nodes.length} nodes and ${links.length} links`);
|
||||
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
primaryNode: primaryNode,
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
@@ -621,93 +694,91 @@ class TopologyViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
async buildEnhancedGraphData(members) {
|
||||
// Creates either a mesh topology (all nodes connected) or star topology (center node to all others)
|
||||
async buildEnhancedGraphData(members, primaryNode) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const nodeConnections = new Map();
|
||||
|
||||
// Get existing nodes to preserve their positions
|
||||
const existingNodes = this.get('nodes') || [];
|
||||
const existingNodeMap = new Map(existingNodes.map(n => [n.id, n]));
|
||||
|
||||
// Create nodes from members
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
const existingNode = existingNodeMap.get(member.ip);
|
||||
const isPrimary = member.ip === primaryNode;
|
||||
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
ip: member.ip,
|
||||
status: member.status || 'UNKNOWN',
|
||||
latency: member.latency || 0,
|
||||
isPrimary: isPrimary,
|
||||
// Preserve both legacy 'resources' and preferred 'labels'
|
||||
labels: (member.labels && typeof member.labels === 'object') ? member.labels : (member.resources || {}),
|
||||
resources: member.resources || {},
|
||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||
// Preserve existing position if node already exists, otherwise assign random position
|
||||
x: existingNode ? existingNode.x : Math.random() * 1200 + 100,
|
||||
y: existingNode ? existingNode.y : Math.random() * 800 + 100,
|
||||
// Preserve velocity if it exists (for D3 simulation)
|
||||
vx: existingNode ? existingNode.vx : undefined,
|
||||
vy: existingNode ? existingNode.vy : undefined,
|
||||
// Preserve fixed position if it was dragged
|
||||
fx: existingNode ? existingNode.fx : undefined,
|
||||
fy: existingNode ? existingNode.fy : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to get cluster members from each node to build actual connections
|
||||
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
|
||||
}
|
||||
}
|
||||
// Build links based on topology mode
|
||||
const topologyMode = this.get('topologyMode') || 'mesh';
|
||||
|
||||
// Build links based on actual connections
|
||||
for (const [sourceIp, sourceMembers] of nodeConnections) {
|
||||
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');
|
||||
if (topologyMode === 'mesh') {
|
||||
// Full mesh - connect all nodes to all other nodes
|
||||
logger.debug('TopologyViewModel: Creating full mesh topology');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const sourceNode = nodes[i];
|
||||
const targetNode = nodes[j];
|
||||
|
||||
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
latency: estimatedLatency,
|
||||
latency: this.estimateLatency(sourceNode, targetNode),
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
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 };
|
||||
@@ -730,6 +801,61 @@ class TopologyViewModel extends ViewModel {
|
||||
clearSelection() {
|
||||
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
|
||||
@@ -1031,4 +1157,278 @@ class ClusterFirmwareViewModel extends ViewModel {
|
||||
|
||||
return file && targetNodes && targetNodes.length > 0 && !isUploading;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WiFi Configuration View Model
|
||||
class WiFiConfigViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.set('targetNodes', []);
|
||||
this.set('ssid', '');
|
||||
this.set('password', '');
|
||||
this.set('isConfiguring', false);
|
||||
this.set('configProgress', null);
|
||||
this.set('configResults', []);
|
||||
}
|
||||
|
||||
// Set target nodes (filtered from cluster view)
|
||||
setTargetNodes(nodes) {
|
||||
this.set('targetNodes', nodes);
|
||||
}
|
||||
|
||||
// Set WiFi credentials
|
||||
setCredentials(ssid, password) {
|
||||
this.set('ssid', ssid);
|
||||
this.set('password', password);
|
||||
}
|
||||
|
||||
// Start configuration
|
||||
startConfiguration() {
|
||||
this.set('isConfiguring', true);
|
||||
this.set('configProgress', {
|
||||
current: 0,
|
||||
total: 0,
|
||||
status: 'Preparing...'
|
||||
});
|
||||
this.set('configResults', []);
|
||||
}
|
||||
|
||||
// Update configuration progress
|
||||
updateConfigProgress(current, total, status) {
|
||||
this.set('configProgress', {
|
||||
current,
|
||||
total,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
// Add configuration result
|
||||
addConfigResult(result) {
|
||||
const results = this.get('configResults');
|
||||
results.push(result);
|
||||
this.set('configResults', results);
|
||||
}
|
||||
|
||||
// Complete configuration
|
||||
completeConfiguration() {
|
||||
this.set('isConfiguring', false);
|
||||
this.set('configProgress', null);
|
||||
}
|
||||
|
||||
// Reset configuration state
|
||||
resetConfiguration() {
|
||||
this.set('ssid', '');
|
||||
this.set('password', '');
|
||||
this.set('configProgress', null);
|
||||
this.set('configResults', []);
|
||||
this.set('isConfiguring', false);
|
||||
}
|
||||
|
||||
// Check if apply is enabled
|
||||
isApplyEnabled() {
|
||||
const ssid = this.get('ssid');
|
||||
const password = this.get('password');
|
||||
const targetNodes = this.get('targetNodes');
|
||||
const isConfiguring = this.get('isConfiguring');
|
||||
|
||||
return ssid && password && targetNodes && targetNodes.length > 0 && !isConfiguring;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
||||
--border-secondary: rgba(255, 255, 255, 0.15);
|
||||
--border-hover: rgba(255, 255, 255, 0.2);
|
||||
|
||||
--accent-primary: #4ade80;
|
||||
--accent-primary: #1d8b45;
|
||||
--accent-secondary: #60a5fa;
|
||||
--accent-warning: #fbbf24;
|
||||
--accent-error: #f87171;
|
||||
@@ -69,20 +69,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.theme-switcher:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: var(--shadow-secondary);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -98,9 +89,8 @@
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.05);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.theme-toggle:active {
|
||||
@@ -232,16 +222,9 @@
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-switcher {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
box-shadow: 0 4px 16px rgba(148, 163, 184, 0.08);
|
||||
}
|
||||
|
||||
[data-theme="light"] .theme-switcher:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
box-shadow: 0 6px 20px rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="light"] .view-content {
|
||||
|
||||
@@ -23,8 +23,8 @@ async function main() {
|
||||
await runExamples(client);
|
||||
} else {
|
||||
console.log('❌ No nodes discovered yet.');
|
||||
console.log('💡 Start the backend server and send CLUSTER_DISCOVERY messages');
|
||||
console.log('💡 Use: npm run test-discovery broadcast');
|
||||
console.log('💡 Start the backend server and send CLUSTER_HEARTBEAT messages');
|
||||
console.log('💡 Use: npm run test-heartbeat broadcast');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
212
test/registry-integration-test.js
Executable file
212
test/registry-integration-test.js
Executable file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Registry Integration Test
|
||||
*
|
||||
* Tests the registry API integration to ensure the firmware registry functionality works correctly
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REGISTRY_URL = 'http://localhost:8080';
|
||||
const TIMEOUT = 10000; // 10 seconds
|
||||
|
||||
function makeRequest(path, method = 'GET', body = null, isFormData = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 8080,
|
||||
path: path,
|
||||
method: method,
|
||||
headers: {}
|
||||
};
|
||||
|
||||
if (body && !isFormData) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(data);
|
||||
resolve({ status: res.statusCode, data: jsonData });
|
||||
} catch (error) {
|
||||
resolve({ status: res.statusCode, data: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.setTimeout(TIMEOUT, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(body);
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function testRegistryHealth() {
|
||||
console.log('Testing registry health endpoint...');
|
||||
try {
|
||||
const response = await makeRequest('/health');
|
||||
if (response.status === 200 && response.data.status === 'healthy') {
|
||||
console.log('✅ Registry health check passed');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ Registry health check failed:', response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Registry health check failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testListFirmware() {
|
||||
console.log('Testing list firmware endpoint...');
|
||||
try {
|
||||
const response = await makeRequest('/firmware');
|
||||
if (response.status === 200 && Array.isArray(response.data)) {
|
||||
console.log('✅ List firmware endpoint works, found', response.data.length, 'firmware entries');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ List firmware endpoint failed:', response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ List firmware endpoint failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testUploadFirmware() {
|
||||
console.log('Testing upload firmware endpoint...');
|
||||
|
||||
// Create a small test firmware file
|
||||
const testFirmwareContent = Buffer.from('test firmware content');
|
||||
const metadata = {
|
||||
name: 'test-firmware',
|
||||
version: '1.0.0',
|
||||
labels: {
|
||||
platform: 'esp32',
|
||||
app: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Create multipart form data
|
||||
const boundary = '----formdata-test-boundary';
|
||||
const formData = [
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="metadata"',
|
||||
'Content-Type: application/json',
|
||||
'',
|
||||
JSON.stringify(metadata),
|
||||
`--${boundary}`,
|
||||
'Content-Disposition: form-data; name="firmware"; filename="test.bin"',
|
||||
'Content-Type: application/octet-stream',
|
||||
'',
|
||||
testFirmwareContent.toString(),
|
||||
`--${boundary}--`
|
||||
].join('\r\n');
|
||||
|
||||
const response = await makeRequest('/firmware', 'POST', formData, true);
|
||||
|
||||
if (response.status === 201 && response.data.success) {
|
||||
console.log('✅ Upload firmware endpoint works');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ Upload firmware endpoint failed:', response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Upload firmware endpoint failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testDownloadFirmware() {
|
||||
console.log('Testing download firmware endpoint...');
|
||||
try {
|
||||
const response = await makeRequest('/firmware/test-firmware/1.0.0');
|
||||
if (response.status === 200) {
|
||||
console.log('✅ Download firmware endpoint works');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ Download firmware endpoint failed:', response);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Download firmware endpoint failed:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('Starting Registry Integration Tests...\n');
|
||||
|
||||
const tests = [
|
||||
{ name: 'Health Check', fn: testRegistryHealth },
|
||||
{ name: 'List Firmware', fn: testListFirmware },
|
||||
{ name: 'Upload Firmware', fn: testUploadFirmware },
|
||||
{ name: 'Download Firmware', fn: testDownloadFirmware }
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let total = tests.length;
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(`\n--- ${test.name} ---`);
|
||||
try {
|
||||
const result = await test.fn();
|
||||
if (result) {
|
||||
passed++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ ${test.name} failed with error:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n--- Test Results ---`);
|
||||
console.log(`Passed: ${passed}/${total}`);
|
||||
|
||||
if (passed === total) {
|
||||
console.log('🎉 All tests passed! Registry integration is working correctly.');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('⚠️ Some tests failed. Please check the registry server.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests if this script is executed directly
|
||||
if (require.main === module) {
|
||||
runTests().catch(error => {
|
||||
console.error('Test runner failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testRegistryHealth,
|
||||
testListFirmware,
|
||||
testUploadFirmware,
|
||||
testDownloadFirmware,
|
||||
runTests
|
||||
};
|
||||
Reference in New Issue
Block a user