Compare commits
119 Commits
83d252f3cc
...
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 | |||
| 07be307035 | |||
| 3b2b596014 | |||
| 75bb974a27 | |||
| 79a28bae22 | |||
| 478d23b805 | |||
| f3a61131db | |||
| 3314f7e10a | |||
| e431d3b551 | |||
| bf19071cc4 | |||
| 55cc8c8d8c | |||
| 39eae6562c | |||
| 2cc62d1ee2 | |||
| e58604d726 | |||
| 25911a183c | |||
| 6db56e470c | |||
| f77973a876 | |||
| fa6d72ea62 | |||
| 55bc38577c | |||
| fcff402c75 | |||
| 489fdafa1c | |||
| 7cee2ff94f | |||
| 675d51bc66 | |||
| 96e0641819 | |||
| a26ef3949a | |||
| 75dc122898 | |||
| 9be4af1c09 | |||
| c238105ce7 | |||
| d56e7f3ab6 | |||
| 8b17e18d52 | |||
| 620792178e | |||
| 9e3ab73a73 | |||
| 602a3d6215 | |||
| 85505586ac | |||
| 9d4b68e7fc | |||
| 491ddb86b8 | |||
| d2438eab82 | |||
| 5203b480b1 | |||
| 66b5537330 | |||
| cde3861c84 | |||
| 22adf7d65f | |||
| e4cfb77a67 | |||
| b4aeb9d388 | |||
| c13d544e54 | |||
| e0e86f88a9 | |||
| 5d350a3fcf | |||
| 4538853ec7 | |||
| 03e4c50766 | |||
| da80228eb4 | |||
| 5cd187e674 | |||
| eb50048016 | |||
| 3e1c6eaef0 | |||
| 0aca182de9 | |||
| 262b03413a | |||
| 1062691e7b | |||
| bfe973afe6 | |||
| fd1c8e5a8c | |||
| 13f771837b | |||
| 27f93959ff | |||
| f9dc811239 | |||
| d870219136 | |||
| 52436f8b93 | |||
| 41be660d94 | |||
| 2dbba87098 | |||
| d01f094edd | |||
| 6f1e194545 | |||
| 8476c76637 | |||
| d0557a56a2 | |||
| 3055c2cb0e | |||
| 0b341ad6dd | |||
| 103aeabea7 | |||
| 2cf3fb2852 | |||
| 7898a1d461 | |||
| 2cf50486a7 | |||
| 4498da72fa | |||
| 1564816dc6 | |||
| 4b1011ce5e | |||
| 6ff9f8dce9 | |||
| 29855a45a1 | |||
| 9986b4acac | |||
| d49a586eb0 | |||
| ab20128008 | |||
| a4736948f5 | |||
| ef40bf1ee2 | |||
| 2f271f4b29 | |||
| ac6c2fbb80 | |||
| 8b0267ea2a | |||
| cc7fa0fa00 |
56
.cursor/rules/cleancode.mdc
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
# Clean Code Guidelines
|
||||
|
||||
## Constants Over Magic Numbers
|
||||
- Replace hard-coded values with named constants
|
||||
- Use descriptive constant names that explain the value's purpose
|
||||
- Keep constants at the top of the file or in a dedicated constants file
|
||||
|
||||
## Meaningful Names
|
||||
- Variables, functions, and classes should reveal their purpose
|
||||
- Names should explain why something exists and how it's used
|
||||
- Avoid abbreviations unless they're universally understood
|
||||
|
||||
## Smart Comments
|
||||
- Don't comment on what the code does - make the code self-documenting
|
||||
- Use comments to explain why something is done a certain way
|
||||
- Document APIs, complex algorithms, and non-obvious side effects
|
||||
|
||||
## Single Responsibility
|
||||
- Each function should do exactly one thing
|
||||
- Functions should be small and focused
|
||||
- If a function needs a comment to explain what it does, it should be split
|
||||
|
||||
## DRY (Don't Repeat Yourself)
|
||||
- Extract repeated code into reusable functions
|
||||
- Share common logic through proper abstraction
|
||||
- Maintain single sources of truth
|
||||
|
||||
## Clean Structure
|
||||
- Keep related code together
|
||||
- Organize code in a logical hierarchy
|
||||
- Use consistent file and folder naming conventions
|
||||
|
||||
## Encapsulation
|
||||
- Hide implementation details
|
||||
- Expose clear interfaces
|
||||
- Move nested conditionals into well-named functions
|
||||
|
||||
## Code Quality Maintenance
|
||||
- Refactor continuously
|
||||
- Fix technical debt early
|
||||
- Leave code cleaner than you found it
|
||||
|
||||
## Testing
|
||||
- Write tests before fixing bugs
|
||||
- Keep tests readable and maintainable
|
||||
- Test edge cases and error conditions
|
||||
|
||||
## Version Control
|
||||
- Write clear commit messages
|
||||
- Make small, focused commits
|
||||
- Use meaningful branch names
|
||||
111
.cursor/rules/gitflow.mdc
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
|
||||
---
|
||||
# Gitflow Workflow Rules
|
||||
|
||||
## Main Branches
|
||||
|
||||
### main (or master)
|
||||
- Contains production-ready code
|
||||
- Never commit directly to main
|
||||
- Only accepts merges from:
|
||||
- hotfix/* branches
|
||||
- release/* branches
|
||||
- Must be tagged with version number after each merge
|
||||
|
||||
### develop
|
||||
- Main development branch
|
||||
- Contains latest delivered development changes
|
||||
- Source branch for feature branches
|
||||
- Never commit directly to develop
|
||||
|
||||
## Supporting Branches
|
||||
|
||||
### feature/*
|
||||
- Branch from: develop
|
||||
- Merge back into: develop
|
||||
- Naming convention: feature/[issue-id]-descriptive-name
|
||||
- Example: feature/123-user-authentication
|
||||
- Must be up-to-date with develop before creating PR
|
||||
- Delete after merge
|
||||
|
||||
### release/*
|
||||
- Branch from: develop
|
||||
- Merge back into:
|
||||
- main
|
||||
- develop
|
||||
- Naming convention: release/vX.Y.Z
|
||||
- Example: release/v1.2.0
|
||||
- Only bug fixes, documentation, and release-oriented tasks
|
||||
- No new features
|
||||
- Delete after merge
|
||||
|
||||
### hotfix/*
|
||||
- Branch from: main
|
||||
- Merge back into:
|
||||
- main
|
||||
- develop
|
||||
- Naming convention: hotfix/vX.Y.Z
|
||||
- Example: hotfix/v1.2.1
|
||||
- Only for urgent production fixes
|
||||
- Delete after merge
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Format: `type(scope): description`
|
||||
- Types:
|
||||
- feat: New feature
|
||||
- fix: Bug fix
|
||||
- docs: Documentation changes
|
||||
- style: Formatting, missing semicolons, etc.
|
||||
- refactor: Code refactoring
|
||||
- test: Adding tests
|
||||
- chore: Maintenance tasks
|
||||
|
||||
## Version Control
|
||||
|
||||
### Semantic Versioning
|
||||
- MAJOR version for incompatible API changes
|
||||
- MINOR version for backwards-compatible functionality
|
||||
- PATCH version for backwards-compatible bug fixes
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
1. All changes must go through Pull Requests
|
||||
2. Required approvals: minimum 1
|
||||
3. CI checks must pass
|
||||
4. No direct commits to protected branches (main, develop)
|
||||
5. Branch must be up to date before merging
|
||||
6. Delete branch after merge
|
||||
|
||||
## Branch Protection Rules
|
||||
|
||||
### main & develop
|
||||
- Require pull request reviews
|
||||
- Require status checks to pass
|
||||
- Require branches to be up to date
|
||||
- Include administrators in restrictions
|
||||
- No force pushes
|
||||
- No deletions
|
||||
|
||||
## Release Process
|
||||
|
||||
1. Create release branch from develop
|
||||
2. Bump version numbers
|
||||
3. Fix any release-specific issues
|
||||
4. Create PR to main
|
||||
5. After merge to main:
|
||||
- Tag release
|
||||
- Merge back to develop
|
||||
- Delete release branch
|
||||
|
||||
## Hotfix Process
|
||||
|
||||
1. Create hotfix branch from main
|
||||
2. Fix the issue
|
||||
3. Bump patch version
|
||||
4. Create PR to main
|
||||
5. After merge to main:
|
||||
- Tag release
|
||||
- Merge back to develop
|
||||
- Delete hotfix branch
|
||||
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
@@ -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
@@ -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 \
|
||||
.
|
||||
|
||||
237
README.md
@@ -1,206 +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
|
||||

|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
spore-ui/
|
||||
├── index.js # Express.js backend server
|
||||
├── api/
|
||||
│ └── openapi.yaml # API specification
|
||||
├── src/
|
||||
│ └── client/ # SPORE API client library
|
||||
│ ├── index.js # Main client class
|
||||
│ ├── package.json # Client package info
|
||||
│ ├── README.md # Client documentation
|
||||
│ └── example.js # Usage examples
|
||||
├── public/ # Frontend files
|
||||
│ ├── index.html # Main HTML page
|
||||
│ ├── styles.css # All CSS styles
|
||||
│ ├── framework.js # Enhanced component framework with state preservation
|
||||
│ ├── components.js # UI components with partial update support
|
||||
│ ├── view-models.js # Data models with UI state management
|
||||
│ ├── app.js # Main application logic
|
||||
│ └── test-state-preservation.html # Test interface for state preservation
|
||||
├── docs/
|
||||
│ └── STATE_PRESERVATION.md # Detailed documentation of state preservation system
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
1. **Install dependencies**: `npm install`
|
||||
2. **Start the server**: `npm start`
|
||||
3. **Open in browser**: `http://localhost:3001`
|
||||
4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html`
|
||||
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 with state preservation
|
||||
- **API**: SPORE Embedded System API
|
||||
- **Framework**: Custom component-based architecture
|
||||
- **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
|
||||
|
||||
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 458 KiB |
BIN
assets/events-messages.png
Normal file
|
After Width: | Height: | Size: 453 KiB |
BIN
assets/events.png
Normal file
|
After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 343 KiB |
BIN
assets/monitoring.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 390 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
@@ -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
@@ -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.
|
||||
69
docs/LOGGING.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# SPORE UI Backend Logging
|
||||
|
||||
The SPORE UI backend now includes a configurable logging system to reduce log noise while maintaining important information.
|
||||
|
||||
## Log Levels
|
||||
|
||||
The logging system supports different levels:
|
||||
|
||||
- **INFO**: Important operational messages (default)
|
||||
- **DEBUG**: Detailed debugging information (only shown when enabled)
|
||||
- **WARN**: Warning messages
|
||||
- **ERROR**: Error messages
|
||||
|
||||
## Controlling Log Levels
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the `LOG_LEVEL` environment variable to control logging:
|
||||
|
||||
```bash
|
||||
# Show only INFO, WARN, and ERROR messages (default)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Show all messages including DEBUG
|
||||
LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
|
||||
In development mode (`NODE_ENV=development`), DEBUG messages are automatically enabled:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development npm start
|
||||
```
|
||||
|
||||
## What Was Changed
|
||||
|
||||
The following verbose logging has been moved to DEBUG level:
|
||||
|
||||
1. **Heartbeat Messages**: Regular heartbeat logs from nodes
|
||||
2. **WebSocket Broadcasts**: Routine cluster update broadcasts
|
||||
3. **Proxy Calls**: Individual API proxy request details
|
||||
4. **Cluster Updates**: Member list change notifications
|
||||
5. **Discovery Events**: Routine node discovery messages
|
||||
|
||||
## Important Messages Still Shown
|
||||
|
||||
These messages remain at INFO level for operational visibility:
|
||||
|
||||
- Node discovery (new nodes)
|
||||
- Node status changes (inactive/stale)
|
||||
- Failover events
|
||||
- Server startup/shutdown
|
||||
- Error conditions
|
||||
|
||||
## Example Usage
|
||||
|
||||
```bash
|
||||
# Production with minimal logging
|
||||
LOG_LEVEL=info npm start
|
||||
|
||||
# Development with full debugging
|
||||
LOG_LEVEL=debug npm start
|
||||
|
||||
# Or use development mode
|
||||
NODE_ENV=development npm start
|
||||
```
|
||||
|
||||
This reduces log noise significantly while preserving important operational information.
|
||||
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
@@ -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
773
index.js
@@ -1,754 +1,45 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const dgram = require('dgram');
|
||||
const SporeApiClient = require('./src/client');
|
||||
const cors = require('cors');
|
||||
|
||||
// Simple logging utility with level control
|
||||
const logger = {
|
||||
debug: (...args) => {
|
||||
if (process.env.LOG_LEVEL === 'debug' || process.env.NODE_ENV === 'development') {
|
||||
console.log('[DEBUG]', ...args);
|
||||
}
|
||||
},
|
||||
info: (...args) => console.log('[INFO]', ...args),
|
||||
warn: (...args) => console.warn('[WARN]', ...args),
|
||||
error: (...args) => console.error('[ERROR]', ...args)
|
||||
};
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// File upload middleware
|
||||
const fileUpload = require('express-fileupload');
|
||||
app.use(fileUpload({
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB limit
|
||||
abortOnLimit: true,
|
||||
responseOnLimit: 'File size limit has been reached',
|
||||
debug: false
|
||||
}));
|
||||
|
||||
// Add CORS middleware
|
||||
app.use(cors({
|
||||
origin: '*', // Or specify your phone's IP range like: ['http://192.168.1.0/24']
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// UDP discovery configuration
|
||||
const UDP_PORT = 4210;
|
||||
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
|
||||
|
||||
// Initialize UDP server for auto discovery
|
||||
const udpServer = dgram.createSocket('udp4');
|
||||
|
||||
// Store discovered nodes and their IPs
|
||||
const discoveredNodes = new Map();
|
||||
let primaryNodeIp = null;
|
||||
|
||||
// UDP server event handlers
|
||||
udpServer.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`UDP port ${UDP_PORT} is already in use. Please check if another instance is running.`);
|
||||
} else {
|
||||
console.error('UDP Server error:', err);
|
||||
}
|
||||
udpServer.close();
|
||||
});
|
||||
|
||||
udpServer.on('message', (msg, rinfo) => {
|
||||
try {
|
||||
const message = msg.toString().trim();
|
||||
const sourceIp = rinfo.address;
|
||||
const sourcePort = rinfo.port;
|
||||
|
||||
//console.log(`UDP message received from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||
|
||||
if (message === DISCOVERY_MESSAGE) {
|
||||
//console.log(`Received CLUSTER_DISCOVERY from ${sourceIp}:${sourcePort}`);
|
||||
|
||||
// Store the discovered node
|
||||
const nodeInfo = {
|
||||
ip: sourceIp,
|
||||
port: sourcePort,
|
||||
discoveredAt: new Date(),
|
||||
lastSeen: new Date()
|
||||
};
|
||||
|
||||
discoveredNodes.set(sourceIp, nodeInfo);
|
||||
|
||||
// Set as primary node if this is the first one or if we don't have one
|
||||
if (!primaryNodeIp) {
|
||||
primaryNodeIp = sourceIp;
|
||||
console.log(`Set primary node to ${sourceIp}`);
|
||||
|
||||
// Immediately try to initialize the client
|
||||
updateSporeClient();
|
||||
}
|
||||
|
||||
// Update last seen timestamp
|
||||
discoveredNodes.get(sourceIp).lastSeen = new Date();
|
||||
|
||||
//console.log(`Node ${sourceIp} added/updated. Total discovered nodes: ${discoveredNodes.size}`);
|
||||
} else {
|
||||
console.log(`Received unknown message from ${sourceIp}:${sourcePort}: "${message}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing UDP message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
udpServer.on('listening', () => {
|
||||
const address = udpServer.address();
|
||||
console.log(`UDP discovery server listening on ${address.address}:${address.port}`);
|
||||
});
|
||||
|
||||
// Bind UDP server to listen for discovery messages
|
||||
udpServer.bind(UDP_PORT, () => {
|
||||
console.log(`UDP discovery server bound to port ${UDP_PORT}`);
|
||||
});
|
||||
|
||||
// Initialize the SPORE API client with dynamic IP
|
||||
let sporeClient = null;
|
||||
|
||||
// Function to initialize or update the SporeApiClient
|
||||
function initializeSporeClient(nodeIp) {
|
||||
if (!nodeIp) {
|
||||
console.warn('No node IP available for SporeApiClient initialization');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new SporeApiClient(`http://${nodeIp}`);
|
||||
console.log(`Initialized SporeApiClient with node IP: ${nodeIp}`);
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize SporeApiClient with IP ${nodeIp}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to clean up stale discovered nodes (nodes not seen in the last 5 minutes)
|
||||
function cleanupStaleNodes() {
|
||||
const now = new Date();
|
||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
for (const [ip, node] of discoveredNodes.entries()) {
|
||||
if (now - node.lastSeen > staleThreshold) {
|
||||
console.log(`Removing stale node: ${ip} (last seen: ${node.lastSeen.toISOString()})`);
|
||||
discoveredNodes.delete(ip);
|
||||
|
||||
// If this was our primary node, clear it
|
||||
if (primaryNodeIp === ip) {
|
||||
primaryNodeIp = null;
|
||||
console.log('Primary node became stale, clearing primary node selection');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to select the best primary node
|
||||
function selectBestPrimaryNode() {
|
||||
if (discoveredNodes.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we already have a valid primary node, keep it
|
||||
if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) {
|
||||
return primaryNodeIp;
|
||||
}
|
||||
|
||||
// Select the most recently seen node as primary
|
||||
let bestNode = null;
|
||||
let mostRecent = new Date(0);
|
||||
|
||||
for (const [ip, node] of discoveredNodes.entries()) {
|
||||
if (node.lastSeen > mostRecent) {
|
||||
mostRecent = node.lastSeen;
|
||||
bestNode = ip;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestNode && bestNode !== primaryNodeIp) {
|
||||
primaryNodeIp = bestNode;
|
||||
console.log(`Selected new primary node: ${bestNode}`);
|
||||
}
|
||||
|
||||
return bestNode;
|
||||
}
|
||||
|
||||
// Function to randomly select a primary node
|
||||
function selectRandomPrimaryNode() {
|
||||
if (discoveredNodes.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert discovered nodes to array and filter out current primary
|
||||
const availableNodes = Array.from(discoveredNodes.keys()).filter(ip => ip !== primaryNodeIp);
|
||||
|
||||
if (availableNodes.length === 0) {
|
||||
// If no other nodes available, keep current primary
|
||||
return primaryNodeIp;
|
||||
}
|
||||
|
||||
// Randomly select from available nodes
|
||||
const randomIndex = Math.floor(Math.random() * availableNodes.length);
|
||||
const randomNode = availableNodes[randomIndex];
|
||||
|
||||
// Update primary node
|
||||
primaryNodeIp = randomNode;
|
||||
console.log(`Randomly selected new primary node: ${randomNode}`);
|
||||
|
||||
return randomNode;
|
||||
}
|
||||
|
||||
// Initialize client when a node is discovered
|
||||
function updateSporeClient() {
|
||||
const nodeIp = selectBestPrimaryNode();
|
||||
if (nodeIp) {
|
||||
sporeClient = initializeSporeClient(nodeIp);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: perform an operation against the current primary, failing over to other discovered nodes if needed
|
||||
async function performWithFailover(operation) {
|
||||
// Build candidate list: current primary first, then others by most recently seen
|
||||
const candidateIps = [];
|
||||
if (primaryNodeIp && discoveredNodes.has(primaryNodeIp)) {
|
||||
candidateIps.push(primaryNodeIp);
|
||||
}
|
||||
const others = Array.from(discoveredNodes.values())
|
||||
.filter(n => n.ip !== primaryNodeIp)
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.map(n => n.ip);
|
||||
candidateIps.push(...others);
|
||||
|
||||
if (candidateIps.length === 0) {
|
||||
throw new Error('No SPORE nodes discovered');
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
for (const ip of candidateIps) {
|
||||
try {
|
||||
const client = (sporeClient && ip === primaryNodeIp)
|
||||
? sporeClient
|
||||
: initializeSporeClient(ip);
|
||||
if (!client) {
|
||||
throw new Error(`Failed to initialize client for ${ip}`);
|
||||
}
|
||||
const result = await operation(client, ip);
|
||||
if (ip !== primaryNodeIp) {
|
||||
primaryNodeIp = ip;
|
||||
sporeClient = client;
|
||||
console.log(`Failover: switched primary node to ${ip}`);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn(`Primary attempt on ${ip} failed: ${err.message}`);
|
||||
lastError = err;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('All discovered nodes failed');
|
||||
}
|
||||
|
||||
// Set up periodic tasks
|
||||
setInterval(() => {
|
||||
cleanupStaleNodes();
|
||||
if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) {
|
||||
updateSporeClient();
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Serve the main HTML page
|
||||
app.get('/', (req, res) => {
|
||||
// Health check endpoint (before catch-all route)
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'spore-ui-frontend',
|
||||
timestamp: new Date().toISOString(),
|
||||
note: 'Frontend server - API calls are handled by spore-gateway on port 3001'
|
||||
});
|
||||
});
|
||||
|
||||
// SPA catch-all route - serves index.html for all routes
|
||||
// This allows client-side routing to work properly
|
||||
// Using regex pattern for Express 5 compatibility
|
||||
app.get(/^\/(?!health$).*/, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// API endpoint to get discovered nodes
|
||||
app.get('/api/discovery/nodes', (req, res) => {
|
||||
const nodes = Array.from(discoveredNodes.values()).map(node => ({
|
||||
...node,
|
||||
discoveredAt: node.discoveredAt.toISOString(),
|
||||
lastSeen: node.lastSeen.toISOString(),
|
||||
isPrimary: node.ip === primaryNodeIp
|
||||
}));
|
||||
|
||||
res.json({
|
||||
primaryNode: primaryNodeIp,
|
||||
totalNodes: discoveredNodes.size,
|
||||
nodes: nodes,
|
||||
clientInitialized: !!sporeClient,
|
||||
clientBaseUrl: sporeClient ? sporeClient.baseUrl : null,
|
||||
discoveryStatus: {
|
||||
udpPort: UDP_PORT,
|
||||
discoveryMessage: DISCOVERY_MESSAGE,
|
||||
serverRunning: udpServer.listening
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API endpoint to manually trigger discovery refresh
|
||||
app.post('/api/discovery/refresh', (req, res) => {
|
||||
try {
|
||||
// Clean up stale nodes
|
||||
cleanupStaleNodes();
|
||||
|
||||
// Try to update the client
|
||||
updateSporeClient();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Discovery refresh completed',
|
||||
primaryNode: primaryNodeIp,
|
||||
totalNodes: discoveredNodes.size,
|
||||
clientInitialized: !!sporeClient
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during discovery refresh:', error);
|
||||
res.status(500).json({
|
||||
error: 'Discovery refresh failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to randomly select a new primary node
|
||||
app.post('/api/discovery/random-primary', (req, res) => {
|
||||
try {
|
||||
if (discoveredNodes.size === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No nodes available',
|
||||
message: 'No SPORE nodes have been discovered yet'
|
||||
});
|
||||
}
|
||||
|
||||
// Randomly select a new primary node
|
||||
const randomNode = selectRandomPrimaryNode();
|
||||
|
||||
if (!randomNode) {
|
||||
return res.status(500).json({
|
||||
error: 'Selection failed',
|
||||
message: 'Failed to select a random primary node'
|
||||
});
|
||||
}
|
||||
|
||||
// Update the client with the new primary node
|
||||
updateSporeClient();
|
||||
|
||||
// Get current timestamp for the response
|
||||
const timestamp = req.body && req.body.timestamp ? req.body.timestamp : new Date().toISOString();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Randomly selected new primary node: ${randomNode}`,
|
||||
primaryNode: primaryNodeIp,
|
||||
totalNodes: discoveredNodes.size,
|
||||
clientInitialized: !!sporeClient,
|
||||
timestamp: timestamp
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error selecting random primary node:', error);
|
||||
res.status(500).json({
|
||||
error: 'Random selection failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to manually set primary node
|
||||
app.post('/api/discovery/primary/:ip', (req, res) => {
|
||||
try {
|
||||
const requestedIp = req.params.ip;
|
||||
|
||||
if (!discoveredNodes.has(requestedIp)) {
|
||||
return res.status(404).json({
|
||||
error: 'Node not found',
|
||||
message: `Node with IP ${requestedIp} has not been discovered`
|
||||
});
|
||||
}
|
||||
|
||||
primaryNodeIp = requestedIp;
|
||||
updateSporeClient();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Primary node set to ${requestedIp}`,
|
||||
primaryNode: primaryNodeIp,
|
||||
clientInitialized: !!sporeClient
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting primary node:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to set primary node',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to get cluster members
|
||||
app.get('/api/cluster/members', async (req, res) => {
|
||||
try {
|
||||
if (discoveredNodes.size === 0) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
|
||||
discoveredNodes: Array.from(discoveredNodes.keys())
|
||||
});
|
||||
}
|
||||
|
||||
const members = await performWithFailover((client) => client.getClusterStatus());
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cluster members:', error);
|
||||
res.status(502).json({
|
||||
error: 'Failed to fetch cluster members',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to get task status
|
||||
app.get('/api/tasks/status', async (req, res) => {
|
||||
try {
|
||||
const { ip } = req.query;
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
const nodeClient = new SporeApiClient(`http://${ip}`);
|
||||
const taskStatus = await nodeClient.getTaskStatus();
|
||||
return res.json(taskStatus);
|
||||
} catch (innerError) {
|
||||
console.error('Error fetching task status from specific node:', innerError);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch task status from node',
|
||||
message: innerError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredNodes.size === 0) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
|
||||
discoveredNodes: Array.from(discoveredNodes.keys())
|
||||
});
|
||||
}
|
||||
|
||||
const taskStatus = await performWithFailover((client) => client.getTaskStatus());
|
||||
res.json(taskStatus);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task status:', error);
|
||||
res.status(502).json({
|
||||
error: 'Failed to fetch task status',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to get system status
|
||||
app.get('/api/node/status', async (req, res) => {
|
||||
try {
|
||||
if (discoveredNodes.size === 0) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
|
||||
discoveredNodes: Array.from(discoveredNodes.keys())
|
||||
});
|
||||
}
|
||||
|
||||
const systemStatus = await performWithFailover((client) => client.getSystemStatus());
|
||||
res.json(systemStatus);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system status:', error);
|
||||
res.status(502).json({
|
||||
error: 'Failed to fetch system status',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy endpoint to get node capabilities (optionally for a specific node via ?ip=)
|
||||
app.get('/api/capabilities', async (req, res) => {
|
||||
try {
|
||||
const { ip } = req.query;
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
const nodeClient = new SporeApiClient(`http://${ip}`);
|
||||
const caps = await nodeClient.getCapabilities();
|
||||
return res.json(caps);
|
||||
} catch (innerError) {
|
||||
console.error('Error fetching capabilities from specific node:', innerError);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch capabilities from node',
|
||||
message: innerError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (discoveredNodes.size === 0) {
|
||||
return res.status(503).json({
|
||||
error: 'Service unavailable',
|
||||
message: 'No SPORE nodes discovered yet. Waiting for CLUSTER_DISCOVERY messages...',
|
||||
discoveredNodes: Array.from(discoveredNodes.keys())
|
||||
});
|
||||
}
|
||||
|
||||
const caps = await performWithFailover((client) => client.getCapabilities());
|
||||
return res.json(caps);
|
||||
} catch (error) {
|
||||
console.error('Error fetching capabilities:', error);
|
||||
return res.status(502).json({
|
||||
error: 'Failed to fetch capabilities',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Generic proxy to call a node capability directly
|
||||
app.post('/api/proxy-call', async (req, res) => {
|
||||
try {
|
||||
const { ip, method, uri, params } = req.body || {};
|
||||
|
||||
if (!ip || !method || !uri) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
message: 'Required: ip, method, uri'
|
||||
});
|
||||
}
|
||||
|
||||
// Build target URL
|
||||
let targetPath = uri;
|
||||
let queryParams = new URLSearchParams();
|
||||
let bodyParams = new URLSearchParams();
|
||||
|
||||
if (Array.isArray(params)) {
|
||||
for (const p of params) {
|
||||
const name = p?.name;
|
||||
const value = p?.value ?? '';
|
||||
const location = (p?.location || 'body').toLowerCase();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
if (location === 'query') {
|
||||
queryParams.append(name, String(value));
|
||||
} else if (location === 'path') {
|
||||
// Replace {name} or :name in path
|
||||
targetPath = targetPath.replace(new RegExp(`[{:]${name}[}]?`, 'g'), encodeURIComponent(String(value)));
|
||||
} else {
|
||||
// Default to body
|
||||
bodyParams.append(name, String(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = `http://${ip}${targetPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
// Prepare fetch options
|
||||
const upperMethod = String(method).toUpperCase();
|
||||
const fetchOptions = { method: upperMethod, headers: {} };
|
||||
|
||||
if (upperMethod !== 'GET') {
|
||||
// Default to form-encoded body for generic proxy
|
||||
fetchOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
fetchOptions.body = bodyParams.toString();
|
||||
}
|
||||
|
||||
// Execute request
|
||||
const response = await fetch(fullUrl, fetchOptions);
|
||||
const respContentType = response.headers.get('content-type') || '';
|
||||
|
||||
let data;
|
||||
if (respContentType.includes('application/json')) {
|
||||
data = await response.json();
|
||||
} else {
|
||||
data = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Upstream request failed',
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('Error in /api/proxy-call:', error);
|
||||
return res.status(500).json({
|
||||
error: 'Proxy call failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Proxy endpoint to get status from a specific node
|
||||
app.get('/api/node/status/:ip', async (req, res) => {
|
||||
try {
|
||||
const nodeIp = req.params.ip;
|
||||
|
||||
// Create a temporary client for the specific node
|
||||
const nodeClient = new SporeApiClient(`http://${nodeIp}`);
|
||||
const nodeStatus = await nodeClient.getSystemStatus();
|
||||
|
||||
res.json(nodeStatus);
|
||||
} catch (error) {
|
||||
console.error(`Error fetching status from node ${req.params.ip}:`, error);
|
||||
res.status(500).json({
|
||||
error: `Failed to fetch status from node ${req.params.ip}`,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// File upload endpoint for firmware updates
|
||||
app.post('/api/node/update', async (req, res) => {
|
||||
try {
|
||||
const nodeIp = req.query.ip || req.headers['x-node-ip'];
|
||||
|
||||
if (!nodeIp) {
|
||||
return res.status(400).json({
|
||||
error: 'Node IP address is required',
|
||||
message: 'Please provide the target node IP address'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we have a file in the request
|
||||
if (!req.files || !req.files.file) {
|
||||
console.log('File upload request received but no file found:', {
|
||||
hasFiles: !!req.files,
|
||||
fileKeys: req.files ? Object.keys(req.files) : [],
|
||||
contentType: req.headers['content-type']
|
||||
});
|
||||
return res.status(400).json({
|
||||
error: 'No file data received',
|
||||
message: 'Please select a firmware file to upload'
|
||||
});
|
||||
}
|
||||
|
||||
const uploadedFile = req.files.file;
|
||||
console.log(`File upload received:`, {
|
||||
nodeIp: nodeIp,
|
||||
filename: uploadedFile.name,
|
||||
fileSize: uploadedFile.data.length,
|
||||
mimetype: uploadedFile.mimetype,
|
||||
encoding: uploadedFile.encoding
|
||||
});
|
||||
|
||||
// Create a temporary client for the specific node
|
||||
const nodeClient = new SporeApiClient(`http://${nodeIp}`);
|
||||
console.log(`Created SPORE client for node ${nodeIp}`);
|
||||
|
||||
// Send the firmware data to the node
|
||||
console.log(`Starting firmware upload to SPORE device ${nodeIp}...`);
|
||||
|
||||
try {
|
||||
const updateResult = await nodeClient.updateFirmware(uploadedFile.data, uploadedFile.name);
|
||||
console.log(`Firmware upload to SPORE device ${nodeIp} completed successfully:`, updateResult);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Firmware uploaded successfully',
|
||||
nodeIp: nodeIp,
|
||||
fileSize: uploadedFile.data.length,
|
||||
filename: uploadedFile.name,
|
||||
result: updateResult
|
||||
});
|
||||
} catch (uploadError) {
|
||||
console.error(`Firmware upload to SPORE device ${nodeIp} failed:`, uploadError);
|
||||
throw new Error(`SPORE device upload failed: ${uploadError.message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading firmware:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to upload firmware',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
const health = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
http: true,
|
||||
udp: udpServer.listening,
|
||||
sporeClient: !!sporeClient
|
||||
},
|
||||
discovery: {
|
||||
totalNodes: discoveredNodes.size,
|
||||
primaryNode: primaryNodeIp,
|
||||
udpPort: UDP_PORT,
|
||||
serverRunning: udpServer.listening
|
||||
}
|
||||
};
|
||||
|
||||
// If no nodes discovered, mark as degraded
|
||||
if (discoveredNodes.size === 0) {
|
||||
health.status = 'degraded';
|
||||
health.message = 'No SPORE nodes discovered yet';
|
||||
}
|
||||
|
||||
// If no client initialized, mark as degraded
|
||||
if (!sporeClient) {
|
||||
health.status = 'degraded';
|
||||
health.message = health.message ?
|
||||
`${health.message}; SPORE client not initialized` :
|
||||
'SPORE client not initialized';
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'healthy' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Start the server
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server is running on http://0.0.0.0:${PORT}`);
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`SPORE UI Frontend Server is running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Accessible from: http://YOUR_COMPUTER_IP:${PORT}`);
|
||||
console.log(`UDP discovery server listening on port ${UDP_PORT}`);
|
||||
console.log('Waiting for CLUSTER_DISCOVERY messages from SPORE nodes...');
|
||||
});
|
||||
|
||||
// Graceful shutdown handling
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nReceived SIGINT. Shutting down gracefully...');
|
||||
udpServer.close(() => {
|
||||
console.log('UDP server closed.');
|
||||
});
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\nReceived SIGTERM. Shutting down gracefully...');
|
||||
udpServer.close(() => {
|
||||
console.log('UDP server closed.');
|
||||
});
|
||||
server.close(() => {
|
||||
console.log('HTTP server closed.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('Uncaught Exception:', err);
|
||||
udpServer.close();
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
||||
udpServer.close();
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
console.log(`Frontend connects to spore-gateway for API and WebSocket functionality`);
|
||||
console.log(`Make sure spore-gateway is running on port 3001`);
|
||||
});
|
||||
24
package-lock.json
generated
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-fileupload": "^1.4.3"
|
||||
"express-fileupload": "^1.4.3",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -875,6 +876,27 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
package.json
@@ -11,6 +11,18 @@
|
||||
"demo-discovery": "node test/demo-discovery.js",
|
||||
"demo-frontend": "node test/demo-frontend.js",
|
||||
"test-random-selection": "node test/test-random-selection.js",
|
||||
"mock": "node test/mock-cli.js",
|
||||
"mock:start": "node test/mock-cli.js start",
|
||||
"mock:list": "node test/mock-cli.js list",
|
||||
"mock:info": "node test/mock-cli.js info",
|
||||
"mock:healthy": "node test/mock-cli.js start healthy",
|
||||
"mock:degraded": "node test/mock-cli.js start degraded",
|
||||
"mock:large": "node test/mock-cli.js start large",
|
||||
"mock:unstable": "node test/mock-cli.js start unstable",
|
||||
"mock:single": "node test/mock-cli.js start single",
|
||||
"mock:empty": "node test/mock-cli.js start empty",
|
||||
"mock:test": "node test/mock-test.js",
|
||||
"mock:integration": "node test/test-mock-integration.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
@@ -19,6 +31,7 @@
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-fileupload": "^1.4.3"
|
||||
"express-fileupload": "^1.4.3",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 728 B |
@@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI</title>
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
<link rel="stylesheet" href="styles/theme.css?v=1757159926">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -17,12 +18,71 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
<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"/>
|
||||
<circle cx="16" cy="8" r="1.5"/>
|
||||
<circle cx="14" cy="15" r="1.5"/>
|
||||
<path d="M9 11l3 3M9 11l6-3"/>
|
||||
</svg>
|
||||
Cluster
|
||||
</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"/>
|
||||
<circle cx="16" cy="18" r="1.6"/>
|
||||
<circle cx="8" cy="18" r="1.6"/>
|
||||
<circle cx="5" cy="9" r="1.6"/>
|
||||
<path d="M12 4L16 18M16 18L5 9M5 9L19 9M19 9L8 18M8 18L12 4"/>
|
||||
</svg>
|
||||
Topology
|
||||
</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
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="cluster-status">🚀 Cluster Online</div>
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cluster-status">Cluster</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,10 +91,10 @@
|
||||
<div class="cluster-header">
|
||||
<div class="cluster-header-left">
|
||||
<div class="primary-node-info">
|
||||
<span class="primary-node-label">Primary Node:</span>
|
||||
<span class="primary-node-label">API:</span>
|
||||
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
|
||||
<button class="primary-node-refresh" id="select-random-primary-btn"
|
||||
title="🎲 Select random primary node">
|
||||
title="Select random primary node">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14"
|
||||
height="14">
|
||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||
@@ -42,15 +102,51 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cluster-filters">
|
||||
<div class="filter-group">
|
||||
<label for="label-key-filter" class="filter-label">Filter by Label:</label>
|
||||
<select id="label-key-filter" class="filter-select">
|
||||
<option value="">All Labels</option>
|
||||
</select>
|
||||
<select id="label-value-filter" class="filter-select">
|
||||
<option value="">All Values</option>
|
||||
</select>
|
||||
<div class="filter-pills-container" id="filter-pills-container">
|
||||
<!-- Active filter pills will be dynamically added here -->
|
||||
</div>
|
||||
<button id="clear-filters-btn" class="clear-filters-btn" title="Clear all filters">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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"/>
|
||||
<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>
|
||||
<button class="refresh-btn" id="refresh-cluster-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<button class="refresh-btn" id="refresh-cluster-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
@@ -71,71 +167,95 @@
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<!--div class="firmware-header">
|
||||
<div class="firmware-header-left"></div>
|
||||
<button class="refresh-btn" onclick="refreshFirmwareView()">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div-->
|
||||
|
||||
<div id="firmware-container">
|
||||
<div class="firmware-overview">
|
||||
<div class="firmware-actions">
|
||||
<div class="action-group">
|
||||
<h3>🚀 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()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span class="file-info" id="file-info">No file selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>🚀 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="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>
|
||||
</div>
|
||||
|
||||
<div class="firmware-nodes-list" id="firmware-nodes-list">
|
||||
<!-- Nodes will be populated here -->
|
||||
<div id="monitoring-view" class="view-content">
|
||||
<div class="monitoring-view-section">
|
||||
<div class="monitoring-header">
|
||||
<h2>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18" style="margin-right:8px; vertical-align: -2px;">
|
||||
<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>
|
||||
</svg>
|
||||
Monitoring
|
||||
</h2>
|
||||
<button class="refresh-btn" id="refresh-monitoring-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="monitoring-content">
|
||||
<div class="cluster-summary" id="cluster-summary">
|
||||
<div class="loading">
|
||||
<div>Loading cluster resource summary...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nodes-monitoring" id="nodes-monitoring">
|
||||
<div class="loading">
|
||||
<div>Loading node resource data...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="events-view" class="view-content">
|
||||
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||
<div class="loading">
|
||||
<div>Waiting for websocket events...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,10 +264,31 @@
|
||||
|
||||
<script src="./vendor/d3.v7.min.js"></script>
|
||||
<script src="./scripts/constants.js"></script>
|
||||
<script src="./scripts/icons.js"></script>
|
||||
<script src="./scripts/framework.js"></script>
|
||||
<script src="./scripts/api-client.js"></script>
|
||||
<script src="./scripts/view-models.js"></script>
|
||||
<script src="./scripts/components.js"></script>
|
||||
<!-- Base/leaf components first -->
|
||||
<script src="./scripts/components/DrawerComponent.js"></script>
|
||||
<script src="./scripts/components/TerminalPanelComponent.js"></script>
|
||||
<script src="./scripts/components/PrimaryNodeComponent.js"></script>
|
||||
<script src="./scripts/components/NodeDetailsComponent.js"></script>
|
||||
<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>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -80,11 +87,11 @@ class ApiClient {
|
||||
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
|
||||
}
|
||||
|
||||
async getCapabilities(ip) {
|
||||
return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
|
||||
async getEndpoints(ip) {
|
||||
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
|
||||
}
|
||||
|
||||
async callCapability({ ip, method, uri, params }) {
|
||||
async callEndpoint({ ip, method, uri, params }) {
|
||||
return this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: { ip, method, uri, params }
|
||||
@@ -94,15 +101,281 @@ class ApiClient {
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return this.request(`/api/node/update`, {
|
||||
const data = await this.request(`/api/node/update`, {
|
||||
method: 'POST',
|
||||
query: { ip: nodeIp },
|
||||
body: formData,
|
||||
isForm: true,
|
||||
headers: {},
|
||||
});
|
||||
// Some endpoints may return HTTP 200 with success=false on logical failure
|
||||
if (data && data.success === false) {
|
||||
const message = data.message || 'Firmware upload failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMonitoringResources(ip) {
|
||||
return this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
ip: ip,
|
||||
method: 'GET',
|
||||
uri: '/api/monitoring/resources',
|
||||
params: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getNodeLabels(ip) {
|
||||
return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||
}
|
||||
|
||||
async setNodeLabels(ip, labels) {
|
||||
return this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
ip: ip,
|
||||
method: 'POST',
|
||||
uri: '/api/node/config',
|
||||
params: [{ name: 'labels', value: JSON.stringify(labels) }]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
window.apiClient = new ApiClient();
|
||||
window.apiClient = new ApiClient();
|
||||
|
||||
// WebSocket Client for real-time updates
|
||||
class WebSocketClient {
|
||||
constructor() {
|
||||
this.ws = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000; // Start with 1 second
|
||||
this.listeners = new Map();
|
||||
this.isConnected = false;
|
||||
|
||||
// Auto-detect WebSocket URL based on current location
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// Use ws:// for HTTP and wss:// for HTTPS
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
this.wsUrl = `${wsProtocol}//localhost:3001/ws`;
|
||||
} else {
|
||||
this.wsUrl = `${wsProtocol}//${currentHost}:3001/ws`;
|
||||
}
|
||||
|
||||
logger.debug('WebSocket Client initialized with URL:', this.wsUrl);
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
try {
|
||||
this.ws = new WebSocket(this.wsUrl);
|
||||
this.setupEventListeners();
|
||||
} catch (error) {
|
||||
logger.error('Failed to create WebSocket connection:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.ws.onopen = () => {
|
||||
logger.debug('WebSocket connected');
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = 1000;
|
||||
|
||||
// Notify listeners of connection
|
||||
this.emit('connected');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
logger.debug('WebSocket message received:', data);
|
||||
const messageTopic = data.topic || data.type;
|
||||
logger.debug('WebSocket message topic:', messageTopic);
|
||||
this.emit('message', data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
logger.debug('WebSocket disconnected:', event.code, event.reason);
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected');
|
||||
|
||||
if (event.code !== 1000) { // Not a normal closure
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
this.emit('error', error);
|
||||
};
|
||||
}
|
||||
|
||||
handleMessage(data) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
logger.error('Max reconnection attempts reached');
|
||||
this.emit('maxReconnectAttemptsReached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); // Exponential backoff
|
||||
|
||||
logger.debug(`Scheduling WebSocket reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(event)) {
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, ...args) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (error) {
|
||||
logger.error('Error in WebSocket event listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
} else {
|
||||
logger.warn('WebSocket not connected, cannot send data');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'Client disconnect');
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionStatus() {
|
||||
return {
|
||||
connected: this.isConnected,
|
||||
reconnectAttempts: this.reconnectAttempts,
|
||||
maxReconnectAttempts: this.maxReconnectAttempts,
|
||||
url: this.wsUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Global WebSocket client instance
|
||||
window.wsClient = new WebSocketClient();
|
||||
@@ -1,19 +1,24 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
logger.debug('=== SPORE UI Application Initialization ===');
|
||||
|
||||
// Initialize the framework (but don't navigate yet)
|
||||
logger.debug('App: Creating framework instance...');
|
||||
const app = window.app;
|
||||
|
||||
// Components are loaded via script tags in order; no blocking wait required
|
||||
|
||||
// Create view models
|
||||
logger.debug('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
const firmwareViewModel = new FirmwareViewModel();
|
||||
const clusterFirmwareViewModel = new ClusterFirmwareViewModel();
|
||||
const topologyViewModel = new TopologyViewModel();
|
||||
logger.debug('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
|
||||
const monitoringViewModel = new 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) => {
|
||||
@@ -33,11 +38,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Connect cluster firmware view model to cluster data
|
||||
// Note: This subscription is disabled because target nodes should be set explicitly
|
||||
// when opening the firmware deploy drawer, not automatically updated
|
||||
/*
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
logger.debug('App: Members subscription triggered for cluster firmware:', members);
|
||||
if (members && members.length > 0) {
|
||||
// Extract node information for cluster firmware view
|
||||
const nodes = members.map(member => ({
|
||||
ip: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
labels: member.labels || {}
|
||||
}));
|
||||
clusterFirmwareViewModel.setTargetNodes(nodes);
|
||||
logger.debug('App: Updated cluster firmware view model with nodes:', nodes);
|
||||
} else {
|
||||
clusterFirmwareViewModel.setTargetNodes([]);
|
||||
logger.debug('App: Cleared cluster firmware view model nodes');
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
// Register routes with their view models
|
||||
logger.debug('App: Registering routes...');
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
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
|
||||
@@ -50,6 +79,65 @@ document.addEventListener('DOMContentLoaded', 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();
|
||||
@@ -88,7 +176,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
})();
|
||||
|
||||
// Set up periodic updates with state preservation
|
||||
// Set up periodic updates
|
||||
function setupPeriodicUpdates() {
|
||||
// Auto-refresh cluster members every 30 seconds using smart update
|
||||
setInterval(() => {
|
||||
@@ -97,7 +185,7 @@ function setupPeriodicUpdates() {
|
||||
|
||||
// Use smart update if available, otherwise fall back to regular update
|
||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||
logger.debug('App: Performing smart update to preserve UI state...');
|
||||
logger.debug('App: Performing smart update...');
|
||||
viewModel.smartUpdate();
|
||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
logger.debug('App: Performing regular update...');
|
||||
|
||||
1290
public/scripts/components/ClusterMembersComponent.js
Normal file
113
public/scripts/components/ClusterStatusComponent.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Cluster Status Component for header badge
|
||||
class ClusterStatusComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
this.wsConnected = false;
|
||||
this.wsReconnectAttempts = 0;
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
// Subscribe to properties that affect cluster status
|
||||
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
||||
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
||||
this.subscribeToProperty('error', this.render.bind(this));
|
||||
|
||||
// Set up WebSocket status listeners
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) return;
|
||||
|
||||
window.wsClient.on('connected', () => {
|
||||
this.wsConnected = true;
|
||||
this.wsReconnectAttempts = 0;
|
||||
this.render();
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
this.wsConnected = false;
|
||||
this.render();
|
||||
});
|
||||
|
||||
window.wsClient.on('maxReconnectAttemptsReached', () => {
|
||||
this.wsConnected = false;
|
||||
this.wsReconnectAttempts = window.wsClient ? window.wsClient.reconnectAttempts : 0;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Initialize current WebSocket status
|
||||
if (window.wsClient) {
|
||||
const status = window.wsClient.getConnectionStatus();
|
||||
this.wsConnected = status.connected;
|
||||
this.wsReconnectAttempts = status.reconnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const totalNodes = this.viewModel.get('totalNodes');
|
||||
const clientInitialized = this.viewModel.get('clientInitialized');
|
||||
const error = this.viewModel.get('error');
|
||||
|
||||
let statusText, statusIcon, statusClass;
|
||||
let wsStatusText = '';
|
||||
let wsStatusIcon = '';
|
||||
|
||||
// Determine WebSocket status
|
||||
if (this.wsConnected) {
|
||||
wsStatusIcon = window.icon('dotGreen', { width: 10, height: 10 });
|
||||
wsStatusText = 'WS';
|
||||
} else if (this.wsReconnectAttempts > 0) {
|
||||
wsStatusIcon = window.icon('dotYellow', { width: 10, height: 10 });
|
||||
wsStatusText = 'WS Reconnecting';
|
||||
} else {
|
||||
wsStatusIcon = window.icon('dotRed', { width: 10, height: 10 });
|
||||
wsStatusText = 'WS Offline';
|
||||
}
|
||||
|
||||
if (error) {
|
||||
statusText = 'Cluster Error';
|
||||
statusIcon = window.icon('error', { width: 12, height: 12 });
|
||||
statusClass = 'cluster-status-error';
|
||||
} else if (totalNodes === 0) {
|
||||
statusText = 'Cluster Offline';
|
||||
statusIcon = window.icon('dotRed', { width: 12, height: 12 });
|
||||
statusClass = 'cluster-status-offline';
|
||||
} else if (clientInitialized) {
|
||||
statusText = 'Cluster';
|
||||
statusIcon = window.icon('dotGreen', { width: 12, height: 12 });
|
||||
statusClass = 'cluster-status-online';
|
||||
} else {
|
||||
statusText = 'Cluster Connecting';
|
||||
statusIcon = window.icon('dotYellow', { width: 12, height: 12 });
|
||||
statusClass = 'cluster-status-connecting';
|
||||
}
|
||||
|
||||
// Update the cluster status badge using the container passed to this component
|
||||
if (this.container) {
|
||||
// Create HTML with both cluster and WebSocket status on a single compact line
|
||||
this.container.innerHTML = `
|
||||
<div class="cluster-status-compact">
|
||||
<span class="cluster-status-main">${statusIcon} ${statusText}</span>
|
||||
<span class="websocket-status" title="WebSocket Connection: ${wsStatusText}">${wsStatusIcon} ${wsStatusText}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Remove all existing status classes
|
||||
this.container.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error');
|
||||
|
||||
// Add the appropriate status class
|
||||
this.container.classList.add(statusClass);
|
||||
|
||||
// Add WebSocket connection class
|
||||
this.container.classList.remove('ws-connected', 'ws-disconnected', 'ws-reconnecting');
|
||||
if (this.wsConnected) {
|
||||
this.container.classList.add('ws-connected');
|
||||
} else if (this.wsReconnectAttempts > 0) {
|
||||
this.container.classList.add('ws-reconnecting');
|
||||
} else {
|
||||
this.container.classList.add('ws-disconnected');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
495
public/scripts/components/ClusterViewComponent.js
Normal file
@@ -0,0 +1,495 @@
|
||||
// Cluster View Component
|
||||
class ClusterViewComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('ClusterViewComponent: Constructor called');
|
||||
logger.debug('ClusterViewComponent: Container:', container);
|
||||
logger.debug('ClusterViewComponent: Container ID:', container?.id);
|
||||
|
||||
// Find elements for sub-components
|
||||
const primaryNodeContainer = this.findElement('.primary-node-info');
|
||||
const clusterMembersContainer = this.findElement('#cluster-members-container');
|
||||
|
||||
logger.debug('ClusterViewComponent: Primary node container:', primaryNodeContainer);
|
||||
logger.debug('ClusterViewComponent: Cluster members container:', clusterMembersContainer);
|
||||
logger.debug('ClusterViewComponent: Cluster members container ID:', clusterMembersContainer?.id);
|
||||
logger.debug('ClusterViewComponent: Cluster members container innerHTML:', clusterMembersContainer?.innerHTML);
|
||||
|
||||
// Create sub-components
|
||||
this.primaryNodeComponent = new PrimaryNodeComponent(
|
||||
primaryNodeContainer,
|
||||
viewModel,
|
||||
eventBus
|
||||
);
|
||||
|
||||
this.clusterMembersComponent = new ClusterMembersComponent(
|
||||
clusterMembersContainer,
|
||||
viewModel,
|
||||
eventBus
|
||||
);
|
||||
|
||||
logger.debug('ClusterViewComponent: Sub-components created');
|
||||
|
||||
// Track if we've already loaded data to prevent unnecessary reloads
|
||||
this.dataLoaded = false;
|
||||
|
||||
// Initialize overlay dialog
|
||||
this.overlayDialog = null;
|
||||
}
|
||||
|
||||
mount() {
|
||||
logger.debug('ClusterViewComponent: Mounting...');
|
||||
super.mount();
|
||||
|
||||
logger.debug('ClusterViewComponent: Mounting sub-components...');
|
||||
// Mount sub-components
|
||||
this.primaryNodeComponent.mount();
|
||||
this.clusterMembersComponent.mount();
|
||||
|
||||
// Set up refresh button event listener (since it's in the cluster header, not in the members container)
|
||||
this.setupRefreshButton();
|
||||
|
||||
// Set up deploy button event listener
|
||||
this.setupDeployButton();
|
||||
|
||||
// Set up config button event listener
|
||||
this.setupConfigButton();
|
||||
|
||||
// Initialize overlay dialog
|
||||
this.initializeOverlayDialog();
|
||||
|
||||
// Only load data if we haven't already or if the view model is empty
|
||||
const members = this.viewModel.get('members');
|
||||
const shouldLoadData = true; // always perform initial refresh quickly
|
||||
|
||||
if (shouldLoadData) {
|
||||
logger.debug('ClusterViewComponent: Starting initial data load...');
|
||||
// Initial data load - ensure it happens after mounting
|
||||
// Trigger immediately to reduce perceived startup latency
|
||||
this.viewModel.updateClusterMembers().then(() => {
|
||||
this.dataLoaded = true;
|
||||
}).catch(error => {
|
||||
logger.error('ClusterViewComponent: Failed to load initial data:', error);
|
||||
});
|
||||
} else {
|
||||
logger.debug('ClusterViewComponent: Data already loaded, skipping initial load');
|
||||
}
|
||||
|
||||
// Set up periodic updates
|
||||
// this.setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
logger.debug('ClusterViewComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
setupRefreshButton() {
|
||||
logger.debug('ClusterViewComponent: Setting up refresh button...');
|
||||
|
||||
const refreshBtn = this.findElement('.refresh-btn');
|
||||
logger.debug('ClusterViewComponent: Found refresh button:', !!refreshBtn, refreshBtn);
|
||||
|
||||
if (refreshBtn) {
|
||||
logger.debug('ClusterViewComponent: Adding click event listener to refresh button');
|
||||
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
|
||||
logger.debug('ClusterViewComponent: Event listener added successfully');
|
||||
} else {
|
||||
logger.error('ClusterViewComponent: Refresh button not found!');
|
||||
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
|
||||
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
|
||||
}
|
||||
}
|
||||
|
||||
setupDeployButton() {
|
||||
logger.debug('ClusterViewComponent: Setting up deploy button...');
|
||||
|
||||
const deployBtn = this.findElement('#deploy-firmware-btn');
|
||||
logger.debug('ClusterViewComponent: Found deploy button:', !!deployBtn, deployBtn);
|
||||
|
||||
if (deployBtn) {
|
||||
logger.debug('ClusterViewComponent: Adding click event listener to deploy button');
|
||||
this.addEventListener(deployBtn, 'click', this.handleDeploy.bind(this));
|
||||
logger.debug('ClusterViewComponent: Event listener added successfully');
|
||||
} else {
|
||||
logger.error('ClusterViewComponent: Deploy button not found!');
|
||||
logger.debug('ClusterViewComponent: Container HTML:', this.container.innerHTML);
|
||||
logger.debug('ClusterViewComponent: All buttons in container:', this.container.querySelectorAll('button'));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
if (!overlayContainer) {
|
||||
overlayContainer = document.createElement('div');
|
||||
overlayContainer.id = 'cluster-overlay-dialog';
|
||||
overlayContainer.className = 'overlay-dialog';
|
||||
document.body.appendChild(overlayContainer);
|
||||
}
|
||||
|
||||
// Create and initialize the overlay dialog component
|
||||
if (!this.overlayDialog) {
|
||||
const overlayVM = new ViewModel();
|
||||
this.overlayDialog = new OverlayDialogComponent(overlayContainer, overlayVM, this.eventBus);
|
||||
this.overlayDialog.mount();
|
||||
}
|
||||
}
|
||||
|
||||
showConfirmationDialog(options) {
|
||||
if (!this.overlayDialog) {
|
||||
this.initializeOverlayDialog();
|
||||
}
|
||||
|
||||
this.overlayDialog.show(options);
|
||||
}
|
||||
|
||||
async handleDeploy() {
|
||||
logger.debug('ClusterViewComponent: Deploy button clicked, opening firmware upload 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 firmware deployment. Please ensure cluster members are loaded and visible.',
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Open drawer with firmware upload interface
|
||||
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');
|
||||
|
||||
// Get display name for drawer title
|
||||
const nodeCount = targetNodes.length;
|
||||
const displayName = `Firmware Deployment - ${nodeCount} node${nodeCount !== 1 ? 's' : ''}`;
|
||||
|
||||
// Open drawer with content callback (hide terminal button for firmware upload)
|
||||
this.clusterMembersComponent.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
||||
// Create firmware upload view model and component
|
||||
const firmwareUploadVM = new ClusterFirmwareViewModel();
|
||||
firmwareUploadVM.setTargetNodes(targetNodes);
|
||||
|
||||
// Create HTML for firmware upload interface
|
||||
contentContainer.innerHTML = `
|
||||
<div class="firmware-upload-drawer">
|
||||
<div class="firmware-upload-section">
|
||||
<!--div class="firmware-upload-header">
|
||||
<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 Upload
|
||||
</h3>
|
||||
</div-->
|
||||
|
||||
<div class="firmware-upload-controls">
|
||||
<div class="file-input-wrapper">
|
||||
<div class="file-input-left">
|
||||
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button class="upload-btn-compact" onclick="document.getElementById('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>
|
||||
<button class="deploy-btn-compact" id="deploy-btn" disabled>
|
||||
<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="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 class="target-nodes-section">
|
||||
<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="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>
|
||||
Target Nodes (${targetNodes.length})
|
||||
</h3>
|
||||
<div class="target-nodes-list">
|
||||
${targetNodes.map(node => `
|
||||
<div class="target-node-item" data-node-ip="${node.ip}">
|
||||
<div class="node-info">
|
||||
<span class="node-name">${node.hostname || node.ip}</span>
|
||||
<span class="node-ip">${node.ip}</span>
|
||||
</div>
|
||||
<div class="node-status">
|
||||
<span class="status-indicator ready">Ready</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="firmware-progress-container">
|
||||
<!-- Progress will be shown here during upload -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Create and mount firmware upload component
|
||||
const firmwareUploadComponent = new FirmwareUploadComponent(contentContainer, firmwareUploadVM, this.eventBus);
|
||||
setActiveComponent(firmwareUploadComponent);
|
||||
firmwareUploadComponent.mount();
|
||||
|
||||
}, null, () => {
|
||||
// Close callback - clear any upload state
|
||||
logger.debug('ClusterViewComponent: Firmware upload drawer closed');
|
||||
}, 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...');
|
||||
|
||||
// Get the refresh button and show loading state
|
||||
const refreshBtn = this.findElement('.refresh-btn');
|
||||
logger.debug('ClusterViewComponent: Found refresh button for loading state:', !!refreshBtn);
|
||||
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
logger.debug('ClusterViewComponent: Original button text:', originalText);
|
||||
|
||||
refreshBtn.innerHTML = `
|
||||
<svg class="refresh-icon spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refreshing...
|
||||
`;
|
||||
refreshBtn.disabled = true;
|
||||
|
||||
try {
|
||||
logger.debug('ClusterViewComponent: Starting cluster members update...');
|
||||
// Always perform a full refresh when user clicks refresh button
|
||||
await this.viewModel.updateClusterMembers();
|
||||
logger.debug('ClusterViewComponent: Cluster members update completed successfully');
|
||||
} catch (error) {
|
||||
logger.error('ClusterViewComponent: Error during refresh:', error);
|
||||
// Show error state
|
||||
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
|
||||
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
|
||||
}
|
||||
} finally {
|
||||
logger.debug('ClusterViewComponent: Restoring button state...');
|
||||
// Restore button state
|
||||
refreshBtn.innerHTML = originalText;
|
||||
refreshBtn.disabled = false;
|
||||
}
|
||||
} else {
|
||||
logger.warn('ClusterViewComponent: Refresh button not found, using fallback refresh');
|
||||
// Fallback if button not found
|
||||
try {
|
||||
await this.viewModel.updateClusterMembers();
|
||||
} catch (error) {
|
||||
logger.error('ClusterViewComponent: Fallback refresh failed:', error);
|
||||
if (this.clusterMembersComponent && this.clusterMembersComponent.showErrorState) {
|
||||
this.clusterMembersComponent.showErrorState(error.message || 'Refresh failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unmount() {
|
||||
logger.debug('ClusterViewComponent: Unmounting...');
|
||||
|
||||
// Unmount sub-components
|
||||
if (this.primaryNodeComponent) {
|
||||
this.primaryNodeComponent.unmount();
|
||||
}
|
||||
if (this.clusterMembersComponent) {
|
||||
this.clusterMembersComponent.unmount();
|
||||
}
|
||||
|
||||
// Clear intervals
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
super.unmount();
|
||||
logger.debug('ClusterViewComponent: Unmounted');
|
||||
}
|
||||
|
||||
// Override pause method to handle sub-components
|
||||
onPause() {
|
||||
logger.debug('ClusterViewComponent: Pausing...');
|
||||
|
||||
// Pause sub-components
|
||||
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
|
||||
this.primaryNodeComponent.pause();
|
||||
}
|
||||
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
|
||||
this.clusterMembersComponent.pause();
|
||||
}
|
||||
|
||||
// Clear any active intervals
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Override resume method to handle sub-components
|
||||
onResume() {
|
||||
logger.debug('ClusterViewComponent: Resuming...');
|
||||
|
||||
// Resume sub-components
|
||||
if (this.primaryNodeComponent && this.primaryNodeComponent.isMounted) {
|
||||
this.primaryNodeComponent.resume();
|
||||
}
|
||||
if (this.clusterMembersComponent && this.clusterMembersComponent.isMounted) {
|
||||
this.clusterMembersComponent.resume();
|
||||
}
|
||||
|
||||
// Restart periodic updates if needed
|
||||
// this.setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
}
|
||||
|
||||
// Override to determine if re-render is needed on resume
|
||||
shouldRenderOnResume() {
|
||||
// Don't re-render on resume - the component should maintain its state
|
||||
return false;
|
||||
}
|
||||
|
||||
setupPeriodicUpdates() {
|
||||
// Update primary node display every 10 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.viewModel.updatePrimaryNodeDisplay();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
window.ClusterViewComponent = ClusterViewComponent;
|
||||
16
public/scripts/components/ComponentsLoader.js
Normal file
@@ -0,0 +1,16 @@
|
||||
(function(){
|
||||
// Simple readiness flag once all component constructors are present
|
||||
function allReady(){
|
||||
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) => {
|
||||
const start = Date.now();
|
||||
(function check(){
|
||||
if (allReady()) return resolve(true);
|
||||
if (Date.now() - start > timeoutMs) return reject(new Error('Components did not load in time'));
|
||||
setTimeout(check, 25);
|
||||
})();
|
||||
});
|
||||
};
|
||||
})();
|
||||
177
public/scripts/components/DrawerComponent.js
Normal file
@@ -0,0 +1,177 @@
|
||||
// Reusable Drawer Component for desktop slide-in panels
|
||||
class DrawerComponent {
|
||||
constructor() {
|
||||
if (window.__sharedDrawerInstance) {
|
||||
return window.__sharedDrawerInstance;
|
||||
}
|
||||
|
||||
this.detailsDrawer = null;
|
||||
this.detailsDrawerContent = null;
|
||||
this.activeDrawerComponent = null;
|
||||
this.onCloseCallback = null;
|
||||
|
||||
window.__sharedDrawerInstance = this;
|
||||
}
|
||||
|
||||
// Determine if we should use desktop drawer behavior
|
||||
isDesktop() {
|
||||
try {
|
||||
return window && window.innerWidth >= 1024; // desktop threshold
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ensureDrawer() {
|
||||
if (this.detailsDrawer) return;
|
||||
|
||||
// Create drawer
|
||||
this.detailsDrawer = document.createElement('div');
|
||||
this.detailsDrawer.className = 'details-drawer';
|
||||
|
||||
// Header with actions and close button
|
||||
const header = document.createElement('div');
|
||||
header.className = 'details-drawer-header';
|
||||
header.innerHTML = `
|
||||
<div class="drawer-title">Node Details</div>
|
||||
<div class="drawer-actions">
|
||||
<button class="drawer-terminal-btn" title="Open Terminal" aria-label="Open Terminal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 17l6-6-6-6"></path>
|
||||
<path d="M12 19h8"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="drawer-close" aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
this.detailsDrawer.appendChild(header);
|
||||
|
||||
// Content container
|
||||
this.detailsDrawerContent = document.createElement('div');
|
||||
this.detailsDrawerContent.className = 'details-drawer-content';
|
||||
this.detailsDrawer.appendChild(this.detailsDrawerContent);
|
||||
|
||||
// Terminal panel container (positioned left of details drawer)
|
||||
this.terminalPanelContainer = document.createElement('div');
|
||||
this.terminalPanelContainer.className = 'terminal-panel-container';
|
||||
document.body.appendChild(this.terminalPanelContainer);
|
||||
|
||||
document.body.appendChild(this.detailsDrawer);
|
||||
|
||||
// Close handlers
|
||||
const close = () => this.closeDrawer();
|
||||
header.querySelector('.drawer-close').addEventListener('click', close);
|
||||
const terminalBtn = header.querySelector('.drawer-terminal-btn');
|
||||
if (terminalBtn) {
|
||||
terminalBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const nodeIp = this.activeDrawerComponent && this.activeDrawerComponent.viewModel && this.activeDrawerComponent.viewModel.get('nodeIp');
|
||||
if (!window.TerminalPanel) return;
|
||||
const panel = window.TerminalPanel;
|
||||
const wasMinimized = panel.isMinimized;
|
||||
panel.open(this.terminalPanelContainer, nodeIp);
|
||||
if (nodeIp && panel._updateTitle) {
|
||||
panel._updateTitle(nodeIp);
|
||||
}
|
||||
if (wasMinimized && panel.restore) {
|
||||
panel.restore();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open terminal:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') close();
|
||||
});
|
||||
}
|
||||
|
||||
openDrawer(title, contentCallback, errorCallback, onCloseCallback, hideTerminalButton = false) {
|
||||
this.ensureDrawer();
|
||||
this.onCloseCallback = onCloseCallback;
|
||||
|
||||
// Set drawer title
|
||||
const titleEl = this.detailsDrawer.querySelector('.drawer-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = title;
|
||||
}
|
||||
|
||||
// Show/hide terminal button based on parameter
|
||||
const terminalBtn = this.detailsDrawer.querySelector('.drawer-terminal-btn');
|
||||
if (terminalBtn) {
|
||||
terminalBtn.style.display = hideTerminalButton ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Clear previous component if any
|
||||
if (this.activeDrawerComponent && typeof this.activeDrawerComponent.unmount === 'function') {
|
||||
try {
|
||||
this.activeDrawerComponent.unmount();
|
||||
} catch (_) {}
|
||||
}
|
||||
this.detailsDrawerContent.innerHTML = '<div class="loading-details">Loading detailed information...</div>';
|
||||
|
||||
// Execute content callback
|
||||
try {
|
||||
contentCallback(this.detailsDrawerContent, (component) => {
|
||||
this.activeDrawerComponent = component;
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to load drawer content:', error);
|
||||
if (errorCallback) {
|
||||
errorCallback(error);
|
||||
} else {
|
||||
this.detailsDrawerContent.innerHTML = `
|
||||
<div class="error">
|
||||
<strong>Error loading content:</strong><br>
|
||||
${this.escapeHtml ? this.escapeHtml(error.message) : error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Open drawer
|
||||
this.detailsDrawer.classList.add('open');
|
||||
// Inform terminal container that the drawer is open for alignment
|
||||
if (this.terminalPanelContainer) {
|
||||
this.terminalPanelContainer.classList.add('drawer-open');
|
||||
}
|
||||
}
|
||||
|
||||
closeDrawer() {
|
||||
if (this.detailsDrawer) this.detailsDrawer.classList.remove('open');
|
||||
if (this.terminalPanelContainer) {
|
||||
this.terminalPanelContainer.classList.remove('drawer-open');
|
||||
}
|
||||
|
||||
// Call close callback if provided
|
||||
if (this.onCloseCallback) {
|
||||
this.onCloseCallback();
|
||||
this.onCloseCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up drawer elements
|
||||
destroy() {
|
||||
if (this.detailsDrawer && this.detailsDrawer.parentNode) {
|
||||
this.detailsDrawer.parentNode.removeChild(this.detailsDrawer);
|
||||
}
|
||||
this.detailsDrawer = null;
|
||||
this.detailsDrawerContent = null;
|
||||
this.activeDrawerComponent = null;
|
||||
}
|
||||
|
||||
// Helper method for HTML escaping (can be overridden)
|
||||
escapeHtml(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
window.DrawerComponent = DrawerComponent;
|
||||
1128
public/scripts/components/EventComponent.js
Normal file
711
public/scripts/components/FirmwareComponent.js
Normal file
@@ -0,0 +1,711 @@
|
||||
// Registry Firmware Component - CRUD interface for firmware registry
|
||||
class FirmwareComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('FirmwareComponent: Constructor called');
|
||||
logger.debug('FirmwareComponent: Container:', container);
|
||||
|
||||
// Initialize drawer component
|
||||
this.drawer = new DrawerComponent();
|
||||
|
||||
// Registry connection status
|
||||
this.registryConnected = false;
|
||||
this.registryError = null;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Setup refresh button
|
||||
const refreshBtn = this.findElement('#refresh-firmware-btn');
|
||||
if (refreshBtn) {
|
||||
this.addEventListener(refreshBtn, 'click', this.refreshFirmwareList.bind(this));
|
||||
}
|
||||
|
||||
// Setup add firmware button
|
||||
const addBtn = this.findElement('#add-firmware-btn');
|
||||
if (addBtn) {
|
||||
this.addEventListener(addBtn, 'click', this.showAddFirmwareForm.bind(this));
|
||||
}
|
||||
|
||||
// Setup search input
|
||||
const searchInput = this.findElement('#firmware-search');
|
||||
if (searchInput) {
|
||||
this.addEventListener(searchInput, 'input', this.handleSearch.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToProperty('firmwareList', this.renderFirmwareList.bind(this));
|
||||
this.subscribeToProperty('isLoading', this.updateLoadingState.bind(this));
|
||||
this.subscribeToProperty('searchQuery', this.updateSearchResults.bind(this));
|
||||
this.subscribeToProperty('registryConnected', this.updateRegistryStatus.bind(this));
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
|
||||
logger.debug('FirmwareComponent: Mounting...');
|
||||
|
||||
// Check registry connection and load firmware list
|
||||
this.checkRegistryConnection();
|
||||
this.loadFirmwareList();
|
||||
|
||||
logger.debug('FirmwareComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
unmount() {
|
||||
this.cleanupDynamicListeners();
|
||||
super.unmount();
|
||||
}
|
||||
|
||||
async checkRegistryConnection() {
|
||||
try {
|
||||
await window.apiClient.getRegistryHealth();
|
||||
this.registryConnected = true;
|
||||
this.registryError = null;
|
||||
this.viewModel.set('registryConnected', true);
|
||||
} catch (error) {
|
||||
logger.error('Registry connection failed:', error);
|
||||
this.registryConnected = false;
|
||||
this.registryError = error.message;
|
||||
this.viewModel.set('registryConnected', false);
|
||||
}
|
||||
}
|
||||
|
||||
async loadFirmwareList() {
|
||||
try {
|
||||
this.viewModel.set('isLoading', true);
|
||||
const firmwareList = await window.apiClient.listFirmwareFromRegistry();
|
||||
this.viewModel.set('firmwareList', firmwareList);
|
||||
} catch (error) {
|
||||
logger.error('Failed to load firmware list:', error);
|
||||
this.viewModel.set('firmwareList', []);
|
||||
this.showError('Failed to load firmware list: ' + error.message);
|
||||
} finally {
|
||||
this.viewModel.set('isLoading', false);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshFirmwareList() {
|
||||
await this.checkRegistryConnection();
|
||||
await this.loadFirmwareList();
|
||||
}
|
||||
|
||||
renderFirmwareList() {
|
||||
const container = this.findElement('#firmware-list-container');
|
||||
if (!container) return;
|
||||
|
||||
const groupedFirmware = this.viewModel.get('firmwareList') || [];
|
||||
const searchQuery = this.viewModel.get('searchQuery') || '';
|
||||
|
||||
// Filter grouped firmware based on search query
|
||||
const filteredGroups = groupedFirmware.map(group => {
|
||||
if (!searchQuery) return group;
|
||||
|
||||
// Split search query into individual terms
|
||||
const searchTerms = searchQuery.toLowerCase().split(/\s+/).filter(term => term.length > 0);
|
||||
|
||||
// Filter firmware versions within the group
|
||||
const filteredFirmware = group.firmware.filter(firmware => {
|
||||
// All search terms must match somewhere in the firmware data
|
||||
return searchTerms.every(term => {
|
||||
// Check group name
|
||||
if (group.name.toLowerCase().includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check version
|
||||
if (firmware.version.toLowerCase().includes(term)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check labels
|
||||
if (Object.values(firmware.labels || {}).some(label =>
|
||||
label.toLowerCase().includes(term)
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
// Return group with filtered firmware, or null if no firmware matches
|
||||
return filteredFirmware.length > 0 ? {
|
||||
...group,
|
||||
firmware: filteredFirmware
|
||||
} : null;
|
||||
}).filter(group => group !== null);
|
||||
|
||||
if (filteredGroups.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="48" height="48">
|
||||
<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/>
|
||||
<path d="M12 8v8"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="empty-title">${searchQuery ? 'No firmware found' : 'No firmware available'}</div>
|
||||
<div class="empty-description">
|
||||
${searchQuery ? 'Try adjusting your search terms' : 'Upload your first firmware to get started'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-expand groups when search is active to show results
|
||||
const autoExpand = searchQuery.trim().length > 0;
|
||||
const firmwareHTML = filteredGroups.map(group => this.renderFirmwareGroup(group, autoExpand)).join('');
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="firmware-groups">
|
||||
${firmwareHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Setup event listeners for firmware items
|
||||
this.setupFirmwareItemListeners();
|
||||
}
|
||||
|
||||
renderFirmwareGroup(group, autoExpand = false) {
|
||||
const versionsHTML = group.firmware.map(firmware => this.renderFirmwareVersion(firmware)).join('');
|
||||
|
||||
// Add 'expanded' class if autoExpand is true (e.g., when search results are shown)
|
||||
const expandedClass = autoExpand ? 'expanded' : '';
|
||||
|
||||
return `
|
||||
<div class="firmware-group ${expandedClass}">
|
||||
<div class="firmware-group-header">
|
||||
<div class="firmware-group-header-content">
|
||||
<h3 class="firmware-group-name">${this.escapeHtml(group.name)}</h3>
|
||||
<span class="firmware-group-count">${group.firmware.length} version${group.firmware.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<svg class="firmware-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="firmware-versions">
|
||||
${versionsHTML}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFirmwareVersion(firmware) {
|
||||
const labels = firmware.labels || {};
|
||||
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
||||
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
||||
).join('');
|
||||
|
||||
const sizeKB = Math.round(firmware.size / 1024);
|
||||
|
||||
return `
|
||||
<div class="firmware-version-item clickable" data-name="${firmware.name}" data-version="${firmware.version}" title="Click to edit firmware">
|
||||
<div class="firmware-version-main">
|
||||
<div class="firmware-version-info">
|
||||
<div class="firmware-version-number">v${this.escapeHtml(firmware.version)}</div>
|
||||
<div class="firmware-size">${sizeKB} KB</div>
|
||||
</div>
|
||||
<div class="firmware-version-labels">
|
||||
${labelsHTML}
|
||||
</div>
|
||||
</div>
|
||||
<div class="firmware-version-actions">
|
||||
<button class="action-btn rollout-btn" title="Rollout firmware" data-name="${firmware.name}" data-version="${firmware.version}" data-labels='${JSON.stringify(firmware.labels)}'>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<polyline points="3,6 5,6 21,6"/>
|
||||
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFirmwareItem(firmware) {
|
||||
const labels = firmware.labels || {};
|
||||
const labelsHTML = Object.entries(labels).map(([key, value]) =>
|
||||
`<span class="label-chip" title="${key}: ${value}">${key}: ${value}</span>`
|
||||
).join('');
|
||||
|
||||
const sizeKB = Math.round(firmware.size / 1024);
|
||||
|
||||
return `
|
||||
<div class="firmware-list-item" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<div class="firmware-item-main">
|
||||
<div class="firmware-item-info">
|
||||
<div class="firmware-name">${this.escapeHtml(firmware.name)}</div>
|
||||
<div class="firmware-version">v${this.escapeHtml(firmware.version)}</div>
|
||||
<div class="firmware-size">${sizeKB} KB</div>
|
||||
</div>
|
||||
<div class="firmware-item-labels">
|
||||
${labelsHTML}
|
||||
</div>
|
||||
</div>
|
||||
<div class="firmware-item-actions">
|
||||
<button class="action-btn edit-btn" title="Edit firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn download-btn" title="Download firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7,10 12,15 17,10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn delete-btn" title="Delete firmware" data-name="${firmware.name}" data-version="${firmware.version}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<polyline points="3,6 5,6 21,6"/>
|
||||
<path d="M19,6v14a2,2 0 0,1 -2,2H7a2,2 0 0,1 -2,-2V6m3,0V4a2,2 0 0,1 2,-2h4a2,2 0 0,1 2,2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
setupFirmwareItemListeners() {
|
||||
// First, clean up existing listeners for dynamically created content
|
||||
this.cleanupDynamicListeners();
|
||||
this.dynamicUnsubscribers = [];
|
||||
|
||||
// Firmware group header clicks (for expand/collapse)
|
||||
const groupHeaders = this.findAllElements('.firmware-group-header');
|
||||
groupHeaders.forEach(header => {
|
||||
const handler = (e) => {
|
||||
const group = header.closest('.firmware-group');
|
||||
group.classList.toggle('expanded');
|
||||
};
|
||||
header.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => header.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Version item clicks (for editing)
|
||||
const versionItems = this.findAllElements('.firmware-version-item.clickable');
|
||||
versionItems.forEach(item => {
|
||||
const handler = (e) => {
|
||||
// Don't trigger if clicking on action buttons
|
||||
if (e.target.closest('.firmware-version-actions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = item.getAttribute('data-name');
|
||||
const version = item.getAttribute('data-version');
|
||||
this.showEditFirmwareForm(name, version);
|
||||
};
|
||||
item.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => item.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Rollout buttons
|
||||
const rolloutBtns = this.findAllElements('.rollout-btn');
|
||||
rolloutBtns.forEach(btn => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
const labels = JSON.parse(btn.getAttribute('data-labels') || '{}');
|
||||
this.showRolloutPanel(name, version, labels);
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Download buttons
|
||||
const downloadBtns = this.findAllElements('.download-btn');
|
||||
downloadBtns.forEach(btn => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
this.downloadFirmware(name, version);
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
|
||||
});
|
||||
|
||||
// Delete buttons
|
||||
const deleteBtns = this.findAllElements('.delete-btn');
|
||||
logger.debug('Found delete buttons:', deleteBtns.length);
|
||||
deleteBtns.forEach(btn => {
|
||||
const handler = (e) => {
|
||||
e.stopPropagation();
|
||||
const name = btn.getAttribute('data-name');
|
||||
const version = btn.getAttribute('data-version');
|
||||
logger.debug('Delete button clicked:', name, version);
|
||||
this.showDeleteConfirmation(name, version);
|
||||
};
|
||||
btn.addEventListener('click', handler);
|
||||
this.dynamicUnsubscribers.push(() => btn.removeEventListener('click', handler));
|
||||
});
|
||||
}
|
||||
|
||||
cleanupDynamicListeners() {
|
||||
if (this.dynamicUnsubscribers) {
|
||||
this.dynamicUnsubscribers.forEach(unsub => unsub());
|
||||
this.dynamicUnsubscribers = [];
|
||||
}
|
||||
}
|
||||
|
||||
showAddFirmwareForm() {
|
||||
this.openFirmwareForm('Add Firmware', null, null);
|
||||
}
|
||||
|
||||
showEditFirmwareForm(name, version) {
|
||||
const groupedFirmware = this.viewModel.get('firmwareList') || [];
|
||||
|
||||
// Find the firmware in the grouped data
|
||||
let firmware = null;
|
||||
for (const group of groupedFirmware) {
|
||||
if (group.name === name) {
|
||||
firmware = group.firmware.find(f => f.version === version);
|
||||
if (firmware) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firmware) {
|
||||
this.openFirmwareForm('Edit Firmware', firmware, null);
|
||||
}
|
||||
}
|
||||
|
||||
openFirmwareForm(title, firmwareData, onCloseCallback) {
|
||||
this.drawer.openDrawer(title, (contentContainer, setActiveComponent) => {
|
||||
const formComponent = new FirmwareFormComponent(contentContainer, this.viewModel, this.eventBus);
|
||||
setActiveComponent(formComponent);
|
||||
|
||||
formComponent.setFirmwareData(firmwareData);
|
||||
formComponent.setOnSaveCallback(() => {
|
||||
this.loadFirmwareList();
|
||||
this.drawer.closeDrawer();
|
||||
});
|
||||
formComponent.setOnCancelCallback(() => {
|
||||
this.drawer.closeDrawer();
|
||||
});
|
||||
|
||||
formComponent.mount();
|
||||
}, null, onCloseCallback, true); // Hide terminal button
|
||||
}
|
||||
|
||||
async downloadFirmware(name, version) {
|
||||
try {
|
||||
const blob = await window.apiClient.downloadFirmwareFromRegistry(name, version);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name}-${version}.bin`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
this.showSuccess(`Firmware ${name} v${version} downloaded successfully`);
|
||||
} catch (error) {
|
||||
logger.error('Download failed:', error);
|
||||
this.showError('Download failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
showDeleteConfirmation(name, version) {
|
||||
OverlayDialogComponent.danger({
|
||||
title: 'Delete Firmware',
|
||||
message: `Are you sure you want to delete firmware "${name}" version "${version}"?<br><br>This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
onConfirm: () => this.deleteFirmware(name, version)
|
||||
});
|
||||
}
|
||||
|
||||
async showRolloutPanel(name, version, labels) {
|
||||
try {
|
||||
// Get cluster node versions to show which nodes will be affected
|
||||
const nodeVersions = await window.apiClient.getClusterNodeVersions();
|
||||
|
||||
// Filter nodes that match the firmware labels
|
||||
const matchingNodes = nodeVersions.nodes.filter(node => {
|
||||
return this.nodeMatchesLabels(node.labels, labels);
|
||||
});
|
||||
|
||||
this.openRolloutDrawer(name, version, labels, matchingNodes);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get cluster node versions:', error);
|
||||
this.showError('Failed to get cluster information: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
nodeMatchesLabels(nodeLabels, firmwareLabels) {
|
||||
for (const [key, value] of Object.entries(firmwareLabels)) {
|
||||
if (!nodeLabels[key] || nodeLabels[key] !== value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
openRolloutDrawer(name, version, labels, matchingNodes) {
|
||||
this.drawer.openDrawer('Rollout Firmware', (contentContainer, setActiveComponent) => {
|
||||
const rolloutComponent = new RolloutComponent(contentContainer, this.viewModel, this.eventBus);
|
||||
setActiveComponent(rolloutComponent);
|
||||
|
||||
rolloutComponent.setRolloutData(name, version, labels, matchingNodes);
|
||||
rolloutComponent.setOnRolloutCallback((rolloutData) => {
|
||||
this.startRollout(rolloutData);
|
||||
});
|
||||
rolloutComponent.setOnCancelCallback(() => {
|
||||
this.drawer.closeDrawer();
|
||||
});
|
||||
|
||||
// Store reference for status updates
|
||||
this.currentRolloutComponent = rolloutComponent;
|
||||
|
||||
rolloutComponent.mount();
|
||||
}, null, null, true); // Hide terminal button
|
||||
}
|
||||
|
||||
async startRollout(rolloutData) {
|
||||
try {
|
||||
// Start rollout in the panel (no backdrop)
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.startRollout();
|
||||
}
|
||||
|
||||
const response = await window.apiClient.startRollout(rolloutData);
|
||||
|
||||
logger.info('Rollout started:', response);
|
||||
this.showSuccess(`Rollout started for ${response.totalNodes} nodes`);
|
||||
|
||||
// Set up WebSocket listener for rollout progress
|
||||
this.setupRolloutProgressListener(response.rolloutId);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Rollout failed:', error);
|
||||
this.showError('Rollout failed: ' + error.message);
|
||||
|
||||
// Reset rollout state on error
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.resetRolloutState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showRolloutProgress() {
|
||||
// Create backdrop and progress overlay
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'rollout-backdrop';
|
||||
backdrop.id = 'rollout-backdrop';
|
||||
|
||||
const progressOverlay = document.createElement('div');
|
||||
progressOverlay.className = 'rollout-progress-overlay';
|
||||
progressOverlay.innerHTML = `
|
||||
<div class="rollout-progress-content">
|
||||
<div class="rollout-progress-header">
|
||||
<h3>Rolling Out Firmware</h3>
|
||||
</div>
|
||||
<div class="rollout-progress-body">
|
||||
<div class="rollout-progress-info">
|
||||
<p>Firmware rollout in progress...</p>
|
||||
<p class="rollout-progress-text">Preparing rollout...</p>
|
||||
</div>
|
||||
<div class="rollout-progress-bar">
|
||||
<div class="rollout-progress-fill" id="rollout-progress-fill"></div>
|
||||
</div>
|
||||
<div class="rollout-progress-details" id="rollout-progress-details">
|
||||
<div class="rollout-node-list" id="rollout-node-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
backdrop.appendChild(progressOverlay);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
// Block UI interactions
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
hideRolloutProgress() {
|
||||
const backdrop = document.getElementById('rollout-backdrop');
|
||||
if (backdrop) {
|
||||
document.body.removeChild(backdrop);
|
||||
}
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
setupRolloutProgressListener(rolloutId) {
|
||||
// Track completed nodes for parallel processing
|
||||
this.completedNodes = new Set();
|
||||
this.totalNodes = 0;
|
||||
|
||||
const progressListener = (data) => {
|
||||
if (data.rolloutId === rolloutId) {
|
||||
// Set total nodes from first update
|
||||
if (this.totalNodes === 0) {
|
||||
this.totalNodes = data.total;
|
||||
}
|
||||
this.updateRolloutProgress(data);
|
||||
}
|
||||
};
|
||||
|
||||
window.wsClient.on('rolloutProgress', progressListener);
|
||||
|
||||
// Store listener for cleanup
|
||||
this.currentRolloutListener = progressListener;
|
||||
}
|
||||
|
||||
updateRolloutProgress(data) {
|
||||
// Update status in the rollout panel
|
||||
if (this.currentRolloutComponent) {
|
||||
this.currentRolloutComponent.updateNodeStatus(data.nodeIp, data.status);
|
||||
}
|
||||
|
||||
// Track completed nodes for parallel processing
|
||||
if (data.status === 'completed') {
|
||||
this.completedNodes.add(data.nodeIp);
|
||||
} else if (data.status === 'failed') {
|
||||
// Also count failed nodes as "processed" for completion check
|
||||
this.completedNodes.add(data.nodeIp);
|
||||
}
|
||||
|
||||
// Check if rollout is complete (all nodes processed)
|
||||
if (this.completedNodes.size >= this.totalNodes) {
|
||||
setTimeout(() => {
|
||||
this.showSuccess('Rollout completed successfully');
|
||||
|
||||
// Clean up WebSocket listener
|
||||
if (this.currentRolloutListener) {
|
||||
window.wsClient.off('rolloutProgress', this.currentRolloutListener);
|
||||
this.currentRolloutListener = null;
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFirmware(name, version) {
|
||||
try {
|
||||
await window.apiClient.deleteFirmwareFromRegistry(name, version);
|
||||
this.showSuccess(`Firmware ${name} v${version} deleted successfully`);
|
||||
await this.loadFirmwareList();
|
||||
} catch (error) {
|
||||
logger.error('Delete failed:', error);
|
||||
this.showError('Delete failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
handleSearch(event) {
|
||||
const query = event.target.value;
|
||||
this.viewModel.set('searchQuery', query);
|
||||
}
|
||||
|
||||
updateSearchResults() {
|
||||
// This method is called when searchQuery property changes
|
||||
// The actual filtering is handled in renderFirmwareList
|
||||
this.renderFirmwareList();
|
||||
}
|
||||
|
||||
updateLoadingState() {
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
const container = this.findElement('#firmware-list-container');
|
||||
|
||||
if (isLoading && container) {
|
||||
container.innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading firmware...</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
updateRegistryStatus() {
|
||||
const isConnected = this.viewModel.get('registryConnected');
|
||||
const statusElement = this.findElement('#registry-status');
|
||||
|
||||
if (statusElement) {
|
||||
if (isConnected) {
|
||||
statusElement.innerHTML = `
|
||||
<span class="status-indicator connected">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||
<polyline points="22,4 12,14.01 9,11.01"/>
|
||||
</svg>
|
||||
Registry Connected
|
||||
</span>
|
||||
`;
|
||||
} else {
|
||||
statusElement.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
showSuccess(message) {
|
||||
this.showNotification(message, 'success');
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showNotification(message, 'error');
|
||||
}
|
||||
|
||||
showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `notification notification-${type}`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.add('show');
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
notification.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
if (typeof text !== 'string') return text;
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
window.FirmwareComponent = FirmwareComponent;
|
||||
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;
|
||||
811
public/scripts/components/FirmwareUploadComponent.js
Normal file
@@ -0,0 +1,811 @@
|
||||
// Reusable Firmware Upload Component
|
||||
class FirmwareUploadComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('FirmwareUploadComponent: Constructor called');
|
||||
logger.debug('FirmwareUploadComponent: Container:', container);
|
||||
logger.debug('FirmwareUploadComponent: Container ID:', container?.id);
|
||||
|
||||
// Initialize overlay dialog
|
||||
this.overlayDialog = null;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Setup firmware file input
|
||||
const firmwareFile = this.findElement('#firmware-file');
|
||||
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() {
|
||||
this.subscribeToProperty('selectedFile', () => {
|
||||
this.updateFileInfo();
|
||||
this.updateDeployButton();
|
||||
});
|
||||
this.subscribeToProperty('isUploading', this.updateUploadState.bind(this));
|
||||
this.subscribeToProperty('uploadProgress', this.updateUploadProgress.bind(this));
|
||||
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();
|
||||
|
||||
logger.debug('FirmwareUploadComponent: Mounting...');
|
||||
|
||||
// Initialize overlay dialog
|
||||
this.initializeOverlayDialog();
|
||||
|
||||
// Initialize UI state
|
||||
this.updateFileInfo();
|
||||
this.updateDeployButton();
|
||||
|
||||
logger.debug('FirmwareUploadComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
render() {
|
||||
// Initial render is handled by the HTML template
|
||||
this.updateDeployButton();
|
||||
}
|
||||
|
||||
initializeOverlayDialog() {
|
||||
// Create overlay container if it doesn't exist
|
||||
let overlayContainer = document.getElementById('firmware-upload-overlay-dialog');
|
||||
if (!overlayContainer) {
|
||||
overlayContainer = document.createElement('div');
|
||||
overlayContainer.id = 'firmware-upload-overlay-dialog';
|
||||
overlayContainer.className = 'overlay-dialog';
|
||||
document.body.appendChild(overlayContainer);
|
||||
}
|
||||
|
||||
// Create and initialize the overlay dialog component
|
||||
if (!this.overlayDialog) {
|
||||
const overlayVM = new ViewModel();
|
||||
this.overlayDialog = new OverlayDialogComponent(overlayContainer, overlayVM, this.eventBus);
|
||||
this.overlayDialog.mount();
|
||||
}
|
||||
}
|
||||
|
||||
showConfirmationDialog(options) {
|
||||
if (!this.overlayDialog) {
|
||||
this.initializeOverlayDialog();
|
||||
}
|
||||
|
||||
this.overlayDialog.show(options);
|
||||
}
|
||||
|
||||
handleFileSelect(event) {
|
||||
const file = event.target.files[0];
|
||||
this.viewModel.setSelectedFile(file);
|
||||
}
|
||||
|
||||
async handleDeploy() {
|
||||
const file = this.viewModel.get('selectedFile');
|
||||
const targetNodes = this.viewModel.get('targetNodes');
|
||||
|
||||
if (!file) {
|
||||
this.showConfirmationDialog({
|
||||
title: 'No File Selected',
|
||||
message: 'Please select a firmware file first.',
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetNodes || targetNodes.length === 0) {
|
||||
this.showConfirmationDialog({
|
||||
title: 'No Target Nodes',
|
||||
message: 'No target nodes available for firmware update.',
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog for deployment
|
||||
this.showDeploymentConfirmation(file, targetNodes);
|
||||
}
|
||||
|
||||
showDeploymentConfirmation(file, targetNodes) {
|
||||
const title = 'Deploy Firmware';
|
||||
const message = `Upload firmware "${file.name}" to ${targetNodes.length} node(s)?<br><br>Target nodes:<br>${targetNodes.map(n => `• ${n.hostname || n.ip} (${n.ip})`).join('<br>')}<br><br>This will update the firmware on all selected nodes.`;
|
||||
|
||||
this.showConfirmationDialog({
|
||||
title: title,
|
||||
message: message,
|
||||
confirmText: 'Deploy',
|
||||
cancelText: 'Cancel',
|
||||
onConfirm: () => this.performDeployment(file, targetNodes),
|
||||
onCancel: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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('FirmwareUploadComponent: Firmware deployment failed:', error);
|
||||
this.showConfirmationDialog({
|
||||
title: 'Deployment Failed',
|
||||
message: `Deployment failed: ${error.message}`,
|
||||
confirmText: 'OK',
|
||||
cancelText: null,
|
||||
onConfirm: () => {},
|
||||
onCancel: null
|
||||
});
|
||||
// Only complete upload on error
|
||||
this.viewModel.completeUpload();
|
||||
this.hideProgressOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async performBatchUpload(file, nodes) {
|
||||
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 {
|
||||
// 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);
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`FirmwareUploadComponent: Failed to upload to node ${nodeIp}:`, error);
|
||||
const errorResult = {
|
||||
nodeIp: nodeIp,
|
||||
hostname: node.hostname || nodeIp,
|
||||
success: false,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
results.push(errorResult);
|
||||
|
||||
// For HTTP errors, we can immediately mark as failed since the upload didn't start
|
||||
this.updateNodeProgress(i + 1, totalNodes, nodeIp, 'Failed');
|
||||
}
|
||||
|
||||
// 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,
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
showUploadProgress(file, nodes) {
|
||||
// Update the target nodes section header to show upload progress
|
||||
const targetNodesSection = this.findElement('.target-nodes-section');
|
||||
if (targetNodesSection) {
|
||||
const h3 = targetNodesSection.querySelector('h3');
|
||||
if (h3) {
|
||||
h3.innerHTML = `
|
||||
<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>
|
||||
Firmware Upload Progress (${nodes.length} nodes)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add progress info to the firmware-progress-container
|
||||
const container = this.findElement('#firmware-progress-container');
|
||||
if (container) {
|
||||
const progressHTML = `
|
||||
<div class="upload-progress-info">
|
||||
<div class="overall-progress">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="overall-progress-bar" style="width: 0%; background-color: #fbbf24;"></div>
|
||||
</div>
|
||||
<span class="progress-text">0/${nodes.length} Successful (0%)</span>
|
||||
</div>
|
||||
<div class="progress-summary" id="progress-summary">
|
||||
<span>Status: Upload in progress...</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.innerHTML = progressHTML;
|
||||
}
|
||||
|
||||
// Update existing target nodes to show upload status
|
||||
this.updateTargetNodesForUpload(nodes);
|
||||
}
|
||||
|
||||
updateTargetNodesForUpload(nodes) {
|
||||
const targetNodesList = this.findElement('.target-nodes-list');
|
||||
if (!targetNodesList) return;
|
||||
|
||||
// Update each target node item to show upload status
|
||||
targetNodesList.innerHTML = nodes.map(node => `
|
||||
<div class="target-node-item" data-node-ip="${node.ip}">
|
||||
<div class="node-info">
|
||||
<span class="node-name">${node.hostname || node.ip}</span>
|
||||
<span class="node-ip">${node.ip}</span>
|
||||
</div>
|
||||
<div class="node-status">
|
||||
<span class="status-indicator uploading">Uploading...</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
updateNodeProgress(current, total, nodeIp, status) {
|
||||
const targetNodeItem = this.findElement(`[data-node-ip="${nodeIp}"]`);
|
||||
if (targetNodeItem) {
|
||||
const statusElement = targetNodeItem.querySelector('.status-indicator');
|
||||
|
||||
if (statusElement) {
|
||||
statusElement.textContent = status;
|
||||
|
||||
// Update status-specific styling
|
||||
statusElement.className = 'status-indicator';
|
||||
if (status === 'Completed') {
|
||||
statusElement.classList.add('success');
|
||||
} else if (status === 'Failed') {
|
||||
statusElement.classList.add('error');
|
||||
} else if (status === 'Uploading...') {
|
||||
statusElement.classList.add('uploading');
|
||||
} else if (status === 'Pending...') {
|
||||
statusElement.classList.add('pending');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
} else if (successPercentage > 50) {
|
||||
progressBar.style.backgroundColor = '#60a5fa';
|
||||
} else {
|
||||
progressBar.style.backgroundColor = '#fbbf24';
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
displayUploadResults(results) {
|
||||
const progressHeader = this.findElement('.progress-header h3');
|
||||
const progressSummary = this.findElement('#progress-summary');
|
||||
|
||||
if (progressHeader && progressSummary) {
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const totalCount = results.length;
|
||||
const successRate = Math.round((successCount / totalCount) * 100);
|
||||
|
||||
if (totalCount === 1) {
|
||||
// Single node upload
|
||||
if (successCount === 1) {
|
||||
progressHeader.textContent = `Firmware Upload Complete`;
|
||||
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||||
} else {
|
||||
progressHeader.textContent = `Firmware Upload Failed`;
|
||||
progressSummary.innerHTML = `<span>${window.icon('error', { width: 14, height: 14 })} Upload to ${results[0].hostname || results[0].nodeIp} failed at ${new Date().toLocaleTimeString()}</span>`;
|
||||
}
|
||||
} else if (successCount === totalCount) {
|
||||
// Multi-node upload - all successful
|
||||
progressHeader.textContent = `Firmware Upload Complete (${successCount}/${totalCount} Successful)`;
|
||||
progressSummary.innerHTML = `<span>${window.icon('success', { width: 14, height: 14 })} All uploads completed successfully at ${new Date().toLocaleTimeString()}</span>`;
|
||||
} else {
|
||||
// Multi-node upload - some failed
|
||||
progressHeader.textContent = `Firmware Upload Results (${successCount}/${totalCount} Successful)`;
|
||||
progressSummary.innerHTML = `<span>${window.icon('warning', { width: 14, height: 14 })} Upload completed with ${totalCount - successCount} failure(s) at ${new Date().toLocaleTimeString()}</span>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateFileInfo() {
|
||||
const file = this.viewModel.get('selectedFile');
|
||||
const fileInfo = this.findElement('#file-info');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
fileInfo.classList.add('has-file');
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
fileInfo.classList.remove('has-file');
|
||||
}
|
||||
|
||||
this.updateDeployButton();
|
||||
}
|
||||
|
||||
updateDeployButton() {
|
||||
const deployBtn = this.findElement('#deploy-btn');
|
||||
if (deployBtn) {
|
||||
const file = this.viewModel.get('selectedFile');
|
||||
const targetNodes = this.viewModel.get('targetNodes');
|
||||
const isUploading = this.viewModel.get('isUploading');
|
||||
|
||||
deployBtn.disabled = !file || !targetNodes || targetNodes.length === 0 || isUploading;
|
||||
}
|
||||
}
|
||||
|
||||
updateUploadState() {
|
||||
const isUploading = this.viewModel.get('isUploading');
|
||||
const deployBtn = this.findElement('#deploy-btn');
|
||||
|
||||
if (deployBtn) {
|
||||
deployBtn.disabled = isUploading;
|
||||
if (isUploading) {
|
||||
deployBtn.classList.add('loading');
|
||||
// Update button text while keeping the SVG icon
|
||||
const iconSvg = deployBtn.querySelector('svg');
|
||||
deployBtn.innerHTML = '';
|
||||
if (iconSvg) {
|
||||
deployBtn.appendChild(iconSvg);
|
||||
}
|
||||
deployBtn.appendChild(document.createTextNode(' Deploying...'));
|
||||
} else {
|
||||
deployBtn.classList.remove('loading');
|
||||
// Restore original button content with SVG icon
|
||||
deployBtn.innerHTML = `
|
||||
<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="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
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateUploadProgress() {
|
||||
// This will be implemented when we add upload progress tracking
|
||||
}
|
||||
|
||||
updateUploadResults() {
|
||||
// This will be implemented when we add upload results display
|
||||
}
|
||||
|
||||
showProgressOverlay() {
|
||||
// Create overlay element that only covers the left side (main content area)
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'firmware-upload-overlay';
|
||||
overlay.className = 'firmware-upload-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="overlay-content">
|
||||
<div class="overlay-spinner">
|
||||
<svg class="spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overlay-text">Firmware upload in progress...</div>
|
||||
<div class="overlay-subtext">Check the drawer for detailed progress</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add to body
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Check if drawer is open and adjust overlay accordingly
|
||||
const drawer = document.querySelector('.details-drawer');
|
||||
if (drawer && drawer.classList.contains('open')) {
|
||||
overlay.classList.add('drawer-open');
|
||||
}
|
||||
|
||||
// Block ESC key during upload
|
||||
this.blockEscapeKey();
|
||||
|
||||
// Block drawer close button during upload
|
||||
this.blockDrawerCloseButton();
|
||||
|
||||
// Block choose file button during upload
|
||||
this.blockChooseFileButton();
|
||||
|
||||
|
||||
// Store reference for cleanup
|
||||
this.progressOverlay = overlay;
|
||||
}
|
||||
|
||||
blockDrawerCloseButton() {
|
||||
// Find the drawer close button
|
||||
const closeButton = document.querySelector('.drawer-close');
|
||||
if (closeButton) {
|
||||
// Store original state
|
||||
this.originalCloseButtonDisabled = closeButton.disabled;
|
||||
this.originalCloseButtonStyle = closeButton.style.cssText;
|
||||
|
||||
// Disable the close button
|
||||
closeButton.disabled = true;
|
||||
closeButton.style.opacity = '0.5';
|
||||
closeButton.style.cursor = 'not-allowed';
|
||||
closeButton.style.pointerEvents = 'none';
|
||||
|
||||
// Add visual indicator that it's disabled
|
||||
closeButton.title = 'Cannot close during firmware upload';
|
||||
}
|
||||
}
|
||||
|
||||
unblockDrawerCloseButton() {
|
||||
// Restore the drawer close button
|
||||
const closeButton = document.querySelector('.drawer-close');
|
||||
if (closeButton) {
|
||||
// Restore original state
|
||||
closeButton.disabled = this.originalCloseButtonDisabled || false;
|
||||
closeButton.style.cssText = this.originalCloseButtonStyle || '';
|
||||
closeButton.title = 'Close';
|
||||
}
|
||||
}
|
||||
|
||||
blockChooseFileButton() {
|
||||
// Find the choose file button
|
||||
const chooseFileButton = document.querySelector('.upload-btn-compact');
|
||||
if (chooseFileButton) {
|
||||
// Store original state
|
||||
this.originalChooseFileButtonDisabled = chooseFileButton.disabled;
|
||||
this.originalChooseFileButtonStyle = chooseFileButton.style.cssText;
|
||||
|
||||
// Disable the choose file button
|
||||
chooseFileButton.disabled = true;
|
||||
chooseFileButton.style.opacity = '0.5';
|
||||
chooseFileButton.style.cursor = 'not-allowed';
|
||||
chooseFileButton.style.pointerEvents = 'none';
|
||||
|
||||
// Add visual indicator that it's disabled
|
||||
chooseFileButton.title = 'Cannot change file during upload';
|
||||
}
|
||||
}
|
||||
|
||||
unblockChooseFileButton() {
|
||||
// Restore the choose file button
|
||||
const chooseFileButton = document.querySelector('.upload-btn-compact');
|
||||
if (chooseFileButton) {
|
||||
// Restore original state
|
||||
chooseFileButton.disabled = this.originalChooseFileButtonDisabled || false;
|
||||
chooseFileButton.style.cssText = this.originalChooseFileButtonStyle || '';
|
||||
chooseFileButton.title = 'Choose File';
|
||||
}
|
||||
}
|
||||
|
||||
hideTargetNodesSection() {
|
||||
// Find the target nodes section
|
||||
const targetNodesSection = document.querySelector('.target-nodes-section');
|
||||
if (targetNodesSection) {
|
||||
// Store original state
|
||||
this.originalTargetNodesSectionDisplay = targetNodesSection.style.display;
|
||||
|
||||
// Hide the target nodes section
|
||||
targetNodesSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
showTargetNodesSection() {
|
||||
// Restore the target nodes section
|
||||
const targetNodesSection = document.querySelector('.target-nodes-section');
|
||||
if (targetNodesSection) {
|
||||
// Restore original state
|
||||
targetNodesSection.style.display = this.originalTargetNodesSectionDisplay || '';
|
||||
}
|
||||
}
|
||||
|
||||
blockEscapeKey() {
|
||||
// Create a keydown event listener that prevents ESC from closing the drawer
|
||||
this.escapeKeyHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Add the event listener with capture=true to intercept before drawer's handler
|
||||
document.addEventListener('keydown', this.escapeKeyHandler, true);
|
||||
}
|
||||
|
||||
unblockEscapeKey() {
|
||||
// Remove the ESC key blocker
|
||||
if (this.escapeKeyHandler) {
|
||||
document.removeEventListener('keydown', this.escapeKeyHandler, true);
|
||||
this.escapeKeyHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
hideProgressOverlay() {
|
||||
if (this.progressOverlay) {
|
||||
this.progressOverlay.remove();
|
||||
this.progressOverlay = null;
|
||||
}
|
||||
|
||||
// Unblock ESC key
|
||||
this.unblockEscapeKey();
|
||||
|
||||
// Unblock drawer close button
|
||||
this.unblockDrawerCloseButton();
|
||||
|
||||
// Unblock choose file button
|
||||
this.unblockChooseFileButton();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
window.FirmwareUploadComponent = FirmwareUploadComponent;
|
||||
67
public/scripts/components/FirmwareViewComponent.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// Firmware View Component
|
||||
class FirmwareViewComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('FirmwareViewComponent: Constructor called');
|
||||
logger.debug('FirmwareViewComponent: Container:', container);
|
||||
|
||||
// Pass the entire firmware view container to the FirmwareComponent
|
||||
logger.debug('FirmwareViewComponent: Using entire container for FirmwareComponent');
|
||||
|
||||
this.firmwareComponent = new FirmwareComponent(
|
||||
container,
|
||||
viewModel,
|
||||
eventBus
|
||||
);
|
||||
|
||||
logger.debug('FirmwareViewComponent: FirmwareComponent created');
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
|
||||
logger.debug('FirmwareViewComponent: Mounting...');
|
||||
|
||||
// Mount sub-component
|
||||
this.firmwareComponent.mount();
|
||||
|
||||
logger.debug('FirmwareViewComponent: Mounted successfully');
|
||||
}
|
||||
|
||||
unmount() {
|
||||
// Unmount sub-component
|
||||
if (this.firmwareComponent) {
|
||||
this.firmwareComponent.unmount();
|
||||
}
|
||||
|
||||
super.unmount();
|
||||
}
|
||||
|
||||
// Override pause method to handle sub-components
|
||||
onPause() {
|
||||
logger.debug('FirmwareViewComponent: Pausing...');
|
||||
|
||||
// Pause sub-component
|
||||
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
|
||||
this.firmwareComponent.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// Override resume method to handle sub-components
|
||||
onResume() {
|
||||
logger.debug('FirmwareViewComponent: Resuming...');
|
||||
|
||||
// Resume sub-component
|
||||
if (this.firmwareComponent && this.firmwareComponent.isMounted) {
|
||||
this.firmwareComponent.resume();
|
||||
}
|
||||
}
|
||||
|
||||
// Override to determine if re-render is needed on resume
|
||||
shouldRenderOnResume() {
|
||||
// Don't re-render on resume - maintain current state
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
551
public/scripts/components/MonitoringViewComponent.js
Normal file
@@ -0,0 +1,551 @@
|
||||
// Monitoring View Component
|
||||
class MonitoringViewComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
logger.debug('MonitoringViewComponent: Constructor called');
|
||||
logger.debug('MonitoringViewComponent: Container:', container);
|
||||
logger.debug('MonitoringViewComponent: Container ID:', container?.id);
|
||||
|
||||
// Track if we've already loaded data to prevent unnecessary reloads
|
||||
this.dataLoaded = false;
|
||||
|
||||
// Drawer state for desktop (shared singleton)
|
||||
this.drawer = new DrawerComponent();
|
||||
}
|
||||
|
||||
mount() {
|
||||
logger.debug('MonitoringViewComponent: Mounting...');
|
||||
super.mount();
|
||||
|
||||
// Set up refresh button event listener
|
||||
this.setupRefreshButton();
|
||||
|
||||
// Only load data if we haven't already or if the view model is empty
|
||||
const clusterMembers = this.viewModel.get('clusterMembers');
|
||||
if (!this.dataLoaded || !clusterMembers || clusterMembers.length === 0) {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
// Subscribe to view model changes
|
||||
this.setupSubscriptions();
|
||||
}
|
||||
|
||||
setupRefreshButton() {
|
||||
const refreshBtn = this.findElement('#refresh-monitoring-btn');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
this.refreshData();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupSubscriptions() {
|
||||
// Subscribe to cluster members changes
|
||||
this.viewModel.subscribe('clusterMembers', () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Subscribe to node resources changes
|
||||
this.viewModel.subscribe('nodeResources', () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Subscribe to cluster summary changes
|
||||
this.viewModel.subscribe('clusterSummary', () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Subscribe to loading state changes
|
||||
this.viewModel.subscribe('isLoading', () => {
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Subscribe to error changes
|
||||
this.viewModel.subscribe('error', () => {
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
async loadData() {
|
||||
logger.debug('MonitoringViewComponent: Loading data...');
|
||||
this.dataLoaded = true;
|
||||
await this.viewModel.loadClusterData();
|
||||
}
|
||||
|
||||
async refreshData() {
|
||||
logger.debug('MonitoringViewComponent: Refreshing data...');
|
||||
await this.viewModel.refresh();
|
||||
}
|
||||
|
||||
// Determine if we should use desktop drawer behavior
|
||||
isDesktop() {
|
||||
return this.drawer.isDesktop();
|
||||
}
|
||||
|
||||
// Open drawer for a specific node
|
||||
openDrawerForNode(nodeData) {
|
||||
const { ip, hostname } = nodeData;
|
||||
|
||||
// Get display name for drawer title
|
||||
let displayName = ip;
|
||||
if (hostname && ip) {
|
||||
displayName = `${hostname} - ${ip}`;
|
||||
} else if (hostname) {
|
||||
displayName = hostname;
|
||||
} else if (ip) {
|
||||
displayName = ip;
|
||||
}
|
||||
|
||||
// Open drawer with content callback
|
||||
this.drawer.openDrawer(displayName, (contentContainer, setActiveComponent) => {
|
||||
// Load and mount NodeDetails into drawer
|
||||
const nodeDetailsVM = new NodeDetailsViewModel();
|
||||
const nodeDetailsComponent = new NodeDetailsComponent(contentContainer, nodeDetailsVM, this.eventBus);
|
||||
setActiveComponent(nodeDetailsComponent);
|
||||
|
||||
nodeDetailsVM.loadNodeDetails(ip).then(() => {
|
||||
nodeDetailsComponent.mount();
|
||||
}).catch((error) => {
|
||||
logger.error('Failed to load node details for drawer:', error);
|
||||
contentContainer.innerHTML = `
|
||||
<div class="error">
|
||||
<strong>Error loading node details:</strong><br>
|
||||
${this.escapeHtml(error.message)}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
closeDrawer() {
|
||||
this.drawer.closeDrawer();
|
||||
}
|
||||
|
||||
// Get color class based on utilization percentage (same logic as gauges)
|
||||
getUtilizationColorClass(percentage) {
|
||||
const numPercentage = parseFloat(percentage);
|
||||
|
||||
if (numPercentage === 0 || isNaN(numPercentage)) return 'utilization-empty';
|
||||
if (numPercentage < 50) return 'utilization-green';
|
||||
if (numPercentage < 80) return 'utilization-yellow';
|
||||
return 'utilization-red';
|
||||
}
|
||||
|
||||
// Format lastSeen timestamp to human readable format
|
||||
formatLastSeen(lastSeen) {
|
||||
if (!lastSeen) return 'Unknown';
|
||||
|
||||
// lastSeen appears to be in milliseconds
|
||||
const now = Date.now();
|
||||
const diff = now - lastSeen;
|
||||
|
||||
if (diff < 60000) { // Less than 1 minute
|
||||
return 'Just now';
|
||||
} else if (diff < 3600000) { // Less than 1 hour
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
return `${minutes}m ago`;
|
||||
} else if (diff < 86400000) { // Less than 1 day
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||
return `${hours}h ${minutes}m ago`;
|
||||
} else { // More than 1 day
|
||||
const days = Math.floor(diff / 86400000);
|
||||
const hours = Math.floor((diff % 86400000) / 3600000);
|
||||
return `${days}d ${hours}h ago`;
|
||||
}
|
||||
}
|
||||
|
||||
// Format flash size in human readable format
|
||||
formatFlashSize(bytes) {
|
||||
if (!bytes || bytes === 0) return 'Unknown';
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
// Clean up resources
|
||||
cleanup() {
|
||||
if (this.drawer) {
|
||||
this.drawer.cleanup();
|
||||
}
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
// Setup click event listeners for node cards
|
||||
setupNodeCardClickListeners(container) {
|
||||
const nodeCards = container.querySelectorAll('.node-card');
|
||||
nodeCards.forEach(card => {
|
||||
const nodeIp = card.dataset.nodeIp;
|
||||
if (nodeIp) {
|
||||
// Find the node data
|
||||
const nodeResources = this.viewModel.get('nodeResources');
|
||||
const nodeData = nodeResources.get(nodeIp);
|
||||
|
||||
if (nodeData) {
|
||||
this.addEventListener(card, 'click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.openDrawerForNode(nodeData);
|
||||
});
|
||||
|
||||
// Add hover cursor style
|
||||
card.style.cursor = 'pointer';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
logger.debug('MonitoringViewComponent: Rendering...');
|
||||
|
||||
const isLoading = this.viewModel.get('isLoading');
|
||||
const error = this.viewModel.get('error');
|
||||
const clusterSummary = this.viewModel.get('clusterSummary');
|
||||
const nodeResources = this.viewModel.get('nodeResources');
|
||||
const lastUpdated = this.viewModel.get('lastUpdated');
|
||||
|
||||
// Render cluster summary
|
||||
this.renderClusterSummary(isLoading, error, clusterSummary, lastUpdated);
|
||||
|
||||
// Render nodes monitoring
|
||||
this.renderNodesMonitoring(isLoading, error, nodeResources);
|
||||
}
|
||||
|
||||
renderClusterSummary(isLoading, error, clusterSummary, lastUpdated) {
|
||||
const container = this.findElement('#cluster-summary');
|
||||
if (!container) return;
|
||||
|
||||
if (isLoading) {
|
||||
container.innerHTML = `
|
||||
<div class="loading">
|
||||
<div>Loading cluster resource summary...</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
container.innerHTML = `
|
||||
<div class="error">
|
||||
<div>${window.icon('error', { width: 14, height: 14, class: 'icon' })} Error: ${this.escapeHtml(String(error))}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const cpuUtilization = this.viewModel.getResourceUtilization('Cpu');
|
||||
const memoryUtilization = this.viewModel.getResourceUtilization('Memory');
|
||||
const storageUtilization = this.viewModel.getResourceUtilization('Storage');
|
||||
|
||||
const lastUpdatedText = lastUpdated ?
|
||||
`Last updated: ${lastUpdated.toLocaleTimeString()}` :
|
||||
'Never updated';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="cluster-summary-content">
|
||||
<div class="summary-header">
|
||||
<h3>Summary</h3>
|
||||
<div class="last-updated">${lastUpdatedText}</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">${window.icon('computer', { width: 18, height: 18 })}</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Total Nodes</div>
|
||||
<div class="stat-value">${clusterSummary.totalNodes}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">${window.icon('cpu', { width: 18, height: 18 })}</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">CPU</div>
|
||||
<div class="stat-value">${Math.round(clusterSummary.totalCpu - clusterSummary.availableCpu)}MHz / ${Math.round(clusterSummary.totalCpu)}MHz</div>
|
||||
<div class="stat-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${cpuUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">${window.icon('memory', { width: 18, height: 18 })}</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Memory</div>
|
||||
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalMemory - clusterSummary.availableMemory, 'memory')} / ${this.viewModel.formatResourceValue(clusterSummary.totalMemory, 'memory')}</div>
|
||||
<div class="stat-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${memoryUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">${window.icon('storage', { width: 18, height: 18 })}</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Storage</div>
|
||||
<div class="stat-value">${this.viewModel.formatResourceValue(clusterSummary.totalStorage - clusterSummary.availableStorage, 'storage')} / ${this.viewModel.formatResourceValue(clusterSummary.totalStorage, 'storage')}</div>
|
||||
<div class="stat-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${storageUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderNodesMonitoring(isLoading, error, nodeResources) {
|
||||
const container = this.findElement('#nodes-monitoring');
|
||||
if (!container) return;
|
||||
|
||||
if (isLoading) {
|
||||
container.innerHTML = `
|
||||
<div class="loading">
|
||||
<div>Loading node resource data...</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
container.innerHTML = `
|
||||
<div class="error">
|
||||
<div>${window.icon('error', { width: 14, height: 14, class: 'icon' })} Error: ${this.escapeHtml(String(error))}</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeResources || nodeResources.size === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="no-data">
|
||||
<div>No node resource data available</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const nodesHtml = Array.from(nodeResources.values())
|
||||
.sort((a, b) => {
|
||||
// Sort by hostname, fallback to IP if hostname is not available
|
||||
const hostnameA = a.hostname || a.ip || '';
|
||||
const hostnameB = b.hostname || b.ip || '';
|
||||
return hostnameA.localeCompare(hostnameB);
|
||||
})
|
||||
.map(nodeData => {
|
||||
return this.renderNodeCard(nodeData);
|
||||
}).join('');
|
||||
|
||||
const nodeCount = nodeResources.size;
|
||||
container.innerHTML = `
|
||||
<div class="nodes-monitoring-content">
|
||||
<h3>${window.icon('computer', { width: 16, height: 16, class: 'icon', strokeWidth: 2 })} Node Resource Details</h3>
|
||||
<div class="nodes-grid" data-item-count="${nodeCount}">
|
||||
${nodesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add click event listeners to node cards
|
||||
this.setupNodeCardClickListeners(container);
|
||||
}
|
||||
|
||||
renderNodeCard(nodeData) {
|
||||
const { ip, hostname, resources, hasResources, error, resourceSource, labels } = nodeData;
|
||||
|
||||
|
||||
if (!hasResources) {
|
||||
return `
|
||||
<div class="node-card error" data-node-ip="${ip}">
|
||||
<div class="node-header">
|
||||
<div class="status-hostname-group">
|
||||
<div class="node-status-indicator status-offline">
|
||||
${window.icon('dotRed', { width: 12, height: 12 })}
|
||||
</div>
|
||||
<div class="node-title">${hostname || ip}</div>
|
||||
</div>
|
||||
<div class="node-ip">${ip}</div>
|
||||
</div>
|
||||
|
||||
${labels && Object.keys(labels).length > 0 ? `
|
||||
<div class="node-labels">
|
||||
<div class="labels-container">
|
||||
${Object.entries(labels).map(([key, value]) =>
|
||||
`<span class="label-chip">${key}: ${value}</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="labels-divider"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="node-error">
|
||||
<div class="error-label">${window.icon('warning', { width: 14, height: 14 })} Error</div>
|
||||
<div class="error-message">${error || 'Monitoring endpoint not available'}</div>
|
||||
</div>
|
||||
${nodeData.lastSeen ? `
|
||||
<div class="node-uptime">
|
||||
<div class="uptime-label">${window.icon('timer', { width: 14, height: 14 })} Last Seen</div>
|
||||
<div class="uptime-value">${this.formatLastSeen(nodeData.lastSeen)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Extract resource data (handle both monitoring API and basic data formats)
|
||||
const cpu = resources?.cpu || {};
|
||||
const memory = resources?.memory || {};
|
||||
const storage = resources?.filesystem || resources?.storage || {};
|
||||
const system = resources?.system || {};
|
||||
|
||||
let cpuTotal, cpuAvailable, cpuUsed, cpuUtilization;
|
||||
let memoryTotal, memoryAvailable, memoryUsed, memoryUtilization;
|
||||
let storageTotal, storageAvailable, storageUsed, storageUtilization;
|
||||
|
||||
if (resourceSource === 'monitoring') {
|
||||
// Real monitoring API format
|
||||
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80; // Get CPU frequency from basic resources
|
||||
cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz
|
||||
cpuAvailable = cpuFreqMHz * (100 - (cpu.average_usage || 0)) / 100; // Available frequency
|
||||
cpuUsed = cpuFreqMHz * (cpu.average_usage || 0) / 100; // Used frequency
|
||||
cpuUtilization = Math.round(cpu.average_usage || 0);
|
||||
|
||||
memoryTotal = memory.total_heap || 0;
|
||||
memoryAvailable = memory.free_heap || 0;
|
||||
memoryUsed = memoryTotal - memoryAvailable;
|
||||
memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0;
|
||||
|
||||
storageTotal = storage.total_bytes || 0;
|
||||
storageAvailable = storage.free_bytes || 0;
|
||||
storageUsed = storageTotal - storageAvailable;
|
||||
storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0;
|
||||
} else {
|
||||
// Basic data format - use CPU frequency from basic resources
|
||||
const cpuFreqMHz = nodeData.basic?.cpuFreqMHz || 80;
|
||||
cpuTotal = cpuFreqMHz; // Total CPU frequency in MHz
|
||||
cpuAvailable = cpuFreqMHz * (cpu.available || 0.8); // Available frequency
|
||||
cpuUsed = cpuFreqMHz * (cpu.used || 0.2); // Used frequency
|
||||
cpuUtilization = cpuTotal > 0 ? Math.round((cpuUsed / cpuTotal) * 100) : 0;
|
||||
|
||||
memoryTotal = memory.total || 0;
|
||||
memoryAvailable = memory.available || memory.free || 0;
|
||||
memoryUsed = memoryTotal - memoryAvailable;
|
||||
memoryUtilization = memoryTotal > 0 ? Math.round((memoryUsed / memoryTotal) * 100) : 0;
|
||||
|
||||
storageTotal = storage.total || 0;
|
||||
storageAvailable = storage.available || storage.free || 0;
|
||||
storageUsed = storageTotal - storageAvailable;
|
||||
storageUtilization = storageTotal > 0 ? Math.round((storageUsed / storageTotal) * 100) : 0;
|
||||
}
|
||||
|
||||
// Determine status indicator based on resource source
|
||||
const statusIcon = resourceSource === 'monitoring' ? window.icon('dotGreen', { width: 12, height: 12 }) :
|
||||
resourceSource === 'basic' ? window.icon('dotYellow', { width: 12, height: 12 }) : window.icon('dotRed', { width: 12, height: 12 });
|
||||
const statusClass = resourceSource === 'monitoring' ? 'status-online' :
|
||||
resourceSource === 'basic' ? 'status-warning' : 'status-offline';
|
||||
|
||||
return `
|
||||
<div class="node-card" data-node-ip="${ip}">
|
||||
<div class="node-header">
|
||||
<div class="status-hostname-group">
|
||||
<div class="node-status-indicator ${statusClass}">
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div class="node-title">${hostname || ip}</div>
|
||||
</div>
|
||||
<div class="node-ip">${ip}</div>
|
||||
</div>
|
||||
|
||||
${labels && Object.keys(labels).length > 0 ? `
|
||||
<div class="node-labels">
|
||||
<div class="labels-container">
|
||||
${Object.entries(labels).map(([key, value]) =>
|
||||
`<span class="label-chip">${key}: ${value}</span>`
|
||||
).join('')}
|
||||
</div>
|
||||
<div class="labels-divider"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
${system.uptime_formatted ? `
|
||||
<div class="node-uptime">
|
||||
<div class="uptime-label">${window.icon('timer', { width: 14, height: 14 })} Uptime</div>
|
||||
<div class="uptime-value">${system.uptime_formatted}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="node-latency">
|
||||
<div class="latency-label">${window.icon('latency', { width: 14, height: 14 })} Latency</div>
|
||||
<div class="latency-value">${nodeData.latency ? `${nodeData.latency}ms` : 'N/A'}</div>
|
||||
</div>
|
||||
<div class="latency-divider"></div>
|
||||
|
||||
${(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize) ? `
|
||||
<div class="node-flash">
|
||||
<div class="flash-label">${window.icon('storage', { width: 14, height: 14 })} Flash</div>
|
||||
<div class="flash-value">${this.formatFlashSize(nodeData.basic?.flashChipSize || nodeData.resources?.flashChipSize)}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="node-resources">
|
||||
<div class="resource-item">
|
||||
<div class="resource-label">${window.icon('cpu', { width: 14, height: 14 })} CPU</div>
|
||||
<div class="resource-value">
|
||||
<span class="value-label">Used:</span> ${Math.round(cpuUsed)}MHz / <span class="value-label">Total:</span> ${Math.round(cpuTotal)}MHz
|
||||
</div>
|
||||
<div class="resource-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(cpuUtilization)}" style="width: ${cpuUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${cpuUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-item">
|
||||
<div class="resource-label">${window.icon('memory', { width: 14, height: 14 })} Memory</div>
|
||||
<div class="resource-value">
|
||||
<span class="value-label">Used:</span> ${this.viewModel.formatResourceValue(memoryUsed, 'memory')} / <span class="value-label">Total:</span> ${this.viewModel.formatResourceValue(memoryTotal, 'memory')}
|
||||
</div>
|
||||
<div class="resource-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(memoryUtilization)}" style="width: ${memoryUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${memoryUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resource-item">
|
||||
<div class="resource-label">${window.icon('storage', { width: 14, height: 14 })} Storage</div>
|
||||
<div class="resource-value">
|
||||
<span class="value-label">Used:</span> ${this.viewModel.formatResourceValue(storageUsed, 'storage')} / <span class="value-label">Total:</span> ${this.viewModel.formatResourceValue(storageTotal, 'storage')}
|
||||
</div>
|
||||
<div class="resource-utilization">
|
||||
<div class="utilization-bar">
|
||||
<div class="utilization-fill ${this.getUtilizationColorClass(storageUtilization)}" style="width: ${storageUtilization}%"></div>
|
||||
</div>
|
||||
<span class="utilization-text">${storageUtilization}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
1252
public/scripts/components/NodeDetailsComponent.js
Normal file
223
public/scripts/components/OverlayDialogComponent.js
Normal file
@@ -0,0 +1,223 @@
|
||||
// Overlay Dialog Component - Reusable confirmation dialog
|
||||
class OverlayDialogComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
this.isVisible = false;
|
||||
this.onConfirm = null;
|
||||
this.onCancel = null;
|
||||
this.title = '';
|
||||
this.message = '';
|
||||
this.confirmText = 'Yes';
|
||||
this.cancelText = 'No';
|
||||
this.confirmClass = 'overlay-dialog-btn-confirm';
|
||||
this.showCloseButton = true;
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Close overlay when clicking outside or pressing escape
|
||||
this.addEventListener(this.container, 'click', (e) => {
|
||||
if (!this.isVisible) return;
|
||||
if (e.target === this.container) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.isVisible) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
show(options = {}) {
|
||||
const {
|
||||
title = 'Confirm Action',
|
||||
message = 'Are you sure you want to proceed?',
|
||||
confirmText = 'Yes',
|
||||
cancelText = 'No',
|
||||
confirmClass = 'overlay-dialog-btn-confirm',
|
||||
showCloseButton = true,
|
||||
onConfirm = null,
|
||||
onCancel = null
|
||||
} = options;
|
||||
|
||||
this.title = title;
|
||||
this.message = message;
|
||||
this.confirmText = confirmText;
|
||||
this.cancelText = cancelText;
|
||||
this.confirmClass = confirmClass;
|
||||
this.showCloseButton = showCloseButton;
|
||||
this.onConfirm = onConfirm;
|
||||
this.onCancel = onCancel;
|
||||
|
||||
this.render();
|
||||
|
||||
// Add visible class with small delay for animation
|
||||
setTimeout(() => {
|
||||
this.container.classList.add('visible');
|
||||
}, 10);
|
||||
|
||||
this.isVisible = true;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.container.classList.remove('visible');
|
||||
|
||||
setTimeout(() => {
|
||||
this.isVisible = false;
|
||||
|
||||
// Call cancel callback if provided
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
this.onCancel = null;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
handleConfirm() {
|
||||
this.container.classList.remove('visible');
|
||||
|
||||
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.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">
|
||||
<p class="overlay-dialog-message">${this.message}</p>
|
||||
</div>
|
||||
<div class="overlay-dialog-footer">
|
||||
${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>
|
||||
`;
|
||||
|
||||
// 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(`.${this.confirmClass}`);
|
||||
|
||||
if (closeBtn) {
|
||||
this.addEventListener(closeBtn, 'click', () => this.hide());
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
this.addEventListener(cancelBtn, 'click', () => this.hide());
|
||||
}
|
||||
|
||||
if (confirmBtn) {
|
||||
this.addEventListener(confirmBtn, 'click', () => this.handleConfirm());
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Call parent unmount
|
||||
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
|
||||
});
|
||||
};
|
||||
90
public/scripts/components/PrimaryNodeComponent.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// Primary Node Component
|
||||
class PrimaryNodeComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const refreshBtn = this.findElement('.primary-node-refresh');
|
||||
if (refreshBtn) {
|
||||
this.addEventListener(refreshBtn, 'click', this.handleRandomSelection.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
// Listen to primary node changes
|
||||
this.subscribeToProperty('primaryNode', this.render.bind(this));
|
||||
this.subscribeToProperty('clientInitialized', this.render.bind(this));
|
||||
this.subscribeToProperty('totalNodes', this.render.bind(this));
|
||||
this.subscribeToProperty('onlineNodes', this.render.bind(this));
|
||||
this.subscribeToProperty('error', this.render.bind(this));
|
||||
}
|
||||
|
||||
render() {
|
||||
const primaryNode = this.viewModel.get('primaryNode');
|
||||
const clientInitialized = this.viewModel.get('clientInitialized');
|
||||
const totalNodes = this.viewModel.get('totalNodes');
|
||||
const onlineNodes = this.viewModel.get('onlineNodes');
|
||||
const error = this.viewModel.get('error');
|
||||
|
||||
if (error) {
|
||||
this.setText('#primary-node-ip', 'Discovery Failed');
|
||||
this.setClass('#primary-node-ip', 'error', true);
|
||||
this.setClass('#primary-node-ip', 'discovering', false);
|
||||
this.setClass('#primary-node-ip', 'selecting', false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!primaryNode) {
|
||||
this.setText('#primary-node-ip', 'No Nodes Found');
|
||||
this.setClass('#primary-node-ip', 'error', true);
|
||||
this.setClass('#primary-node-ip', 'discovering', false);
|
||||
this.setClass('#primary-node-ip', 'selecting', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = clientInitialized ? '' : '';
|
||||
const nodeCount = (onlineNodes && onlineNodes > 0)
|
||||
? ` (${onlineNodes}/${totalNodes} online)`
|
||||
: (totalNodes > 1 ? ` (${totalNodes} nodes)` : '');
|
||||
|
||||
this.setText('#primary-node-ip', `${primaryNode}${nodeCount}`);
|
||||
this.setClass('#primary-node-ip', 'error', false);
|
||||
this.setClass('#primary-node-ip', 'discovering', false);
|
||||
this.setClass('#primary-node-ip', 'selecting', false);
|
||||
}
|
||||
|
||||
async handleRandomSelection() {
|
||||
try {
|
||||
// Show selecting state
|
||||
this.setText('#primary-node-ip', 'Selecting...');
|
||||
this.setClass('#primary-node-ip', 'selecting', true);
|
||||
this.setClass('#primary-node-ip', 'discovering', false);
|
||||
this.setClass('#primary-node-ip', 'error', false);
|
||||
|
||||
await this.viewModel.selectRandomPrimaryNode();
|
||||
|
||||
// Show success briefly
|
||||
this.setText('#primary-node-ip', 'Selection Complete');
|
||||
|
||||
// Update display after delay
|
||||
setTimeout(() => {
|
||||
this.viewModel.updatePrimaryNodeDisplay();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to select random primary node:', error);
|
||||
this.setText('#primary-node-ip', 'Selection Failed');
|
||||
this.setClass('#primary-node-ip', 'error', true);
|
||||
this.setClass('#primary-node-ip', 'selecting', false);
|
||||
this.setClass('#primary-node-ip', 'discovering', false);
|
||||
|
||||
// Revert to normal display after error
|
||||
setTimeout(() => {
|
||||
this.viewModel.updatePrimaryNodeDisplay();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.PrimaryNodeComponent = PrimaryNodeComponent;
|
||||
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;
|
||||
391
public/scripts/components/TerminalPanelComponent.js
Normal file
@@ -0,0 +1,391 @@
|
||||
// Lightweight Terminal Panel singleton
|
||||
(() => {
|
||||
class TerminalPanelImpl {
|
||||
constructor() {
|
||||
this.container = null; // external container passed by DrawerComponent
|
||||
this.panelEl = null;
|
||||
this.logEl = null;
|
||||
this.inputEl = null;
|
||||
this.socket = null;
|
||||
this.connectedIp = null;
|
||||
this.isOpen = false;
|
||||
this.resizeHandler = null;
|
||||
this.isMinimized = false;
|
||||
this.dockEl = null;
|
||||
this.dockBtnEl = null;
|
||||
this.lastNodeIp = null;
|
||||
this.fallbackContainer = null;
|
||||
|
||||
try {
|
||||
this._onKeydown = this._onKeydown.bind(this);
|
||||
document.addEventListener('keydown', this._onKeydown);
|
||||
} catch (_) {
|
||||
// ignore if document not available
|
||||
}
|
||||
}
|
||||
|
||||
open(container, nodeIp) {
|
||||
try {
|
||||
this.container = container;
|
||||
if (!this.container) return;
|
||||
if (!this.panelEl) {
|
||||
this._buildPanel();
|
||||
}
|
||||
|
||||
this.lastNodeIp = nodeIp || this.lastNodeIp;
|
||||
|
||||
// Reset any leftover inline positioning so CSS centering applies
|
||||
if (this.container) {
|
||||
this.container.style.right = '';
|
||||
this.container.style.left = '';
|
||||
this.container.style.top = '';
|
||||
this.container.style.bottom = '';
|
||||
this.container.style.width = '';
|
||||
}
|
||||
|
||||
if (!this.isMinimized) {
|
||||
if (this.panelEl) {
|
||||
this.panelEl.classList.remove('minimized');
|
||||
this.panelEl.classList.remove('visible');
|
||||
requestAnimationFrame(() => {
|
||||
this.panelEl.classList.add('visible');
|
||||
this.inputEl && this.inputEl.focus();
|
||||
});
|
||||
}
|
||||
this._hideDock();
|
||||
} else {
|
||||
if (this.panelEl) {
|
||||
this.panelEl.classList.remove('visible');
|
||||
this.panelEl.classList.add('minimized');
|
||||
}
|
||||
this._showDock();
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
|
||||
// Connect websocket
|
||||
if (nodeIp) {
|
||||
this._connect(nodeIp);
|
||||
} else if (this.lastNodeIp && !this.socket) {
|
||||
this._connect(this.lastNodeIp);
|
||||
} else if (!this.socket && !this.lastNodeIp) {
|
||||
this._updateTitle(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.open error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
try {
|
||||
if (!this.isOpen) return;
|
||||
this.panelEl && this.panelEl.classList.remove('visible');
|
||||
this.isOpen = false;
|
||||
this.isMinimized = false;
|
||||
if (this.panelEl) this.panelEl.classList.remove('minimized');
|
||||
this._hideDock();
|
||||
if (this.socket) {
|
||||
try { this.socket.close(); } catch (_) {}
|
||||
this.socket = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.close error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_buildPanel() {
|
||||
// Ensure container baseline positioning
|
||||
this.container.classList.add('terminal-panel-container');
|
||||
// Create panel DOM
|
||||
this.panelEl = document.createElement('div');
|
||||
this.panelEl.className = 'terminal-panel';
|
||||
this.panelEl.innerHTML = `
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-title">Terminal</div>
|
||||
<div class="terminal-actions">
|
||||
<button class="terminal-minimize-btn" title="Minimize" aria-label="Minimize">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16">
|
||||
<path d="M5 19h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="terminal-close-btn" title="Close" aria-label="Close">
|
||||
<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>
|
||||
</div>
|
||||
<div class="terminal-body">
|
||||
<pre class="terminal-log"></pre>
|
||||
</div>
|
||||
<div class="terminal-input-row">
|
||||
<input type="text" class="terminal-input" placeholder="Type and press Enter to send" />
|
||||
<button class="terminal-clear-btn" title="Clear">Clear</button>
|
||||
<button class="terminal-send-btn">Send</button>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(this.panelEl);
|
||||
|
||||
this.logEl = this.panelEl.querySelector('.terminal-log');
|
||||
this.inputEl = this.panelEl.querySelector('.terminal-input');
|
||||
const sendBtn = this.panelEl.querySelector('.terminal-send-btn');
|
||||
const closeBtn = this.panelEl.querySelector('.terminal-close-btn');
|
||||
const clearBtn = this.panelEl.querySelector('.terminal-clear-btn');
|
||||
const minimizeBtn = this.panelEl.querySelector('.terminal-minimize-btn');
|
||||
|
||||
const sendHandler = () => {
|
||||
const value = (this.inputEl && this.inputEl.value) || '';
|
||||
if (!value) return;
|
||||
this._send(value);
|
||||
this.inputEl.value = '';
|
||||
};
|
||||
|
||||
if (sendBtn) sendBtn.addEventListener('click', (e) => { e.stopPropagation(); sendHandler(); });
|
||||
if (this.inputEl) this.inputEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendHandler();
|
||||
}
|
||||
});
|
||||
if (closeBtn) closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); });
|
||||
if (clearBtn) clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this._clear(); });
|
||||
if (minimizeBtn) minimizeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.minimize(); });
|
||||
}
|
||||
|
||||
_connect(nodeIp) {
|
||||
try {
|
||||
this._updateTitle(nodeIp);
|
||||
|
||||
// Close previous socket if switching node
|
||||
if (this.socket) {
|
||||
try { this.socket.close(); } catch (_) {}
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
const protocol = (window.location && window.location.protocol === 'https:') ? 'wss' : 'ws';
|
||||
const url = `${protocol}://${nodeIp}/ws`;
|
||||
this.connectedIp = nodeIp;
|
||||
this._appendLine(`[connecting] ${url}`);
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
this.socket = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this._appendLine('[open] WebSocket connection established');
|
||||
});
|
||||
ws.addEventListener('message', (evt) => {
|
||||
let dataStr = typeof evt.data === 'string' ? evt.data : '[binary]';
|
||||
// Decode any HTML entities so JSON isn't shown as " etc.
|
||||
dataStr = this._decodeHtmlEntities(dataStr);
|
||||
// Try to pretty-print JSON if applicable
|
||||
const trimmed = dataStr.trim();
|
||||
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
const pretty = JSON.stringify(obj, null, 2);
|
||||
this._appendLine(pretty);
|
||||
return;
|
||||
} catch (_) {
|
||||
// fall through if not valid JSON
|
||||
}
|
||||
}
|
||||
this._appendLine(dataStr);
|
||||
});
|
||||
ws.addEventListener('error', (evt) => {
|
||||
this._appendLine('[error] WebSocket error');
|
||||
});
|
||||
ws.addEventListener('close', () => {
|
||||
this._appendLine('[close] WebSocket connection closed');
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel._connect error:', err);
|
||||
this._appendLine(`[error] ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
minimize() {
|
||||
try {
|
||||
if (!this.panelEl || this.isMinimized) return;
|
||||
this.panelEl.classList.remove('visible');
|
||||
this.panelEl.classList.add('minimized');
|
||||
this.isMinimized = true;
|
||||
this.isOpen = true;
|
||||
this._showDock();
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.minimize error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
restore() {
|
||||
try {
|
||||
if (!this.panelEl || !this.isMinimized) return;
|
||||
this.panelEl.classList.remove('minimized');
|
||||
requestAnimationFrame(() => {
|
||||
this.panelEl.classList.add('visible');
|
||||
this.inputEl && this.inputEl.focus();
|
||||
});
|
||||
this.isMinimized = false;
|
||||
this.isOpen = true;
|
||||
this._hideDock();
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.restore error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_ensureDock() {
|
||||
if (this.dockEl) return this.dockEl;
|
||||
const dock = document.createElement('div');
|
||||
dock.className = 'terminal-dock';
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'terminal-dock-btn';
|
||||
button.title = 'Show Terminal';
|
||||
button.setAttribute('aria-label', 'Show Terminal');
|
||||
button.innerHTML = `
|
||||
<span class="terminal-dock-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 17l6-6-6-6"></path>
|
||||
<path d="M12 19h8"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="terminal-dock-label">Terminal</span>
|
||||
`;
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.restore();
|
||||
});
|
||||
dock.appendChild(button);
|
||||
document.body.appendChild(dock);
|
||||
this.dockEl = dock;
|
||||
this.dockBtnEl = button;
|
||||
return dock;
|
||||
}
|
||||
|
||||
_showDock() {
|
||||
const dock = this._ensureDock();
|
||||
if (dock) dock.classList.add('visible');
|
||||
}
|
||||
|
||||
_hideDock() {
|
||||
if (this.dockEl) this.dockEl.classList.remove('visible');
|
||||
}
|
||||
|
||||
_resolveContainer() {
|
||||
if (this.container && document.body && document.body.contains(this.container)) {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
const sharedDrawer = window.__sharedDrawerInstance;
|
||||
if (sharedDrawer && sharedDrawer.terminalPanelContainer) {
|
||||
return sharedDrawer.terminalPanelContainer;
|
||||
}
|
||||
|
||||
if (this.fallbackContainer && document.body && document.body.contains(this.fallbackContainer)) {
|
||||
return this.fallbackContainer;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'terminal-panel-container';
|
||||
document.body.appendChild(fallback);
|
||||
this.fallbackContainer = fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_onKeydown(event) {
|
||||
try {
|
||||
if (!event || event.defaultPrevented) return;
|
||||
if (event.key !== 't' && event.key !== 'T') return;
|
||||
if (event.repeat) return;
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl) {
|
||||
const tagName = activeEl.tagName;
|
||||
const isEditable = activeEl.isContentEditable;
|
||||
if (isEditable || tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.toggleVisibility();
|
||||
} catch (_) {
|
||||
// swallow errors from key handler to avoid breaking global listeners
|
||||
}
|
||||
}
|
||||
|
||||
toggleVisibility() {
|
||||
try {
|
||||
if (this.isOpen && !this.isMinimized) {
|
||||
this.minimize();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen && this.isMinimized) {
|
||||
this.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetContainer = this._resolveContainer();
|
||||
if (!targetContainer) return;
|
||||
const targetIp = this.lastNodeIp || this.connectedIp || null;
|
||||
this.open(targetContainer, targetIp);
|
||||
} catch (err) {
|
||||
console.error('TerminalPanel.toggleVisibility error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
_send(text) {
|
||||
try {
|
||||
if (!this.socket || this.socket.readyState !== 1) {
|
||||
this._appendLine('[warn] Socket not open');
|
||||
return;
|
||||
}
|
||||
this.socket.send(text);
|
||||
this._appendLine(`> ${text}`);
|
||||
} catch (err) {
|
||||
this._appendLine(`[error] ${err.message || err}`);
|
||||
}
|
||||
}
|
||||
|
||||
_updateTitle(nodeIp) {
|
||||
if (!this.panelEl) return;
|
||||
const titleEl = this.panelEl.querySelector('.terminal-title');
|
||||
if (!titleEl) return;
|
||||
titleEl.textContent = nodeIp ? `Terminal — ${nodeIp}` : 'Terminal';
|
||||
}
|
||||
|
||||
_clear() {
|
||||
if (this.logEl) this.logEl.textContent = '';
|
||||
}
|
||||
|
||||
_appendLine(line) {
|
||||
if (!this.logEl) return;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
this.logEl.textContent += `[${timestamp}] ${line}\n`;
|
||||
// Auto-scroll to bottom
|
||||
const bodyEl = this.panelEl.querySelector('.terminal-body');
|
||||
if (bodyEl) bodyEl.scrollTop = bodyEl.scrollHeight;
|
||||
}
|
||||
|
||||
_decodeHtmlEntities(text) {
|
||||
try {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
return div.textContent || div.innerText || '';
|
||||
} catch (_) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose singleton API
|
||||
window.TerminalPanel = window.TerminalPanel || new TerminalPanelImpl();
|
||||
})();
|
||||
|
||||
|
||||
1651
public/scripts/components/TopologyGraphComponent.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;
|
||||
@@ -225,10 +225,7 @@ class ViewModel {
|
||||
|
||||
// Batch update with change detection
|
||||
batchUpdate(updates, options = {}) {
|
||||
const { preserveUIState = true, notifyChanges = true } = options;
|
||||
|
||||
// Optionally preserve UI state snapshot
|
||||
const currentUIState = preserveUIState ? new Map(this._uiState) : null;
|
||||
const { notifyChanges = true } = options;
|
||||
|
||||
// Track which keys actually change and what the previous values were
|
||||
const changedKeys = [];
|
||||
@@ -245,11 +242,6 @@ class ViewModel {
|
||||
}
|
||||
});
|
||||
|
||||
// Restore UI state if requested
|
||||
if (preserveUIState && currentUIState) {
|
||||
this._uiState = currentUIState;
|
||||
}
|
||||
|
||||
// Notify listeners for changed keys
|
||||
if (notifyChanges) {
|
||||
changedKeys.forEach(key => {
|
||||
@@ -259,7 +251,7 @@ class ViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Base Component class with enhanced state preservation
|
||||
// Base Component class
|
||||
class Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
@@ -635,8 +627,8 @@ class App {
|
||||
registerRoute(name, componentClass, containerId, viewModel = null) {
|
||||
this.routes.set(name, { componentClass, containerId, viewModel });
|
||||
|
||||
// Pre-initialize component in cache for better performance
|
||||
this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
// Defer instantiation until navigation to reduce startup work
|
||||
// this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
}
|
||||
|
||||
// Pre-initialize component in cache
|
||||
@@ -655,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) {
|
||||
@@ -678,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;
|
||||
@@ -771,7 +798,15 @@ class App {
|
||||
async showView(routeName, component) {
|
||||
const container = component.container;
|
||||
|
||||
// Ensure component is mounted (but not necessarily active)
|
||||
// Ensure component is mounted (but not necessarily active); lazy-create now if needed
|
||||
if (!component) {
|
||||
const route = this.routes.get(routeName);
|
||||
const container = document.getElementById(route.containerId);
|
||||
component = new route.componentClass(container, route.viewModel, this.eventBus);
|
||||
component.routeName = routeName;
|
||||
component.isCached = true;
|
||||
this.componentCache.set(routeName, component);
|
||||
}
|
||||
if (!component.isMounted) {
|
||||
logger.debug(`App: Mounting component for '${routeName}'`);
|
||||
component.mount();
|
||||
@@ -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)
|
||||
|
||||
70
public/scripts/icons.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// Centralized SVG Icons for SPORE UI
|
||||
// Usage: window.icon('cluster', {class: 'foo', width: 16, height: 16}) -> returns inline SVG string
|
||||
(function(){
|
||||
const toAttrs = (opts) => {
|
||||
if (!opts) return '';
|
||||
const attrs = [];
|
||||
if (opts.class) attrs.push(`class="${opts.class}"`);
|
||||
if (opts.width) attrs.push(`width="${opts.width}"`);
|
||||
if (opts.height) attrs.push(`height="${opts.height}"`);
|
||||
if (opts.strokeWidth) attrs.push(`stroke-width="${opts.strokeWidth}"`);
|
||||
return attrs.join(' ');
|
||||
};
|
||||
|
||||
const withSvg = (inner, opts) => {
|
||||
const attr = toAttrs(opts);
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ${attr}>${inner}</svg>`;
|
||||
};
|
||||
|
||||
const Icons = {
|
||||
// Navigation / sections
|
||||
cluster: (o) => withSvg(`<circle cx="12" cy="12" r="9"/><circle cx="8" cy="10" r="1.5"/><circle cx="16" cy="8" r="1.5"/><circle cx="14" cy="15" r="1.5"/><path d="M9 11l3 3M9 11l6-3"/>`, o),
|
||||
topology: (o) => withSvg(`
|
||||
<g transform="rotate(-60 12 12)">
|
||||
<circle cx="12" cy="4" r="1.6"/>
|
||||
<circle cx="19" cy="9" r="1.6"/>
|
||||
<circle cx="16" cy="18" r="1.6"/>
|
||||
<circle cx="8" cy="18" r="1.6"/>
|
||||
<circle cx="5" cy="9" r="1.6"/>
|
||||
<path d="M12 4L16 18M16 18L5 9M5 9L19 9M19 9L8 18M8 18L12 4"/>
|
||||
</g>
|
||||
`, o),
|
||||
monitoring: (o) => withSvg(`<path d="M3 12h3l2 7 4-14 3 10 2-6h4"/>`, o),
|
||||
firmware: (o) => withSvg(`<path d="M4 7l8-4 8 4v10l-8 4-8-4z"/><path d="M12 8v8"/>`, o),
|
||||
|
||||
// Status / feedback
|
||||
success: (o) => withSvg(`<path d="M20 6L9 17l-5-5"/>`, o),
|
||||
warning: (o) => withSvg(`<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L14.71 3.86a2 2 0 0 0-3.42 0z"/>`, o),
|
||||
error: (o) => withSvg(`<path d="M18 6L6 18M6 6l12 12"/>`, o),
|
||||
offlineDot: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="currentColor"/>`, o),
|
||||
dotGreen: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#10b981"/>`, o),
|
||||
dotYellow: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#f59e0b"/>`, o),
|
||||
dotRed: (o) => withSvg(`<circle cx="12" cy="12" r="6" fill="#ef4444"/>`, o),
|
||||
|
||||
// Actions
|
||||
refresh: (o) => withSvg(`<path d="M3 3v6h6"/><path d="M21 21v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L3 9m18 6-2.64 2.36A9 9 0 0 1 3.51 15"/>`, o),
|
||||
terminal: (o) => withSvg(`<path d="M4 17l6-6-6-6"></path><path d="M12 19h8"></path>`, o),
|
||||
chevronDown: (o) => withSvg(`<path d="M6 9l6 6 6-6"/>`, o),
|
||||
upload: (o) => withSvg(`<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"/>`, o),
|
||||
file: (o) => withSvg(`<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"/>`, o),
|
||||
timer: (o) => withSvg(`<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>`, o),
|
||||
cpu: (o) => withSvg(`<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>`, o),
|
||||
memory: (o) => withSvg(`<rect x="4" y="8" width="16" height="8" rx="2"/><path d="M7 8v8M12 8v8M17 8v8"/>`, o),
|
||||
storage: (o) => withSvg(`<rect x="3" y="6" width="18" height="12" rx="2"/><path d="M7 10h10"/>`, o),
|
||||
computer: (o) => withSvg(`<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/>`, o),
|
||||
latency: (o) => withSvg(`<path d="M3 12h4l2 5 4-10 2 7 2-4h4"/>`, o)
|
||||
};
|
||||
|
||||
function icon(name, opts){
|
||||
const fn = Icons[name];
|
||||
if (!fn) return '';
|
||||
return fn(Object.assign({ width: 16, height: 16, strokeWidth: 2 }, opts || {}));
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Icons = Icons;
|
||||
window.icon = icon;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
1
public/scripts/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// intentionally empty placeholder
|
||||
120
public/scripts/theme-manager.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// Theme Manager - Handles theme switching and persistence
|
||||
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = this.getStoredTheme() || 'dark';
|
||||
this.themeToggle = document.getElementById('theme-toggle');
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Apply stored theme on page load
|
||||
this.applyTheme(this.currentTheme);
|
||||
|
||||
// Set up event listener for theme toggle
|
||||
if (this.themeToggle) {
|
||||
this.themeToggle.addEventListener('click', () => this.toggleTheme());
|
||||
}
|
||||
|
||||
// Listen for system theme changes
|
||||
if (window.matchMedia) {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addListener((e) => {
|
||||
if (this.getStoredTheme() === 'system') {
|
||||
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStoredTheme() {
|
||||
try {
|
||||
return localStorage.getItem('spore-ui-theme');
|
||||
} catch (e) {
|
||||
console.warn('Could not access localStorage for theme preference');
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
setStoredTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem('spore-ui-theme', theme);
|
||||
} catch (e) {
|
||||
console.warn('Could not save theme preference to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(theme) {
|
||||
// Update data attribute on html element
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
|
||||
// Update theme toggle icon
|
||||
this.updateThemeIcon(theme);
|
||||
|
||||
// Store the theme preference
|
||||
this.setStoredTheme(theme);
|
||||
|
||||
this.currentTheme = theme;
|
||||
|
||||
// Dispatch custom event for other components
|
||||
window.dispatchEvent(new CustomEvent('themeChanged', {
|
||||
detail: { theme: theme }
|
||||
}));
|
||||
}
|
||||
|
||||
updateThemeIcon(theme) {
|
||||
if (!this.themeToggle) return;
|
||||
|
||||
const svg = this.themeToggle.querySelector('svg');
|
||||
if (!svg) return;
|
||||
|
||||
// Update the SVG content based on theme
|
||||
if (theme === 'light') {
|
||||
// Sun icon for light theme
|
||||
svg.innerHTML = `
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
`;
|
||||
} else {
|
||||
// Moon icon for dark theme
|
||||
svg.innerHTML = `
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
const newTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||
this.applyTheme(newTheme);
|
||||
|
||||
// Add a subtle animation to the toggle button
|
||||
if (this.themeToggle) {
|
||||
this.themeToggle.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => {
|
||||
this.themeToggle.style.transform = 'scale(1)';
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
// Method to get current theme (useful for other components)
|
||||
getCurrentTheme() {
|
||||
return this.currentTheme;
|
||||
}
|
||||
|
||||
// Method to set theme programmatically
|
||||
setTheme(theme) {
|
||||
if (['dark', 'light'].includes(theme)) {
|
||||
this.applyTheme(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme manager when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.themeManager = new ThemeManager();
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = ThemeManager;
|
||||
}
|
||||
3463
public/styles/main.css.backup
Normal file
1341
public/styles/theme.css
Normal file
@@ -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) {
|
||||
|
||||
@@ -85,11 +85,11 @@ class SporeApiClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node capabilities
|
||||
* @returns {Promise<Object>} Capabilities response
|
||||
* Get node endpoints
|
||||
* @returns {Promise<Object>} endpoints response
|
||||
*/
|
||||
async getCapabilities() {
|
||||
return this.request('GET', '/api/capabilities');
|
||||
return this.request('GET', '/api/node/endpoints');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Demo script for UDP discovery functionality
|
||||
* Monitors the discovery endpoints to show how nodes are discovered
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function makeRequest(path, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: path,
|
||||
method: method,
|
||||
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.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await makeRequest('/api/health');
|
||||
console.log('\n=== Health Check ===');
|
||||
console.log(`Status: ${response.data.status}`);
|
||||
console.log(`HTTP Service: ${response.data.services.http}`);
|
||||
console.log(`UDP Service: ${response.data.services.udp}`);
|
||||
console.log(`SPORE Client: ${response.data.services.sporeClient}`);
|
||||
console.log(`Total Nodes: ${response.data.discovery.totalNodes}`);
|
||||
console.log(`Primary Node: ${response.data.discovery.primaryNode || 'None'}`);
|
||||
|
||||
if (response.data.message) {
|
||||
console.log(`Message: ${response.data.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDiscovery() {
|
||||
try {
|
||||
const response = await makeRequest('/api/discovery/nodes');
|
||||
console.log('\n=== Discovery Status ===');
|
||||
console.log(`Primary Node: ${response.data.primaryNode || 'None'}`);
|
||||
console.log(`Total Nodes: ${response.data.totalNodes}`);
|
||||
console.log(`Client Initialized: ${response.data.clientInitialized}`);
|
||||
|
||||
if (response.data.clientBaseUrl) {
|
||||
console.log(`Client Base URL: ${response.data.clientBaseUrl}`);
|
||||
}
|
||||
|
||||
if (response.data.nodes.length > 0) {
|
||||
console.log('\nDiscovered Nodes:');
|
||||
response.data.nodes.forEach((node, index) => {
|
||||
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
|
||||
console.log(` Discovered: ${node.discoveredAt}`);
|
||||
console.log(` Last Seen: ${node.lastSeen}`);
|
||||
});
|
||||
} else {
|
||||
console.log('No nodes discovered yet.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Discovery check failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDemo() {
|
||||
console.log('🚀 SPORE UDP Discovery Demo');
|
||||
console.log('============================');
|
||||
console.log('This demo monitors the discovery endpoints to show how nodes are discovered.');
|
||||
console.log('Start the backend server with: npm start');
|
||||
console.log('Send discovery messages with: npm run test-discovery broadcast');
|
||||
console.log('');
|
||||
|
||||
// Initial check
|
||||
await checkHealth();
|
||||
await checkDiscovery();
|
||||
|
||||
// Set up periodic monitoring
|
||||
console.log('\n📡 Monitoring discovery endpoints every 5 seconds...');
|
||||
console.log('Press Ctrl+C to stop\n');
|
||||
|
||||
setInterval(async () => {
|
||||
await checkHealth();
|
||||
await checkDiscovery();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n👋 Demo stopped. Goodbye!');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Run the demo
|
||||
runDemo().catch(console.error);
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Demo script for Frontend Discovery Integration
|
||||
* Shows how the frontend displays primary node information
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function makeRequest(path, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: path,
|
||||
method: method,
|
||||
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.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function showFrontendIntegration() {
|
||||
console.log('🚀 Frontend Discovery Integration Demo');
|
||||
console.log('=====================================');
|
||||
console.log('This demo shows how the frontend displays primary node information.');
|
||||
console.log('Open http://localhost:3001 in your browser to see the UI.');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Check if backend is running
|
||||
const healthResponse = await makeRequest('/api/health');
|
||||
console.log('✅ Backend is running');
|
||||
|
||||
// Get discovery information
|
||||
const discoveryResponse = await makeRequest('/api/discovery/nodes');
|
||||
console.log('\n📡 Discovery Status:');
|
||||
console.log(` Primary Node: ${discoveryResponse.data.primaryNode || 'None'}`);
|
||||
console.log(` Total Nodes: ${discoveryResponse.data.totalNodes}`);
|
||||
console.log(` Client Initialized: ${discoveryResponse.data.clientInitialized}`);
|
||||
|
||||
if (discoveryResponse.data.nodes.length > 0) {
|
||||
console.log('\n🌐 Discovered Nodes:');
|
||||
discoveryResponse.data.nodes.forEach((node, index) => {
|
||||
console.log(` ${index + 1}. ${node.ip}:${node.port} (${node.isPrimary ? 'PRIMARY' : 'secondary'})`);
|
||||
console.log(` Last Seen: ${node.lastSeen}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n🎯 Frontend Display:');
|
||||
console.log(' The frontend will show:');
|
||||
if (discoveryResponse.data.primaryNode) {
|
||||
const status = discoveryResponse.data.clientInitialized ? '✅' : '⚠️';
|
||||
const nodeCount = discoveryResponse.data.totalNodes > 1 ? ` (${discoveryResponse.data.totalNodes} nodes)` : '';
|
||||
console.log(` ${status} ${discoveryResponse.data.primaryNode}${nodeCount}`);
|
||||
} else if (discoveryResponse.data.totalNodes > 0) {
|
||||
const firstNode = discoveryResponse.data.nodes[0];
|
||||
console.log(` ⚠️ ${firstNode.ip} (No Primary)`);
|
||||
} else {
|
||||
console.log(' 🔍 No Nodes Found');
|
||||
}
|
||||
|
||||
console.log('\n💡 To test the frontend:');
|
||||
console.log(' 1. Open http://localhost:3001 in your browser');
|
||||
console.log(' 2. Look at the cluster header for primary node info');
|
||||
console.log(' 3. Send discovery messages: npm run test-discovery broadcast');
|
||||
console.log(' 4. Watch the primary node display update in real-time');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
console.log('\n💡 Make sure the backend is running: npm start');
|
||||
}
|
||||
}
|
||||
|
||||
// Run the demo
|
||||
showFrontendIntegration().catch(console.error);
|
||||
132
test/mock-api-client.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Mock API Client for communicating with the mock server
|
||||
// This replaces the original API client to use port 3002
|
||||
|
||||
class MockApiClient {
|
||||
constructor() {
|
||||
// Use port 3002 for mock server
|
||||
const currentHost = window.location.hostname;
|
||||
this.baseUrl = `http://${currentHost}:3002`;
|
||||
|
||||
console.log('Mock API Client initialized with base URL:', this.baseUrl);
|
||||
}
|
||||
|
||||
async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
if (query && typeof query === 'object') {
|
||||
Object.entries(query).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
||||
});
|
||||
}
|
||||
const finalHeaders = { 'Accept': 'application/json', ...headers };
|
||||
const options = { method, headers: finalHeaders };
|
||||
if (body !== undefined) {
|
||||
if (isForm) {
|
||||
options.body = body;
|
||||
} else {
|
||||
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
|
||||
options.body = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
}
|
||||
}
|
||||
const response = await fetch(url.toString(), options);
|
||||
let data;
|
||||
const text = await response.text();
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text; // Non-JSON payload
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async getClusterMembers() {
|
||||
return this.request('/api/cluster/members', { method: 'GET' });
|
||||
}
|
||||
|
||||
async getClusterMembersFromNode(ip) {
|
||||
return this.request(`/api/cluster/members`, {
|
||||
method: 'GET',
|
||||
query: { ip: ip }
|
||||
});
|
||||
}
|
||||
|
||||
async getDiscoveryInfo() {
|
||||
return this.request('/api/discovery/nodes', { method: 'GET' });
|
||||
}
|
||||
|
||||
async selectRandomPrimaryNode() {
|
||||
return this.request('/api/discovery/random-primary', {
|
||||
method: 'POST',
|
||||
body: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
return this.request('/api/node/status', {
|
||||
method: 'GET',
|
||||
query: { ip: ip }
|
||||
});
|
||||
}
|
||||
|
||||
async getTasksStatus(ip) {
|
||||
return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
|
||||
}
|
||||
|
||||
async getEndpoints(ip) {
|
||||
return this.request('/api/node/endpoints', { method: 'GET', query: ip ? { ip } : undefined });
|
||||
}
|
||||
|
||||
async callEndpoint({ ip, method, uri, params }) {
|
||||
return this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: { ip, method, uri, params }
|
||||
});
|
||||
}
|
||||
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const data = await this.request(`/api/node/update`, {
|
||||
method: 'POST',
|
||||
query: { ip: nodeIp },
|
||||
body: formData,
|
||||
isForm: true,
|
||||
headers: {},
|
||||
});
|
||||
// Some endpoints may return HTTP 200 with success=false on logical failure
|
||||
if (data && data.success === false) {
|
||||
const message = data.message || 'Firmware upload failed';
|
||||
throw new Error(message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async getMonitoringResources(ip) {
|
||||
return this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
ip: ip,
|
||||
method: 'GET',
|
||||
uri: '/api/monitoring/resources',
|
||||
params: []
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Override the global API client
|
||||
window.apiClient = new MockApiClient();
|
||||
|
||||
// Add debugging
|
||||
console.log('Mock API Client loaded and initialized');
|
||||
console.log('API Client base URL:', window.apiClient.baseUrl);
|
||||
|
||||
// Test API call
|
||||
window.apiClient.getDiscoveryInfo().then(data => {
|
||||
console.log('Mock API test successful:', data);
|
||||
}).catch(error => {
|
||||
console.error('Mock API test failed:', error);
|
||||
});
|
||||
232
test/mock-cli.js
Normal file
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Mock Server CLI Tool
|
||||
*
|
||||
* Command-line interface for managing the SPORE UI mock server
|
||||
* with different configurations and scenarios
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const { getMockConfig, listMockConfigs, createCustomConfig } = require('./mock-configs');
|
||||
|
||||
// Colors for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m'
|
||||
};
|
||||
|
||||
function colorize(text, color) {
|
||||
return `${colors[color]}${text}${colors.reset}`;
|
||||
}
|
||||
|
||||
function printHeader() {
|
||||
console.log(colorize('🚀 SPORE UI Mock Server CLI', 'cyan'));
|
||||
console.log(colorize('=============================', 'cyan'));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log('Usage: node mock-cli.js <command> [options]');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(' start [config] Start mock server with specified config');
|
||||
console.log(' list List available configurations');
|
||||
console.log(' info <config> Show detailed info about a configuration');
|
||||
console.log(' help Show this help message');
|
||||
console.log('');
|
||||
console.log('Available Configurations:');
|
||||
listMockConfigs().forEach(config => {
|
||||
console.log(` ${colorize(config.name, 'green')} - ${config.description} (${config.nodeCount} nodes)`);
|
||||
});
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' node mock-cli.js start healthy');
|
||||
console.log(' node mock-cli.js start degraded');
|
||||
console.log(' node mock-cli.js list');
|
||||
console.log(' node mock-cli.js info large');
|
||||
}
|
||||
|
||||
function printConfigInfo(configName) {
|
||||
const config = getMockConfig(configName);
|
||||
|
||||
console.log(colorize(`📋 Configuration: ${config.name}`, 'blue'));
|
||||
console.log(colorize('='.repeat(50), 'blue'));
|
||||
console.log(`Description: ${config.description}`);
|
||||
console.log(`Nodes: ${config.nodes.length}`);
|
||||
console.log('');
|
||||
|
||||
if (config.nodes.length > 0) {
|
||||
console.log(colorize('🌐 Mock Nodes:', 'yellow'));
|
||||
config.nodes.forEach((node, index) => {
|
||||
const statusColor = node.status === 'ACTIVE' ? 'green' :
|
||||
node.status === 'INACTIVE' ? 'yellow' : 'red';
|
||||
console.log(` ${index + 1}. ${colorize(node.hostname, 'cyan')} (${node.ip}) - ${colorize(node.status, statusColor)}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log(colorize('⚙️ Simulation Settings:', 'yellow'));
|
||||
console.log(` Time Progression: ${config.simulation.enableTimeProgression ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
|
||||
console.log(` Random Failures: ${config.simulation.enableRandomFailures ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
|
||||
if (config.simulation.enableRandomFailures) {
|
||||
console.log(` Failure Rate: ${(config.simulation.failureRate * 100).toFixed(1)}%`);
|
||||
}
|
||||
console.log(` Update Interval: ${config.simulation.updateInterval}ms`);
|
||||
console.log(` Primary Rotation: ${config.simulation.primaryNodeRotation ? colorize('Enabled', 'green') : colorize('Disabled', 'red')}`);
|
||||
if (config.simulation.primaryNodeRotation) {
|
||||
console.log(` Rotation Interval: ${config.simulation.rotationInterval}ms`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
function startMockServer(configName) {
|
||||
const config = getMockConfig(configName);
|
||||
|
||||
console.log(colorize(`🚀 Starting mock server with '${config.name}' configuration...`, 'green'));
|
||||
console.log('');
|
||||
|
||||
// Set environment variables for the mock server
|
||||
const env = {
|
||||
...process.env,
|
||||
MOCK_CONFIG: configName,
|
||||
MOCK_PORT: process.env.MOCK_PORT || '3002'
|
||||
};
|
||||
|
||||
// Start the mock server
|
||||
const mockServerPath = path.join(__dirname, 'mock-server.js');
|
||||
const child = spawn('node', [mockServerPath], {
|
||||
env: env,
|
||||
stdio: 'inherit',
|
||||
cwd: path.join(__dirname, '..')
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on('SIGINT', () => {
|
||||
console.log(colorize('\n\n🛑 Stopping mock server...', 'yellow'));
|
||||
child.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.log(colorize(`\n❌ Mock server exited with code ${code}`, 'red'));
|
||||
} else {
|
||||
console.log(colorize('\n✅ Mock server stopped gracefully', 'green'));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(colorize(`\n❌ Failed to start mock server: ${error.message}`, 'red'));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
function listConfigurations() {
|
||||
console.log(colorize('📋 Available Mock Configurations', 'blue'));
|
||||
console.log(colorize('================================', 'blue'));
|
||||
console.log('');
|
||||
|
||||
const configs = listMockConfigs();
|
||||
configs.forEach(config => {
|
||||
console.log(colorize(`🔧 ${config.displayName}`, 'green'));
|
||||
console.log(` Key: ${colorize(config.name, 'cyan')}`);
|
||||
console.log(` Description: ${config.description}`);
|
||||
console.log(` Nodes: ${config.nodeCount}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
console.log(colorize('💡 Usage:', 'yellow'));
|
||||
console.log(' node mock-cli.js start <config-key>');
|
||||
console.log(' node mock-cli.js info <config-key>');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Main CLI logic
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
const configName = args[1];
|
||||
|
||||
printHeader();
|
||||
|
||||
switch (command) {
|
||||
case 'start':
|
||||
if (!configName) {
|
||||
console.log(colorize('❌ Error: Configuration name required', 'red'));
|
||||
console.log('Usage: node mock-cli.js start <config-name>');
|
||||
console.log('Run "node mock-cli.js list" to see available configurations');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = getMockConfig(configName);
|
||||
if (!config) {
|
||||
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
|
||||
console.log('Run "node mock-cli.js list" to see available configurations');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
printConfigInfo(configName);
|
||||
startMockServer(configName);
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
listConfigurations();
|
||||
break;
|
||||
|
||||
case 'info':
|
||||
if (!configName) {
|
||||
console.log(colorize('❌ Error: Configuration name required', 'red'));
|
||||
console.log('Usage: node mock-cli.js info <config-name>');
|
||||
console.log('Run "node mock-cli.js list" to see available configurations');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const infoConfig = getMockConfig(configName);
|
||||
if (!infoConfig) {
|
||||
console.log(colorize(`❌ Error: Unknown configuration '${configName}'`, 'red'));
|
||||
console.log('Run "node mock-cli.js list" to see available configurations');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
printConfigInfo(configName);
|
||||
break;
|
||||
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
printHelp();
|
||||
break;
|
||||
|
||||
default:
|
||||
if (!command) {
|
||||
console.log(colorize('❌ Error: Command required', 'red'));
|
||||
console.log('');
|
||||
printHelp();
|
||||
} else {
|
||||
console.log(colorize(`❌ Error: Unknown command '${command}'`, 'red'));
|
||||
console.log('');
|
||||
printHelp();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the CLI
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMockConfig,
|
||||
listMockConfigs,
|
||||
printConfigInfo,
|
||||
startMockServer
|
||||
};
|
||||
291
test/mock-configs.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Mock Configuration Presets
|
||||
*
|
||||
* Different scenarios for testing the SPORE UI with various conditions
|
||||
*/
|
||||
|
||||
const mockConfigs = {
|
||||
// Default healthy cluster
|
||||
healthy: {
|
||||
name: "Healthy Cluster",
|
||||
description: "All nodes active and functioning normally",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'spore-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.101',
|
||||
hostname: 'spore-node-2',
|
||||
chipId: 87654321,
|
||||
status: 'ACTIVE',
|
||||
latency: 8
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.102',
|
||||
hostname: 'spore-node-3',
|
||||
chipId: 11223344,
|
||||
status: 'ACTIVE',
|
||||
latency: 12
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: false,
|
||||
failureRate: 0.0,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: false,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
},
|
||||
|
||||
// Single node scenario
|
||||
single: {
|
||||
name: "Single Node",
|
||||
description: "Only one node in the cluster",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'spore-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: false,
|
||||
failureRate: 0.0,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: false,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
},
|
||||
|
||||
// Large cluster
|
||||
large: {
|
||||
name: "Large Cluster",
|
||||
description: "Many nodes in the cluster",
|
||||
nodes: [
|
||||
{ ip: '192.168.1.100', hostname: 'spore-node-1', chipId: 12345678, status: 'ACTIVE', latency: 5 },
|
||||
{ ip: '192.168.1.101', hostname: 'spore-node-2', chipId: 87654321, status: 'ACTIVE', latency: 8 },
|
||||
{ ip: '192.168.1.102', hostname: 'spore-node-3', chipId: 11223344, status: 'ACTIVE', latency: 12 },
|
||||
{ ip: '192.168.1.103', hostname: 'spore-node-4', chipId: 44332211, status: 'ACTIVE', latency: 15 },
|
||||
{ ip: '192.168.1.104', hostname: 'spore-node-5', chipId: 55667788, status: 'ACTIVE', latency: 7 },
|
||||
{ ip: '192.168.1.105', hostname: 'spore-node-6', chipId: 99887766, status: 'ACTIVE', latency: 20 },
|
||||
{ ip: '192.168.1.106', hostname: 'spore-node-7', chipId: 11223355, status: 'ACTIVE', latency: 9 },
|
||||
{ ip: '192.168.1.107', hostname: 'spore-node-8', chipId: 66778899, status: 'ACTIVE', latency: 11 }
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: false,
|
||||
failureRate: 0.0,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: true,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
},
|
||||
|
||||
// Degraded cluster with some failures
|
||||
degraded: {
|
||||
name: "Degraded Cluster",
|
||||
description: "Some nodes are inactive or dead",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'spore-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.101',
|
||||
hostname: 'spore-node-2',
|
||||
chipId: 87654321,
|
||||
status: 'INACTIVE',
|
||||
latency: 8
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.102',
|
||||
hostname: 'spore-node-3',
|
||||
chipId: 11223344,
|
||||
status: 'DEAD',
|
||||
latency: 12
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.103',
|
||||
hostname: 'spore-node-4',
|
||||
chipId: 44332211,
|
||||
status: 'ACTIVE',
|
||||
latency: 15
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: true,
|
||||
failureRate: 0.1,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: false,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
},
|
||||
|
||||
// High failure rate scenario
|
||||
unstable: {
|
||||
name: "Unstable Cluster",
|
||||
description: "High failure rate with frequent node changes",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'spore-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.101',
|
||||
hostname: 'spore-node-2',
|
||||
chipId: 87654321,
|
||||
status: 'ACTIVE',
|
||||
latency: 8
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.102',
|
||||
hostname: 'spore-node-3',
|
||||
chipId: 11223344,
|
||||
status: 'ACTIVE',
|
||||
latency: 12
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: true,
|
||||
failureRate: 0.3, // 30% chance of failures
|
||||
updateInterval: 2000, // Update every 2 seconds
|
||||
primaryNodeRotation: true,
|
||||
rotationInterval: 15000 // Rotate every 15 seconds
|
||||
}
|
||||
},
|
||||
|
||||
// No nodes scenario
|
||||
empty: {
|
||||
name: "Empty Cluster",
|
||||
description: "No nodes discovered",
|
||||
nodes: [],
|
||||
simulation: {
|
||||
enableTimeProgression: false,
|
||||
enableRandomFailures: false,
|
||||
failureRate: 0.0,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: false,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
},
|
||||
|
||||
// Development scenario with custom settings
|
||||
development: {
|
||||
name: "Development Mode",
|
||||
description: "Custom settings for development and testing",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'dev-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
},
|
||||
{
|
||||
ip: '192.168.1.101',
|
||||
hostname: 'dev-node-2',
|
||||
chipId: 87654321,
|
||||
status: 'ACTIVE',
|
||||
latency: 8
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: true,
|
||||
failureRate: 0.05, // 5% failure rate
|
||||
updateInterval: 3000, // Update every 3 seconds
|
||||
primaryNodeRotation: true,
|
||||
rotationInterval: 20000 // Rotate every 20 seconds
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a mock configuration by name
|
||||
* @param {string} configName - Name of the configuration preset
|
||||
* @returns {Object} Mock configuration object
|
||||
*/
|
||||
function getMockConfig(configName = 'healthy') {
|
||||
const config = mockConfigs[configName];
|
||||
if (!config) {
|
||||
console.warn(`Unknown mock config: ${configName}. Using 'healthy' instead.`);
|
||||
return mockConfigs.healthy;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all available mock configurations
|
||||
* @returns {Array} Array of configuration names and descriptions
|
||||
*/
|
||||
function listMockConfigs() {
|
||||
return Object.keys(mockConfigs).map(key => ({
|
||||
name: key,
|
||||
displayName: mockConfigs[key].name,
|
||||
description: mockConfigs[key].description,
|
||||
nodeCount: mockConfigs[key].nodes.length
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom mock configuration
|
||||
* @param {Object} options - Configuration options
|
||||
* @returns {Object} Custom mock configuration
|
||||
*/
|
||||
function createCustomConfig(options = {}) {
|
||||
const defaultConfig = {
|
||||
name: "Custom Configuration",
|
||||
description: "User-defined mock configuration",
|
||||
nodes: [
|
||||
{
|
||||
ip: '192.168.1.100',
|
||||
hostname: 'custom-node-1',
|
||||
chipId: 12345678,
|
||||
status: 'ACTIVE',
|
||||
latency: 5
|
||||
}
|
||||
],
|
||||
simulation: {
|
||||
enableTimeProgression: true,
|
||||
enableRandomFailures: false,
|
||||
failureRate: 0.0,
|
||||
updateInterval: 5000,
|
||||
primaryNodeRotation: false,
|
||||
rotationInterval: 30000
|
||||
}
|
||||
};
|
||||
|
||||
// Merge with provided options
|
||||
return {
|
||||
...defaultConfig,
|
||||
...options,
|
||||
nodes: options.nodes || defaultConfig.nodes,
|
||||
simulation: {
|
||||
...defaultConfig.simulation,
|
||||
...options.simulation
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mockConfigs,
|
||||
getMockConfig,
|
||||
listMockConfigs,
|
||||
createCustomConfig
|
||||
};
|
||||
846
test/mock-server.js
Normal file
@@ -0,0 +1,846 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Complete Mock Server for SPORE UI
|
||||
*
|
||||
* This mock server provides a complete simulation of the SPORE embedded system
|
||||
* without requiring actual hardware or UDP port conflicts. It simulates:
|
||||
* - Multiple SPORE nodes with different IPs
|
||||
* - All API endpoints from the OpenAPI specification
|
||||
* - Discovery system without UDP conflicts
|
||||
* - Realistic data that changes over time
|
||||
* - Different scenarios (healthy, degraded, error states)
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const { getMockConfig } = require('./mock-configs');
|
||||
|
||||
// Load mock configuration
|
||||
const configName = process.env.MOCK_CONFIG || 'healthy';
|
||||
const baseConfig = getMockConfig(configName);
|
||||
|
||||
// Mock server configuration
|
||||
const MOCK_CONFIG = {
|
||||
// Server settings
|
||||
port: process.env.MOCK_PORT || 3002,
|
||||
baseUrl: process.env.MOCK_BASE_URL || 'http://localhost:3002',
|
||||
|
||||
// Load configuration from preset
|
||||
...baseConfig
|
||||
};
|
||||
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}));
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Mock data generators
|
||||
class MockDataGenerator {
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
this.nodeStates = new Map();
|
||||
this.primaryNodeIndex = 0;
|
||||
this.initializeNodeStates();
|
||||
}
|
||||
|
||||
initializeNodeStates() {
|
||||
MOCK_CONFIG.nodes.forEach((node, index) => {
|
||||
this.nodeStates.set(node.ip, {
|
||||
...node,
|
||||
freeHeap: this.generateFreeHeap(),
|
||||
uptime: 0,
|
||||
lastSeen: Date.now(),
|
||||
tasks: this.generateTasks(),
|
||||
systemInfo: this.generateSystemInfo(node),
|
||||
apiEndpoints: this.generateApiEndpoints()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateFreeHeap() {
|
||||
// Simulate realistic ESP8266 memory usage
|
||||
const base = 30000;
|
||||
const variation = 20000;
|
||||
return Math.floor(base + Math.random() * variation);
|
||||
}
|
||||
|
||||
generateSystemInfo(node) {
|
||||
return {
|
||||
freeHeap: this.generateFreeHeap(),
|
||||
chipId: node.chipId,
|
||||
sdkVersion: "3.1.2",
|
||||
cpuFreqMHz: 80,
|
||||
flashChipSize: 1048576
|
||||
};
|
||||
}
|
||||
|
||||
generateTasks() {
|
||||
return [
|
||||
{
|
||||
name: "discovery_send",
|
||||
interval: 1000,
|
||||
enabled: true,
|
||||
running: true,
|
||||
autoStart: true
|
||||
},
|
||||
{
|
||||
name: "heartbeat",
|
||||
interval: 2000,
|
||||
enabled: true,
|
||||
running: true,
|
||||
autoStart: true
|
||||
},
|
||||
{
|
||||
name: "status_update",
|
||||
interval: 1000,
|
||||
enabled: true,
|
||||
running: true,
|
||||
autoStart: true
|
||||
},
|
||||
{
|
||||
name: "wifi_monitor",
|
||||
interval: 5000,
|
||||
enabled: true,
|
||||
running: Math.random() > 0.1, // 90% chance of running
|
||||
autoStart: true
|
||||
},
|
||||
{
|
||||
name: "ota_check",
|
||||
interval: 30000,
|
||||
enabled: true,
|
||||
running: Math.random() > 0.2, // 80% chance of running
|
||||
autoStart: true
|
||||
},
|
||||
{
|
||||
name: "cluster_sync",
|
||||
interval: 10000,
|
||||
enabled: true,
|
||||
running: Math.random() > 0.05, // 95% chance of running
|
||||
autoStart: true
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
generateApiEndpoints() {
|
||||
return [
|
||||
{ uri: "/api/node/status", method: "GET" },
|
||||
{ uri: "/api/tasks/status", method: "GET" },
|
||||
{ uri: "/api/tasks/control", method: "POST" },
|
||||
{ uri: "/api/cluster/members", method: "GET" },
|
||||
{ uri: "/api/node/update", method: "POST" },
|
||||
{ uri: "/api/node/restart", method: "POST" },
|
||||
{
|
||||
uri: "/api/led/brightness",
|
||||
method: "POST",
|
||||
params: [
|
||||
{
|
||||
name: "brightness",
|
||||
type: "numberRange",
|
||||
location: "body",
|
||||
required: true,
|
||||
value: 255,
|
||||
default: 128
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
uri: "/api/led/color",
|
||||
method: "POST",
|
||||
params: [
|
||||
{
|
||||
name: "color",
|
||||
type: "color",
|
||||
location: "body",
|
||||
required: true,
|
||||
default: 16711680
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
uri: "/api/sensor/interval",
|
||||
method: "POST",
|
||||
params: [
|
||||
{
|
||||
name: "interval",
|
||||
type: "numberRange",
|
||||
location: "body",
|
||||
required: true,
|
||||
value: 10000,
|
||||
default: 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
uri: "/api/system/mode",
|
||||
method: "POST",
|
||||
params: [
|
||||
{
|
||||
name: "mode",
|
||||
type: "string",
|
||||
location: "body",
|
||||
required: true,
|
||||
values: ["normal", "debug", "maintenance"],
|
||||
default: "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
updateNodeStates() {
|
||||
if (!MOCK_CONFIG.simulation.enableTimeProgression) return;
|
||||
|
||||
this.nodeStates.forEach((nodeState, ip) => {
|
||||
// Update uptime
|
||||
nodeState.uptime = Date.now() - this.startTime;
|
||||
|
||||
// Update free heap (simulate memory usage changes)
|
||||
const currentHeap = nodeState.freeHeap;
|
||||
const change = Math.floor((Math.random() - 0.5) * 1000);
|
||||
nodeState.freeHeap = Math.max(10000, currentHeap + change);
|
||||
|
||||
// Update last seen
|
||||
nodeState.lastSeen = Date.now();
|
||||
|
||||
// Simulate random failures
|
||||
if (MOCK_CONFIG.simulation.enableRandomFailures && Math.random() < MOCK_CONFIG.simulation.failureRate) {
|
||||
nodeState.status = Math.random() > 0.5 ? 'INACTIVE' : 'DEAD';
|
||||
} else {
|
||||
nodeState.status = 'ACTIVE';
|
||||
}
|
||||
|
||||
// Update task states
|
||||
nodeState.tasks.forEach(task => {
|
||||
if (task.enabled && Math.random() > 0.05) { // 95% chance of running when enabled
|
||||
task.running = true;
|
||||
} else {
|
||||
task.running = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Rotate primary node if enabled
|
||||
if (MOCK_CONFIG.simulation.primaryNodeRotation) {
|
||||
this.primaryNodeIndex = (this.primaryNodeIndex + 1) % MOCK_CONFIG.nodes.length;
|
||||
}
|
||||
}
|
||||
|
||||
getPrimaryNode() {
|
||||
return MOCK_CONFIG.nodes[this.primaryNodeIndex];
|
||||
}
|
||||
|
||||
getAllNodes() {
|
||||
return Array.from(this.nodeStates.values());
|
||||
}
|
||||
|
||||
getNodeByIp(ip) {
|
||||
return this.nodeStates.get(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize mock data generator
|
||||
const mockData = new MockDataGenerator();
|
||||
|
||||
// Update data periodically
|
||||
setInterval(() => {
|
||||
mockData.updateNodeStates();
|
||||
}, MOCK_CONFIG.simulation.updateInterval);
|
||||
|
||||
// API Routes
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', (req, res) => {
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
const allNodes = mockData.getAllNodes();
|
||||
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
|
||||
|
||||
const health = {
|
||||
status: activeNodes.length > 0 ? 'healthy' : 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
services: {
|
||||
http: true,
|
||||
udp: false, // Mock server doesn't use UDP
|
||||
sporeClient: true
|
||||
},
|
||||
discovery: {
|
||||
totalNodes: allNodes.length,
|
||||
primaryNode: primaryNode.ip,
|
||||
udpPort: 4210,
|
||||
serverRunning: false // Mock server doesn't use UDP
|
||||
},
|
||||
mock: {
|
||||
enabled: true,
|
||||
nodes: allNodes.length,
|
||||
activeNodes: activeNodes.length,
|
||||
simulationMode: MOCK_CONFIG.simulation.enableTimeProgression
|
||||
}
|
||||
};
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
health.status = 'degraded';
|
||||
health.message = 'No active nodes in mock simulation';
|
||||
}
|
||||
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
// Discovery endpoints (simulated)
|
||||
app.get('/api/discovery/nodes', (req, res) => {
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
const allNodes = mockData.getAllNodes();
|
||||
|
||||
const response = {
|
||||
primaryNode: primaryNode.ip,
|
||||
totalNodes: allNodes.length,
|
||||
clientInitialized: true,
|
||||
clientBaseUrl: `http://${primaryNode.ip}`,
|
||||
nodes: allNodes.map(node => ({
|
||||
ip: node.ip,
|
||||
port: 80,
|
||||
discoveredAt: new Date(node.lastSeen - 60000).toISOString(), // 1 minute ago
|
||||
lastSeen: new Date(node.lastSeen).toISOString(),
|
||||
isPrimary: node.ip === primaryNode.ip,
|
||||
hostname: node.hostname,
|
||||
status: node.status
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
app.post('/api/discovery/refresh', (req, res) => {
|
||||
// Simulate discovery refresh
|
||||
mockData.updateNodeStates();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Discovery refresh completed',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/discovery/primary/:ip', (req, res) => {
|
||||
const { ip } = req.params;
|
||||
const node = mockData.getNodeByIp(ip);
|
||||
|
||||
if (!node) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Node ${ip} not found`
|
||||
});
|
||||
}
|
||||
|
||||
// Find and set as primary
|
||||
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === ip);
|
||||
if (nodeIndex !== -1) {
|
||||
mockData.primaryNodeIndex = nodeIndex;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Primary node set to ${ip}`,
|
||||
primaryNode: ip
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/discovery/random-primary', (req, res) => {
|
||||
const allNodes = mockData.getAllNodes();
|
||||
const activeNodes = allNodes.filter(node => node.status === 'ACTIVE');
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
message: 'No active nodes available for selection'
|
||||
});
|
||||
}
|
||||
|
||||
// Randomly select a new primary
|
||||
const randomIndex = Math.floor(Math.random() * activeNodes.length);
|
||||
const newPrimary = activeNodes[randomIndex];
|
||||
const nodeIndex = MOCK_CONFIG.nodes.findIndex(n => n.ip === newPrimary.ip);
|
||||
|
||||
if (nodeIndex !== -1) {
|
||||
mockData.primaryNodeIndex = nodeIndex;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Primary node randomly selected: ${newPrimary.ip}`,
|
||||
primaryNode: newPrimary.ip,
|
||||
totalNodes: allNodes.length,
|
||||
clientInitialized: true
|
||||
});
|
||||
});
|
||||
|
||||
// Task management endpoints
|
||||
app.get('/api/tasks/status', (req, res) => {
|
||||
const { ip } = req.query;
|
||||
let nodeData;
|
||||
|
||||
if (ip) {
|
||||
nodeData = mockData.getNodeByIp(ip);
|
||||
if (!nodeData) {
|
||||
return res.status(404).json({
|
||||
error: 'Node not found',
|
||||
message: `Node ${ip} not found in mock simulation`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use primary node
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
nodeData = mockData.getNodeByIp(primaryNode.ip);
|
||||
}
|
||||
|
||||
const tasks = nodeData.tasks;
|
||||
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
|
||||
|
||||
const response = {
|
||||
summary: {
|
||||
totalTasks: tasks.length,
|
||||
activeTasks: activeTasks
|
||||
},
|
||||
tasks: tasks,
|
||||
system: {
|
||||
freeHeap: nodeData.freeHeap,
|
||||
uptime: nodeData.uptime
|
||||
}
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
app.post('/api/tasks/control', (req, res) => {
|
||||
const { task, action } = req.body;
|
||||
|
||||
if (!task || !action) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing parameters. Required: task, action',
|
||||
example: '{"task": "discovery_send", "action": "status"}'
|
||||
});
|
||||
}
|
||||
|
||||
const validActions = ['enable', 'disable', 'start', 'stop', 'status'];
|
||||
if (!validActions.includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid action. Use: enable, disable, start, stop, or status',
|
||||
task: task,
|
||||
action: action
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate task control
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
const nodeData = mockData.getNodeByIp(primaryNode.ip);
|
||||
const taskData = nodeData.tasks.find(t => t.name === task);
|
||||
|
||||
if (!taskData) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: `Task ${task} not found`
|
||||
});
|
||||
}
|
||||
|
||||
// Apply action
|
||||
switch (action) {
|
||||
case 'enable':
|
||||
taskData.enabled = true;
|
||||
break;
|
||||
case 'disable':
|
||||
taskData.enabled = false;
|
||||
taskData.running = false;
|
||||
break;
|
||||
case 'start':
|
||||
if (taskData.enabled) {
|
||||
taskData.running = true;
|
||||
}
|
||||
break;
|
||||
case 'stop':
|
||||
taskData.running = false;
|
||||
break;
|
||||
case 'status':
|
||||
// Return detailed status
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Task status retrieved',
|
||||
task: task,
|
||||
action: action,
|
||||
taskDetails: {
|
||||
name: taskData.name,
|
||||
enabled: taskData.enabled,
|
||||
running: taskData.running,
|
||||
interval: taskData.interval,
|
||||
system: {
|
||||
freeHeap: nodeData.freeHeap,
|
||||
uptime: nodeData.uptime
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Task ${action}d`,
|
||||
task: task,
|
||||
action: action
|
||||
});
|
||||
});
|
||||
|
||||
// System status endpoint
|
||||
app.get('/api/node/status', (req, res) => {
|
||||
const { ip } = req.query;
|
||||
let nodeData;
|
||||
|
||||
if (ip) {
|
||||
nodeData = mockData.getNodeByIp(ip);
|
||||
if (!nodeData) {
|
||||
return res.status(404).json({
|
||||
error: 'Node not found',
|
||||
message: `Node ${ip} not found in mock simulation`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use primary node
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
nodeData = mockData.getNodeByIp(primaryNode.ip);
|
||||
}
|
||||
|
||||
const response = {
|
||||
freeHeap: nodeData.freeHeap,
|
||||
chipId: nodeData.chipId,
|
||||
sdkVersion: nodeData.systemInfo.sdkVersion,
|
||||
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
|
||||
flashChipSize: nodeData.systemInfo.flashChipSize,
|
||||
api: nodeData.apiEndpoints
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
});
|
||||
|
||||
// Cluster members endpoint
|
||||
app.get('/api/cluster/members', (req, res) => {
|
||||
const allNodes = mockData.getAllNodes();
|
||||
|
||||
const members = allNodes.map(node => ({
|
||||
hostname: node.hostname,
|
||||
ip: node.ip,
|
||||
lastSeen: Math.floor(node.lastSeen / 1000), // Convert to seconds
|
||||
latency: node.latency,
|
||||
status: node.status,
|
||||
resources: {
|
||||
freeHeap: node.freeHeap,
|
||||
chipId: node.chipId,
|
||||
sdkVersion: node.systemInfo.sdkVersion,
|
||||
cpuFreqMHz: node.systemInfo.cpuFreqMHz,
|
||||
flashChipSize: node.systemInfo.flashChipSize
|
||||
},
|
||||
api: node.apiEndpoints
|
||||
}));
|
||||
|
||||
res.json({ members });
|
||||
});
|
||||
|
||||
// Node endpoints endpoint
|
||||
app.get('/api/node/endpoints', (req, res) => {
|
||||
const { ip } = req.query;
|
||||
let nodeData;
|
||||
|
||||
if (ip) {
|
||||
nodeData = mockData.getNodeByIp(ip);
|
||||
if (!nodeData) {
|
||||
return res.status(404).json({
|
||||
error: 'Node not found',
|
||||
message: `Node ${ip} not found in mock simulation`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use primary node
|
||||
const primaryNode = mockData.getPrimaryNode();
|
||||
nodeData = mockData.getNodeByIp(primaryNode.ip);
|
||||
}
|
||||
|
||||
res.json(nodeData.apiEndpoints);
|
||||
});
|
||||
|
||||
// Generic proxy endpoint
|
||||
app.post('/api/proxy-call', (req, res) => {
|
||||
const { ip, method, uri, params } = req.body || {};
|
||||
|
||||
if (!ip || !method || !uri) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields',
|
||||
message: 'Required: ip, method, uri'
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate proxy call by routing to appropriate mock endpoint
|
||||
const nodeData = mockData.getNodeByIp(ip);
|
||||
if (!nodeData) {
|
||||
return res.status(404).json({
|
||||
error: 'Node not found',
|
||||
message: `Node ${ip} not found in mock simulation`
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate different responses based on URI
|
||||
if (uri === '/api/node/status') {
|
||||
return res.json({
|
||||
freeHeap: nodeData.freeHeap,
|
||||
chipId: nodeData.chipId,
|
||||
sdkVersion: nodeData.systemInfo.sdkVersion,
|
||||
cpuFreqMHz: nodeData.systemInfo.cpuFreqMHz,
|
||||
flashChipSize: nodeData.systemInfo.flashChipSize,
|
||||
api: nodeData.apiEndpoints
|
||||
});
|
||||
} else if (uri === '/api/tasks/status') {
|
||||
const tasks = nodeData.tasks;
|
||||
const activeTasks = tasks.filter(task => task.enabled && task.running).length;
|
||||
|
||||
return res.json({
|
||||
summary: {
|
||||
totalTasks: tasks.length,
|
||||
activeTasks: activeTasks
|
||||
},
|
||||
tasks: tasks,
|
||||
system: {
|
||||
freeHeap: nodeData.freeHeap,
|
||||
uptime: nodeData.uptime
|
||||
}
|
||||
});
|
||||
} else if (uri === '/api/monitoring/resources') {
|
||||
// Return realistic monitoring resources data
|
||||
const totalHeap = nodeData.systemInfo.flashChipSize || 1048576; // 1MB default
|
||||
const freeHeap = nodeData.freeHeap;
|
||||
const usedHeap = totalHeap - freeHeap;
|
||||
const heapUsagePercent = (usedHeap / totalHeap) * 100;
|
||||
|
||||
return res.json({
|
||||
cpu: {
|
||||
average_usage: Math.random() * 30 + 10, // 10-40% CPU usage
|
||||
current_usage: Math.random() * 50 + 5, // 5-55% current usage
|
||||
frequency_mhz: nodeData.systemInfo.cpuFreqMHz || 80
|
||||
},
|
||||
memory: {
|
||||
total_heap: totalHeap,
|
||||
free_heap: freeHeap,
|
||||
used_heap: usedHeap,
|
||||
heap_usage_percent: heapUsagePercent,
|
||||
min_free_heap: Math.floor(freeHeap * 0.8), // 80% of current free heap
|
||||
max_alloc_heap: Math.floor(totalHeap * 0.9) // 90% of total heap
|
||||
},
|
||||
filesystem: {
|
||||
total_bytes: 3145728, // 3MB SPIFFS
|
||||
used_bytes: Math.floor(3145728 * (0.3 + Math.random() * 0.4)), // 30-70% used
|
||||
free_bytes: 0 // Will be calculated
|
||||
},
|
||||
network: {
|
||||
wifi_rssi: -30 - Math.floor(Math.random() * 40), // -30 to -70 dBm
|
||||
wifi_connected: true,
|
||||
uptime_seconds: nodeData.uptime
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Mock response for ${method} ${uri}`,
|
||||
node: ip,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Firmware update endpoint
|
||||
app.post('/api/node/update', (req, res) => {
|
||||
// Simulate firmware update
|
||||
res.json({
|
||||
status: 'updating',
|
||||
message: 'Firmware update in progress (mock simulation)'
|
||||
});
|
||||
});
|
||||
|
||||
// System restart endpoint
|
||||
app.post('/api/node/restart', (req, res) => {
|
||||
// Simulate system restart
|
||||
res.json({
|
||||
status: 'restarting'
|
||||
});
|
||||
});
|
||||
|
||||
// Test route
|
||||
app.get('/test', (req, res) => {
|
||||
res.send('Mock server is working!');
|
||||
});
|
||||
|
||||
// Serve the mock UI (main UI with modified API client)
|
||||
app.get('/', (req, res) => {
|
||||
const filePath = path.join(__dirname, 'mock-ui.html');
|
||||
console.log('Serving mock UI from:', filePath);
|
||||
res.sendFile(filePath);
|
||||
});
|
||||
|
||||
// Serve the original mock frontend
|
||||
app.get('/frontend', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'mock-frontend.html'));
|
||||
});
|
||||
|
||||
// Serve the main UI with modified API client
|
||||
app.get('/ui', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// Serve static files from public directory (after custom routes)
|
||||
// Only serve static files for specific paths, not the root
|
||||
app.use('/static', express.static(path.join(__dirname, '../public')));
|
||||
app.use('/styles', express.static(path.join(__dirname, '../public/styles')));
|
||||
app.use('/scripts', express.static(path.join(__dirname, '../public/scripts')));
|
||||
app.use('/vendor', express.static(path.join(__dirname, '../public/vendor')));
|
||||
|
||||
// Serve mock API client
|
||||
app.get('/test/mock-api-client.js', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'mock-api-client.js'));
|
||||
});
|
||||
|
||||
// Serve test page
|
||||
app.get('/test-page', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'test-page.html'));
|
||||
});
|
||||
|
||||
// Serve favicon to prevent 404 errors
|
||||
app.get('/favicon.ico', (req, res) => {
|
||||
res.status(204).end(); // No content
|
||||
});
|
||||
|
||||
// Serve mock server info page
|
||||
app.get('/info', (req, res) => {
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - Mock Server Info</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; background: #f5f5f5; }
|
||||
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||
h1 { color: #333; text-align: center; }
|
||||
.status { background: #e8f5e8; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
.info { background: #f0f8ff; padding: 15px; border-radius: 5px; margin: 20px 0; }
|
||||
.endpoint { background: #f9f9f9; padding: 10px; margin: 5px 0; border-left: 4px solid #007acc; }
|
||||
.mock-note { background: #fff3cd; padding: 15px; border-radius: 5px; margin: 20px 0; border-left: 4px solid #ffc107; }
|
||||
.btn { display: inline-block; padding: 10px 20px; background: #007acc; color: white; text-decoration: none; border-radius: 5px; margin: 5px; }
|
||||
.btn:hover { background: #005a9e; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🚀 SPORE UI Mock Server</h1>
|
||||
|
||||
<div class="mock-note">
|
||||
<strong>Mock Mode Active:</strong> This is a complete simulation of the SPORE embedded system.
|
||||
No real hardware or UDP ports are required.
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<h3>📊 Server Status</h3>
|
||||
<p><strong>Status:</strong> Running</p>
|
||||
<p><strong>Port:</strong> ${MOCK_CONFIG.port}</p>
|
||||
<p><strong>Configuration:</strong> ${MOCK_CONFIG.name}</p>
|
||||
<p><strong>Mock Nodes:</strong> ${MOCK_CONFIG.nodes.length}</p>
|
||||
<p><strong>Primary Node:</strong> ${mockData.getPrimaryNode().ip}</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h3>🌐 Access Points</h3>
|
||||
<p><a href="/" class="btn">Mock UI (Port 3002)</a> - Full UI with mock data</p>
|
||||
<p><a href="/frontend" class="btn">Mock Frontend</a> - Custom mock frontend</p>
|
||||
<p><a href="/ui" class="btn">Real UI (Port 3002)</a> - Real UI connected to mock server</p>
|
||||
<p><a href="/api/health" class="btn">API Health</a> - Check server status</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h3>🔗 Available Endpoints</h3>
|
||||
<div class="endpoint"><strong>GET</strong> /api/health - Health check</div>
|
||||
<div class="endpoint"><strong>GET</strong> /api/discovery/nodes - Discovery status</div>
|
||||
<div class="endpoint"><strong>GET</strong> /api/tasks/status - Task status</div>
|
||||
<div class="endpoint"><strong>POST</strong> /api/tasks/control - Control tasks</div>
|
||||
<div class="endpoint"><strong>GET</strong> /api/node/status - System status</div>
|
||||
<div class="endpoint"><strong>GET</strong> /api/cluster/members - Cluster members</div>
|
||||
<div class="endpoint"><strong>POST</strong> /api/proxy-call - Generic proxy</div>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h3>🎮 Mock Features</h3>
|
||||
<ul>
|
||||
<li>✅ Multiple simulated SPORE nodes</li>
|
||||
<li>✅ Realistic data that changes over time</li>
|
||||
<li>✅ No UDP port conflicts</li>
|
||||
<li>✅ All API endpoints implemented</li>
|
||||
<li>✅ Random failures simulation</li>
|
||||
<li>✅ Primary node rotation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<h3>🔧 Configuration</h3>
|
||||
<p>Use npm scripts to change configuration:</p>
|
||||
<ul>
|
||||
<li><code>npm run mock:healthy</code> - Healthy cluster (3 nodes)</li>
|
||||
<li><code>npm run mock:degraded</code> - Degraded cluster (some inactive)</li>
|
||||
<li><code>npm run mock:large</code> - Large cluster (8 nodes)</li>
|
||||
<li><code>npm run mock:unstable</code> - Unstable cluster (high failure rate)</li>
|
||||
<li><code>npm run mock:single</code> - Single node</li>
|
||||
<li><code>npm run mock:empty</code> - Empty cluster</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
// Start the mock server
|
||||
const server = app.listen(MOCK_CONFIG.port, () => {
|
||||
console.log('🚀 SPORE UI Mock Server Started');
|
||||
console.log('================================');
|
||||
console.log(`Configuration: ${MOCK_CONFIG.name}`);
|
||||
console.log(`Description: ${MOCK_CONFIG.description}`);
|
||||
console.log(`Port: ${MOCK_CONFIG.port}`);
|
||||
console.log(`URL: http://localhost:${MOCK_CONFIG.port}`);
|
||||
console.log(`Mock Nodes: ${MOCK_CONFIG.nodes.length}`);
|
||||
console.log(`Primary Node: ${mockData.getPrimaryNode().ip}`);
|
||||
console.log('');
|
||||
console.log('📡 Available Mock Nodes:');
|
||||
MOCK_CONFIG.nodes.forEach((node, index) => {
|
||||
console.log(` ${index + 1}. ${node.hostname} (${node.ip}) - ${node.status}`);
|
||||
});
|
||||
console.log('');
|
||||
console.log('🎮 Mock Features:');
|
||||
console.log(' ✅ No UDP port conflicts');
|
||||
console.log(' ✅ Realistic data simulation');
|
||||
console.log(' ✅ All API endpoints');
|
||||
console.log(` ✅ Time-based data updates (${MOCK_CONFIG.simulation.updateInterval}ms)`);
|
||||
console.log(` ✅ Random failure simulation (${MOCK_CONFIG.simulation.enableRandomFailures ? 'Enabled' : 'Disabled'})`);
|
||||
console.log(` ✅ Primary node rotation (${MOCK_CONFIG.simulation.primaryNodeRotation ? 'Enabled' : 'Disabled'})`);
|
||||
console.log('');
|
||||
console.log('Press Ctrl+C to stop');
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n\n👋 Mock server stopped. Goodbye!');
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = { app, mockData, MOCK_CONFIG };
|
||||
285
test/mock-test.js
Normal file
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Mock Server Integration Test
|
||||
*
|
||||
* Tests the mock server functionality to ensure all endpoints work correctly
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const MOCK_SERVER_URL = 'http://localhost:3002';
|
||||
const TIMEOUT = 5000; // 5 seconds
|
||||
|
||||
function makeRequest(path, method = 'GET', body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3002,
|
||||
path: path,
|
||||
method: method,
|
||||
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(JSON.stringify(body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function testEndpoint(name, testFn) {
|
||||
try {
|
||||
console.log(`🧪 Testing ${name}...`);
|
||||
const result = await testFn();
|
||||
console.log(`✅ ${name}: PASS`);
|
||||
return { name, status: 'PASS', result };
|
||||
} catch (error) {
|
||||
console.log(`❌ ${name}: FAIL - ${error.message}`);
|
||||
return { name, status: 'FAIL', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('🚀 SPORE UI Mock Server Integration Tests');
|
||||
console.log('==========================================');
|
||||
console.log('');
|
||||
|
||||
const results = [];
|
||||
|
||||
// Test 1: Health Check
|
||||
results.push(await testEndpoint('Health Check', async () => {
|
||||
const response = await makeRequest('/api/health');
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!response.data.status) {
|
||||
throw new Error('Missing status field');
|
||||
}
|
||||
if (!response.data.mock) {
|
||||
throw new Error('Missing mock field');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 2: Discovery Nodes
|
||||
results.push(await testEndpoint('Discovery Nodes', async () => {
|
||||
const response = await makeRequest('/api/discovery/nodes');
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!response.data.primaryNode) {
|
||||
throw new Error('Missing primaryNode field');
|
||||
}
|
||||
if (!Array.isArray(response.data.nodes)) {
|
||||
throw new Error('Nodes should be an array');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 3: Task Status
|
||||
results.push(await testEndpoint('Task Status', async () => {
|
||||
const response = await makeRequest('/api/tasks/status');
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!response.data.summary) {
|
||||
throw new Error('Missing summary field');
|
||||
}
|
||||
if (!Array.isArray(response.data.tasks)) {
|
||||
throw new Error('Tasks should be an array');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 4: Task Control
|
||||
results.push(await testEndpoint('Task Control', async () => {
|
||||
const response = await makeRequest('/api/tasks/control', 'POST', {
|
||||
task: 'heartbeat',
|
||||
action: 'status'
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!response.data.success) {
|
||||
throw new Error('Task control should succeed');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 5: System Status
|
||||
results.push(await testEndpoint('System Status', async () => {
|
||||
const response = await makeRequest('/api/node/status');
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (typeof response.data.freeHeap !== 'number') {
|
||||
throw new Error('freeHeap should be a number');
|
||||
}
|
||||
if (!response.data.chipId) {
|
||||
throw new Error('Missing chipId field');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 6: Cluster Members
|
||||
results.push(await testEndpoint('Cluster Members', async () => {
|
||||
const response = await makeRequest('/api/cluster/members');
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!Array.isArray(response.data.members)) {
|
||||
throw new Error('Members should be an array');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 7: Random Primary Selection
|
||||
results.push(await testEndpoint('Random Primary Selection', async () => {
|
||||
const response = await makeRequest('/api/discovery/random-primary', 'POST', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
if (!response.data.success) {
|
||||
throw new Error('Random selection should succeed');
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 8: Proxy Call
|
||||
results.push(await testEndpoint('Proxy Call', async () => {
|
||||
const response = await makeRequest('/api/proxy-call', 'POST', {
|
||||
ip: '192.168.1.100',
|
||||
method: 'GET',
|
||||
uri: '/api/node/status'
|
||||
});
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Expected status 200, got ${response.status}`);
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 9: Error Handling
|
||||
results.push(await testEndpoint('Error Handling', async () => {
|
||||
const response = await makeRequest('/api/tasks/control', 'POST', {
|
||||
task: 'nonexistent',
|
||||
action: 'status'
|
||||
});
|
||||
if (response.status !== 404) {
|
||||
throw new Error(`Expected status 404, got ${response.status}`);
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Test 10: Invalid Parameters
|
||||
results.push(await testEndpoint('Invalid Parameters', async () => {
|
||||
const response = await makeRequest('/api/tasks/control', 'POST', {
|
||||
// Missing required fields
|
||||
});
|
||||
if (response.status !== 400) {
|
||||
throw new Error(`Expected status 400, got ${response.status}`);
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
// Print Results
|
||||
console.log('');
|
||||
console.log('📊 Test Results');
|
||||
console.log('===============');
|
||||
|
||||
const passed = results.filter(r => r.status === 'PASS').length;
|
||||
const failed = results.filter(r => r.status === 'FAIL').length;
|
||||
const total = results.length;
|
||||
|
||||
results.forEach(result => {
|
||||
const status = result.status === 'PASS' ? '✅' : '❌';
|
||||
console.log(`${status} ${result.name}`);
|
||||
if (result.status === 'FAIL') {
|
||||
console.log(` Error: ${result.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('');
|
||||
console.log(`Total: ${total} | Passed: ${passed} | Failed: ${failed}`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('');
|
||||
console.log('🎉 All tests passed! Mock server is working correctly.');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log('⚠️ Some tests failed. Check the mock server configuration.');
|
||||
}
|
||||
|
||||
return failed === 0;
|
||||
}
|
||||
|
||||
// Check if mock server is running
|
||||
async function checkMockServer() {
|
||||
try {
|
||||
const response = await makeRequest('/api/health');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🔍 Checking if mock server is running...');
|
||||
|
||||
const isRunning = await checkMockServer();
|
||||
if (!isRunning) {
|
||||
console.log('❌ Mock server is not running!');
|
||||
console.log('');
|
||||
console.log('Please start the mock server first:');
|
||||
console.log(' npm run mock:healthy');
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Mock server is running');
|
||||
console.log('');
|
||||
|
||||
const success = await runTests();
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run tests
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error('❌ Test runner failed:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runTests, checkMockServer };
|
||||
273
test/mock-ui.html
Normal file
@@ -0,0 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - Mock Mode</title>
|
||||
|
||||
<!-- Include all the same styles as the main UI -->
|
||||
<link rel="stylesheet" href="/styles/main.css">
|
||||
<link rel="stylesheet" href="/styles/theme.css">
|
||||
|
||||
<!-- Include D3.js for topology visualization -->
|
||||
<script src="/vendor/d3.v7.min.js"></script>
|
||||
|
||||
<!-- Include framework and components in correct order -->
|
||||
<script src="/scripts/constants.js"></script>
|
||||
<script src="/scripts/framework.js"></script>
|
||||
<script src="/test/mock-api-client.js"></script>
|
||||
<script src="/scripts/view-models.js"></script>
|
||||
<script src="/scripts/components/DrawerComponent.js"></script>
|
||||
<script src="/scripts/components/PrimaryNodeComponent.js"></script>
|
||||
<script src="/scripts/components/NodeDetailsComponent.js"></script>
|
||||
<script src="/scripts/components/ClusterMembersComponent.js"></script>
|
||||
<script src="/scripts/components/FirmwareComponent.js"></script>
|
||||
<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/ComponentsLoader.js"></script>
|
||||
<script src="/scripts/theme-manager.js"></script>
|
||||
<script src="/scripts/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="main-navigation">
|
||||
<button class="burger-btn" id="burger-btn" aria-label="Menu" title="Menu">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="topology">🔗 Topology</button>
|
||||
<button class="nav-tab" data-view="monitoring">📡 Monitoring</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cluster-status">🚀 Cluster Online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="cluster-view" class="view-content active">
|
||||
<div class="cluster-section">
|
||||
<div class="cluster-header">
|
||||
<div class="cluster-header-left">
|
||||
<div class="primary-node-info">
|
||||
<span class="primary-node-label">Primary Node:</span>
|
||||
<span class="primary-node-ip" id="primary-node-ip">Discovering...</span>
|
||||
<button class="primary-node-refresh" id="select-random-primary-btn"
|
||||
title="🎲 Select random primary node">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14"
|
||||
height="14">
|
||||
<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>
|
||||
<div class="cluster-header-right">
|
||||
<button class="refresh-btn" id="refresh-cluster-btn" title="Refresh cluster data">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
|
||||
height="16">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cluster-members" id="cluster-members-container">
|
||||
<!-- Cluster members will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="topology-view" class="view-content">
|
||||
<div class="topology-section">
|
||||
<div class="topology-graph" id="topology-graph-container">
|
||||
<!-- Topology graph will be rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<div class="firmware-header">
|
||||
<h2>Firmware Management</h2>
|
||||
<button class="refresh-btn" id="refresh-firmware-btn" title="Refresh firmware">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16"
|
||||
height="16">
|
||||
<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>
|
||||
</div>
|
||||
<div class="firmware-content" id="firmware-container">
|
||||
<div class="firmware-overview">
|
||||
<div class="firmware-actions">
|
||||
<div class="action-group">
|
||||
<h3>🚀 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()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span class="file-info" id="file-info">No file selected</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>🚀 Deploy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="firmware-nodes-list" id="firmware-nodes-list">
|
||||
<!-- Nodes will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="monitoring-view" class="view-content">
|
||||
<div class="monitoring-view-section">
|
||||
<div class="monitoring-header">
|
||||
<h2>📡 Monitoring</h2>
|
||||
<button class="refresh-btn" id="refresh-monitoring-btn">
|
||||
<svg class="refresh-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="monitoring-content">
|
||||
<div class="cluster-summary" id="cluster-summary">
|
||||
<div class="loading">
|
||||
<div>Loading cluster resource summary...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nodes-monitoring" id="nodes-monitoring">
|
||||
<div class="loading">
|
||||
<div>Loading node resource data...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mock status indicator -->
|
||||
<div class="mock-status" id="mock-status">
|
||||
<div class="mock-status-content">
|
||||
<span class="mock-status-icon">🎭</span>
|
||||
<span class="mock-status-text">Mock Mode</span>
|
||||
<button class="btn-sm" id="mock-info-btn">Info</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mock server status indicator
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const mockStatus = document.getElementById('mock-status');
|
||||
const mockInfoBtn = document.getElementById('mock-info-btn');
|
||||
|
||||
mockInfoBtn.addEventListener('click', function() {
|
||||
alert('🎭 Mock\n\n' +
|
||||
'This UI is connected to the mock server on port 3002.\n' +
|
||||
'All data is simulated and updates automatically.\n\n' +
|
||||
'To switch to real server:\n' +
|
||||
'1. Start real server: npm start\n' +
|
||||
'2. Open: http://localhost:3001\n\n' +
|
||||
'To change mock configuration:\n' +
|
||||
'npm run mock:degraded\n' +
|
||||
'npm run mock:large\n' +
|
||||
'npm run mock:unstable');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mock-status {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(255, 193, 7, 0.9);
|
||||
color: #000;
|
||||
padding: 10px 15px;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mock-status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mock-status-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mock-status-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
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
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for UDP discovery
|
||||
* Sends CLUSTER_DISCOVERY messages to test the backend discovery functionality
|
||||
*/
|
||||
|
||||
const dgram = require('dgram');
|
||||
const client = dgram.createSocket('udp4');
|
||||
|
||||
const DISCOVERY_MESSAGE = 'CLUSTER_DISCOVERY';
|
||||
const TARGET_PORT = 4210;
|
||||
const BROADCAST_ADDRESS = '255.255.255.255';
|
||||
|
||||
// Enable broadcast
|
||||
client.setBroadcast(true);
|
||||
|
||||
function sendDiscoveryMessage() {
|
||||
const message = Buffer.from(DISCOVERY_MESSAGE);
|
||||
|
||||
client.send(message, 0, message.length, TARGET_PORT, BROADCAST_ADDRESS, (err) => {
|
||||
if (err) {
|
||||
console.error('Error sending discovery message:', err);
|
||||
} else {
|
||||
console.log(`Sent CLUSTER_DISCOVERY message to ${BROADCAST_ADDRESS}:${TARGET_PORT}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendDiscoveryToSpecificIP(ip) {
|
||||
const message = Buffer.from(DISCOVERY_MESSAGE);
|
||||
|
||||
client.send(message, 0, message.length, TARGET_PORT, ip, (err) => {
|
||||
if (err) {
|
||||
console.error(`Error sending discovery message to ${ip}:`, err);
|
||||
} else {
|
||||
console.log(`Sent CLUSTER_DISCOVERY message to ${ip}:${TARGET_PORT}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.log('Usage: node test-discovery.js [broadcast|ip] [count]');
|
||||
console.log(' broadcast: Send to broadcast address (default)');
|
||||
console.log(' ip: Send to specific IP address');
|
||||
console.log(' count: Number of messages to send (default: 1)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const target = args[0];
|
||||
const count = parseInt(args[1]) || 1;
|
||||
|
||||
console.log(`Sending ${count} discovery message(s) to ${target === 'broadcast' ? 'broadcast' : target}`);
|
||||
|
||||
if (target === 'broadcast') {
|
||||
for (let i = 0; i < count; i++) {
|
||||
setTimeout(() => {
|
||||
sendDiscoveryMessage();
|
||||
}, i * 1000); // Send one message per second
|
||||
}
|
||||
} else {
|
||||
// Assume it's an IP address
|
||||
for (let i = 0; i < count; i++) {
|
||||
setTimeout(() => {
|
||||
sendDiscoveryToSpecificIP(target);
|
||||
}, i * 1000); // Send one message per second
|
||||
}
|
||||
}
|
||||
|
||||
// Close the client after sending all messages
|
||||
setTimeout(() => {
|
||||
client.close();
|
||||
console.log('Test completed');
|
||||
}, (count + 1) * 1000);
|
||||
@@ -1,137 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Test script for Random Primary Node Selection
|
||||
* Demonstrates how the random selection works
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function makeRequest(path, method = 'POST', body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: path,
|
||||
method: method,
|
||||
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);
|
||||
});
|
||||
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function testRandomSelection() {
|
||||
console.log('🎲 Testing Random Primary Node Selection');
|
||||
console.log('======================================');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// First, check current discovery status
|
||||
console.log('1. Checking current discovery status...');
|
||||
const discoveryResponse = await makeRequest('/api/discovery/nodes', 'GET');
|
||||
|
||||
if (discoveryResponse.status !== 200) {
|
||||
console.log('❌ Failed to get discovery status');
|
||||
return;
|
||||
}
|
||||
|
||||
const discovery = discoveryResponse.data;
|
||||
console.log(` Current Primary: ${discovery.primaryNode || 'None'}`);
|
||||
console.log(` Total Nodes: ${discovery.totalNodes}`);
|
||||
console.log(` Client Initialized: ${discovery.clientInitialized}`);
|
||||
|
||||
if (discovery.nodes.length === 0) {
|
||||
console.log('\n💡 No nodes discovered yet. Send some discovery messages first:');
|
||||
console.log(' npm run test-discovery broadcast');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n2. Testing random primary node selection...');
|
||||
|
||||
// Store current primary for comparison
|
||||
const currentPrimary = discovery.primaryNode;
|
||||
const availableNodes = discovery.nodes.map(n => n.ip);
|
||||
|
||||
console.log(` Available nodes: ${availableNodes.join(', ')}`);
|
||||
console.log(` Current primary: ${currentPrimary}`);
|
||||
|
||||
// Perform random selection
|
||||
const randomResponse = await makeRequest('/api/discovery/random-primary', 'POST', {
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (randomResponse.status === 200) {
|
||||
const result = randomResponse.data;
|
||||
console.log('\n✅ Random selection successful!');
|
||||
console.log(` New Primary: ${result.primaryNode}`);
|
||||
console.log(` Previous Primary: ${currentPrimary}`);
|
||||
console.log(` Message: ${result.message}`);
|
||||
console.log(` Total Nodes: ${result.totalNodes}`);
|
||||
console.log(` Client Initialized: ${result.clientInitialized}`);
|
||||
|
||||
// Verify the change
|
||||
if (result.primaryNode !== currentPrimary) {
|
||||
console.log('\n🎯 Primary node successfully changed!');
|
||||
} else {
|
||||
console.log('\n⚠️ Primary node remained the same (only one node available)');
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('\n❌ Random selection failed:');
|
||||
console.log(` Status: ${randomResponse.status}`);
|
||||
console.log(` Error: ${randomResponse.data.error || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Show updated status
|
||||
console.log('\n3. Checking updated discovery status...');
|
||||
const updatedResponse = await makeRequest('/api/discovery/nodes', 'GET');
|
||||
if (updatedResponse.status === 200) {
|
||||
const updated = updatedResponse.data;
|
||||
console.log(` Current Primary: ${updated.primaryNode}`);
|
||||
console.log(` Client Base URL: ${updated.clientBaseUrl}`);
|
||||
}
|
||||
|
||||
console.log('\n💡 To test in the frontend:');
|
||||
console.log(' 1. Open http://localhost:3001 in your browser');
|
||||
console.log(' 2. Look at the cluster header for primary node info');
|
||||
console.log(' 3. Click the 🎲 button to randomly select a new primary node');
|
||||
console.log(' 4. Watch the display change in real-time');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:', error.message);
|
||||
console.log('\n💡 Make sure the backend is running: npm start');
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testRandomSelection().catch(console.error);
|
||||