Compare commits
2 Commits
main
...
be502a5e17
| Author | SHA1 | Date | |
|---|---|---|---|
| be502a5e17 | |||
| 918c019dd5 |
@@ -1,56 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
.git
|
||||
.gitignore
|
||||
.cursor
|
||||
*.md
|
||||
node_modules
|
||||
README.md
|
||||
docs
|
||||
test
|
||||
openapitools.json
|
||||
*.backup
|
||||
|
||||
41
Dockerfile
@@ -1,41 +0,0 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Runtime stage
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install wget for health checks
|
||||
RUN apk --no-cache add wget
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from builder
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
# Copy application files
|
||||
COPY package*.json ./
|
||||
COPY index.js index-standalone.js ./
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
# Create non-root user (let Alpine assign available GID/UID)
|
||||
RUN addgroup spore && \
|
||||
adduser -D -s /bin/sh -G spore spore && \
|
||||
chown -R spore:spore /app
|
||||
|
||||
USER spore
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Run the application
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
54
Makefile
@@ -1,54 +0,0 @@
|
||||
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
npm install
|
||||
|
||||
# Build the application (if needed)
|
||||
build: install
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
node index.js
|
||||
|
||||
# Start in development mode
|
||||
dev:
|
||||
node index.js
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf node_modules
|
||||
rm -f package-lock.json
|
||||
|
||||
# Docker variables
|
||||
DOCKER_REGISTRY ?=
|
||||
IMAGE_NAME = wirelos/spore-ui
|
||||
IMAGE_TAG ?= latest
|
||||
FULL_IMAGE_NAME = $(if $(DOCKER_REGISTRY),$(DOCKER_REGISTRY)/$(IMAGE_NAME),$(IMAGE_NAME)):$(IMAGE_TAG)
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
docker build -t $(FULL_IMAGE_NAME) .
|
||||
|
||||
# Run Docker container
|
||||
docker-run:
|
||||
docker run -p 3000:3000 --rm $(FULL_IMAGE_NAME)
|
||||
|
||||
# Push Docker image
|
||||
docker-push:
|
||||
docker push $(FULL_IMAGE_NAME)
|
||||
|
||||
# Build multiarch Docker image
|
||||
docker-build-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
# Push multiarch Docker image (if not pushed during build)
|
||||
docker-push-multiarch:
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t $(FULL_IMAGE_NAME) \
|
||||
--push \
|
||||
.
|
||||
|
||||
272
README.md
@@ -1,117 +1,243 @@
|
||||
# SPORE UI Frontend
|
||||
# SPORE UI
|
||||
|
||||
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
|
||||
Zero-configuration web interface for monitoring and managing SPORE embedded systems.
|
||||
|
||||
## Features
|
||||
|
||||
- **🌐 Cluster Monitoring**: Real-time view of all cluster members via spore-gateway
|
||||
- **🌐 Cluster Monitoring**: Real-time view of all cluster members with auto-discovery
|
||||
- **📊 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
|
||||
- **💾 State Preservation**: Advanced UI state management that preserves user interactions during data refreshes
|
||||
- **⚡ Smart Updates**: Efficient partial updates that only refresh changed data, not entire components
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 🆕 **State Preservation System**
|
||||
- **Expanded Cards**: Cluster member cards stay expanded during data refreshes
|
||||
- **Active Tabs**: Selected tabs in node detail views are maintained
|
||||
- **User Context**: All user interactions are preserved across data updates
|
||||
- **No More UI Resets**: Users never lose their place in the interface
|
||||
|
||||
### 🚀 **Performance Enhancements**
|
||||
- **Partial Updates**: Only changed data triggers UI updates
|
||||
- **Smart Change Detection**: System automatically detects when data has actually changed
|
||||
- **Efficient Rendering**: Reduced DOM manipulation and improved responsiveness
|
||||
- **Batch Operations**: Multiple property updates are handled efficiently
|
||||
|
||||
## 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 spore-gateway**: `./spore-gateway` (in the spore-gateway directory)
|
||||
3. **Start frontend server**: `npm start`
|
||||
2. **Start the server**: `npm start`
|
||||
3. **Open in browser**: `http://localhost:3001`
|
||||
4. **Test state preservation**: `http://localhost:3001/test-state-preservation.html`
|
||||
|
||||
### Access
|
||||
- **Frontend UI**: `http://localhost:3000`
|
||||
- **API Backend**: spore-gateway runs on port 3001
|
||||
- **WebSocket**: Connects to spore-gateway on port 3001
|
||||
## Testing State Preservation
|
||||
|
||||
## API Integration
|
||||
The framework includes a comprehensive test interface to demonstrate state preservation:
|
||||
|
||||
The frontend automatically connects to the spore-gateway for:
|
||||
1. **Expand cluster member cards** to see detailed information
|
||||
2. **Change active tabs** in node detail views
|
||||
3. **Trigger data refresh** using the refresh buttons
|
||||
4. **Verify that all UI state is preserved** during updates
|
||||
|
||||
- **Cluster Discovery**: `/api/discovery/*` endpoints
|
||||
- **Node Management**: `/api/node/*` endpoints
|
||||
- **Task Monitoring**: `/api/tasks/*` endpoints
|
||||
- **Real-time Updates**: WebSocket connections via `/ws`
|
||||
## API Endpoints
|
||||
|
||||
- **`/`** - 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
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Backend Integration**: Express.js server connecting to spore-gateway
|
||||
- **Backend**: Express.js, Node.js
|
||||
- **Frontend**: Vanilla JavaScript, CSS3, HTML5
|
||||
- **Framework**: Custom component-based architecture
|
||||
- **API**: SPORE Embedded System API via spore-gateway
|
||||
- **Framework**: Custom component-based architecture with state preservation
|
||||
- **API**: SPORE Embedded System API
|
||||
- **Design**: Glassmorphism, CSS Grid, Flexbox
|
||||
|
||||
## Development
|
||||
## Architecture Highlights
|
||||
|
||||
### 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
|
||||
```
|
||||
### 🏗️ **Component Framework**
|
||||
- **Base Component Class**: Provides state management and partial update capabilities
|
||||
- **ViewModel Pattern**: Separates data logic from UI logic
|
||||
- **Event-Driven Updates**: Efficient pub/sub system for component communication
|
||||
- **State Persistence**: Automatic preservation of UI state across data refreshes
|
||||
|
||||
### 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
|
||||
### 🔄 **Smart Update System**
|
||||
- **Change Detection**: Automatically identifies when data has actually changed
|
||||
- **Partial Rendering**: Updates only the necessary parts of the UI
|
||||
- **State Preservation**: Maintains user interactions during all updates
|
||||
- **Performance Optimization**: Minimizes unnecessary DOM operations
|
||||
|
||||
## Troubleshooting
|
||||
## UDP Auto Discovery
|
||||
|
||||
### Common Issues
|
||||
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.
|
||||
|
||||
**Frontend not connecting to gateway**
|
||||
### 🚀 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
|
||||
# Check if spore-gateway is running
|
||||
curl http://localhost:3001/api/health
|
||||
# Start the backend server
|
||||
npm start
|
||||
|
||||
# Verify gateway health
|
||||
# Should return gateway health status
|
||||
# The server will automatically:
|
||||
# - Start HTTP server on port 3001
|
||||
# - Start UDP discovery server on port 4210
|
||||
# - Wait for CLUSTER_DISCOVERY messages
|
||||
```
|
||||
|
||||
**WebSocket connection issues**
|
||||
#### Node Configuration
|
||||
SPORE nodes should send discovery messages periodically:
|
||||
```bash
|
||||
# Check WebSocket endpoint
|
||||
curl http://localhost:3001/api/test/websocket
|
||||
|
||||
# Verify gateway WebSocket server is running
|
||||
# Recommended: Send every 30-60 seconds
|
||||
# Message format: "CLUSTER_DISCOVERY"
|
||||
# Target: 255.255.255.255:4210
|
||||
```
|
||||
|
||||
**No cluster data**
|
||||
### 🌐 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
|
||||
# Check gateway discovery status
|
||||
# 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
|
||||
|
||||
# Verify SPORE nodes are sending heartbeat messages
|
||||
# Check health
|
||||
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
|
||||
```
|
||||
|
||||
## Architecture Benefits
|
||||
### 🔍 Troubleshooting
|
||||
|
||||
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
|
||||
#### Common Issues
|
||||
|
||||
**No Nodes Discovered**
|
||||
```bash
|
||||
# Check if backend is running
|
||||
curl http://localhost:3001/api/health
|
||||
|
||||
# Verify UDP port is open
|
||||
netstat -tulpn | grep 4210
|
||||
|
||||
# Send test discovery message
|
||||
npm run test-discovery broadcast
|
||||
```
|
||||
|
||||
**UDP Port Already in Use**
|
||||
```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
|
||||
curl http://localhost:3001/api/discovery/nodes
|
||||
|
||||
# Verify nodes are sending discovery messages
|
||||
# Check network connectivity
|
||||
```
|
||||
|
||||
#### Debug Commands
|
||||
```bash
|
||||
# Check discovery status
|
||||
curl http://localhost:3001/api/discovery/nodes
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 458 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 453 KiB |
|
Before Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 455 KiB |
|
Before 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_HEARTBEAT` (configurable via `HEARTBEAT_MESSAGE` constant)
|
||||
- **Message**: `CLUSTER_DISCOVERY` (configurable via `DISCOVERY_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_HEARTBEAT` messages
|
||||
- **Automatic Discovery**: Nodes are discovered when they send `CLUSTER_DISCOVERY` 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_HEARTBEAT messages
|
||||
3. System waits for CLUSTER_DISCOVERY 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_HEARTBEAT:hostname" to 255.255.255.255:4210
|
||||
1. Node sends "CLUSTER_DISCOVERY" 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 heartbeat port (default: 4210)
|
||||
- `UDP_PORT`: UDP discovery port (default: 4210)
|
||||
|
||||
### Constants (in index.js)
|
||||
- `UDP_PORT`: Heartbeat port (currently 4210)
|
||||
- `HEARTBEAT_MESSAGE`: Expected message (currently "CLUSTER_HEARTBEAT")
|
||||
- `UDP_PORT`: Discovery port (currently 4210)
|
||||
- `DISCOVERY_MESSAGE`: Expected message (currently "CLUSTER_DISCOVERY")
|
||||
- Stale timeout: 5 minutes (configurable in `cleanupStaleNodes()`)
|
||||
- Health check interval: 5 seconds (configurable in `setInterval`)
|
||||
|
||||
|
||||
518
docs/Events.md
@@ -1,518 +0,0 @@
|
||||
# Events Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
### Real-Time Event Visualization
|
||||
- **Interactive Graph**: Events are visualized as a force-directed graph using D3.js
|
||||
- **Topic Chain Visualization**: Multi-part topics (e.g., `cluster/event/api/neopattern`) are broken down into chains showing hierarchical relationships
|
||||
- **Event Counting**: Each event type is tracked with occurrence counts displayed on connections
|
||||
- **Animated Transitions**: New events trigger visual animations showing data flow through the graph
|
||||
|
||||
### User Interactions
|
||||
|
||||
#### Drag to Reposition Nodes
|
||||
- **Center Node**: Dragging the center (blue) node moves the entire graph together
|
||||
- **Topic Nodes**: Individual topic nodes (green) can be repositioned independently
|
||||
- **Fixed Positioning**: Once dragged, nodes maintain their positions to prevent layout disruption
|
||||
|
||||
#### Zoom Controls
|
||||
- **Mouse Wheel**: Zoom in and out using the mouse wheel
|
||||
- **Scale Range**: Zoom level constrained between 0.5x and 5x
|
||||
- **Pan**: Click and drag the canvas to pan around the graph
|
||||
|
||||
#### Rearrange Layout
|
||||
- **Rearrange Button**: Click the button in the top-left corner to reset node positions
|
||||
- **Automatic Layout**: Clears fixed positions and lets the force simulation reposition nodes
|
||||
|
||||
### Visual Elements
|
||||
|
||||
#### Node Types
|
||||
- **Center Node** (Blue):
|
||||
- Represents the central event hub
|
||||
- Always visible in the graph
|
||||
- Acts as the root node for all event chains
|
||||
- Larger size (18px radius)
|
||||
|
||||
- **Topic Nodes** (Green):
|
||||
- Represent individual topic segments
|
||||
- Examples: `cluster`, `event`, `api`, `neopattern`
|
||||
- Smaller size (14px radius)
|
||||
- Positioned dynamically based on event frequency
|
||||
|
||||
#### Link Types
|
||||
- **Center Links** (Blue):
|
||||
- Connect the center node to root topic nodes
|
||||
- Thicker lines (2.5px)
|
||||
- Examples: center → `cluster`, center → `event`
|
||||
|
||||
- **Topic Links** (Green):
|
||||
- Connect adjacent topics in event chains
|
||||
- Line thickness proportional to event frequency
|
||||
- Examples: `cluster` → `event`, `api` → `neopattern`
|
||||
|
||||
#### Hover Interactions
|
||||
- **Link Hover**: Links become thicker and more opaque when hovered
|
||||
- **Event Frequency**: Line thickness dynamically adjusts based on event occurrence counts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Component Structure
|
||||
|
||||
```12:37:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Events Component - Visualizes websocket events as a graph
|
||||
class EventComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
|
||||
this.svg = null;
|
||||
this.simulation = null;
|
||||
this.zoom = null;
|
||||
this.width = 0;
|
||||
this.height = 0;
|
||||
this.isInitialized = false;
|
||||
|
||||
// Center node data - will be initialized with proper coordinates later
|
||||
this.centerNode = { id: 'center', type: 'center', label: '', x: 0, y: 0 };
|
||||
|
||||
// Track nodes for D3
|
||||
this.graphNodes = [];
|
||||
this.graphLinks = [];
|
||||
|
||||
// Track recent events to trigger animations
|
||||
this.lastSeenEvents = new Set();
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel
|
||||
|
||||
The `EventViewModel` manages the state and WebSocket connections:
|
||||
|
||||
```1241:1253:spore-ui/public/scripts/view-models.js
|
||||
// Events View Model for websocket event visualization
|
||||
class EventViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
events: new Map(), // Map of topic -> { parts: [], count: number, lastSeen: timestamp }
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null
|
||||
});
|
||||
|
||||
// Set up WebSocket listeners for real-time updates
|
||||
this.setupWebSocketListeners();
|
||||
}
|
||||
```
|
||||
|
||||
### Event Tracking
|
||||
|
||||
Events are tracked with the following metadata:
|
||||
- **Topic**: The full event topic path (e.g., `cluster/event/api/neopattern`)
|
||||
- **Parts**: Array of topic segments split by `/`
|
||||
- **Count**: Total occurrences of this event
|
||||
- **First Seen**: ISO timestamp of first occurrence
|
||||
- **Last Seen**: ISO timestamp of most recent occurrence
|
||||
- **Last Data**: The most recent event payload
|
||||
|
||||
### Event Addition Logic
|
||||
|
||||
The view model handles nested events specially:
|
||||
|
||||
```1283:1329:spore-ui/public/scripts/view-models.js
|
||||
// Add a topic (parsed by "/" separator)
|
||||
addTopic(topic, data = null) {
|
||||
// Get current events as a new Map to ensure change detection
|
||||
const events = new Map(this.get('events'));
|
||||
|
||||
// Handle nested events from cluster/event
|
||||
let fullTopic = topic;
|
||||
if (topic === 'cluster/event' && data && data.data) {
|
||||
try {
|
||||
const parsedData = typeof data.data === 'string' ? JSON.parse(data.data) : data.data;
|
||||
if (parsedData && parsedData.event) {
|
||||
// Create nested topic chain: cluster/event/api/neopattern
|
||||
fullTopic = `${topic}/${parsedData.event}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, just use the original topic
|
||||
}
|
||||
}
|
||||
|
||||
const parts = fullTopic.split('/').filter(p => p);
|
||||
|
||||
if (events.has(fullTopic)) {
|
||||
// Update existing event - create new object to ensure change detection
|
||||
const existing = events.get(fullTopic);
|
||||
events.set(fullTopic, {
|
||||
topic: existing.topic,
|
||||
parts: existing.parts,
|
||||
count: existing.count + 1,
|
||||
firstSeen: existing.firstSeen,
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
} else {
|
||||
// Create new event entry
|
||||
events.set(fullTopic, {
|
||||
topic: fullTopic,
|
||||
parts: parts,
|
||||
count: 1,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
lastData: data
|
||||
});
|
||||
}
|
||||
|
||||
// Use set to trigger change notification
|
||||
this.set('events', events);
|
||||
this.set('lastUpdateTime', new Date().toISOString());
|
||||
}
|
||||
```
|
||||
|
||||
## Graph Construction
|
||||
|
||||
### Node Creation
|
||||
|
||||
The graph construction process follows two passes:
|
||||
|
||||
1. **First Pass - Node Creation**:
|
||||
- Creates nodes for each unique topic segment
|
||||
- Preserves existing node positions if they've been dragged
|
||||
- New nodes are positioned near their parent in the hierarchy
|
||||
|
||||
2. **Second Pass - Event Counting**:
|
||||
- Counts occurrences of each topic segment
|
||||
- Updates node counts to reflect event frequency
|
||||
|
||||
### Link Construction
|
||||
|
||||
Links are created as chains representing complete topic paths:
|
||||
|
||||
```283:342:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Build links as chains for each topic
|
||||
// For "cluster/update", create: center -> cluster -> update
|
||||
this.graphLinks = [];
|
||||
const linkSet = new Set(); // Track links to avoid duplicates
|
||||
|
||||
if (events && events.size > 0) {
|
||||
for (const [topic, data] of events) {
|
||||
const parts = data.parts;
|
||||
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
// Connect center to first part
|
||||
const firstPart = parts[0];
|
||||
const centerToFirst = `center-${firstPart}`;
|
||||
if (!linkSet.has(centerToFirst)) {
|
||||
this.graphLinks.push({
|
||||
source: 'center',
|
||||
target: firstPart,
|
||||
type: 'center-link',
|
||||
count: data.count,
|
||||
topic: topic
|
||||
});
|
||||
linkSet.add(centerToFirst);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === centerToFirst
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect each part to the next (creating a chain)
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const source = parts[i];
|
||||
const target = parts[i + 1];
|
||||
const linkKey = `${source}-${target}`;
|
||||
|
||||
if (!linkSet.has(linkKey)) {
|
||||
this.graphLinks.push({
|
||||
source: source,
|
||||
target: target,
|
||||
type: 'topic-link',
|
||||
topic: topic,
|
||||
count: data.count
|
||||
});
|
||||
linkSet.add(linkKey);
|
||||
} else {
|
||||
// Update count for existing link
|
||||
const existingLink = this.graphLinks.find(l =>
|
||||
`${l.source}-${l.target}` === linkKey
|
||||
);
|
||||
if (existingLink) {
|
||||
existingLink.count += data.count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Force Simulation
|
||||
|
||||
The D3.js force simulation provides the physics-based layout:
|
||||
|
||||
```103:111:spore-ui/public/scripts/components/EventComponent.js
|
||||
// Initialize simulation
|
||||
this.simulation = d3.forceSimulation()
|
||||
.force('link', d3.forceLink().id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(this.width / 2, this.height / 2))
|
||||
.force('collision', d3.forceCollide().radius(35))
|
||||
.alphaDecay(0.0228) // Slower decay to allow simulation to run longer
|
||||
.velocityDecay(0.4); // Higher velocity decay for smoother, less jumpy movement
|
||||
```
|
||||
|
||||
### Force Parameters
|
||||
- **Link Force**: Maintains 100px distance between connected nodes
|
||||
- **Charge Force**: -300 strength creates repulsion between nodes
|
||||
- **Center Force**: Keeps the graph centered in the viewport
|
||||
- **Collision Detection**: Prevents nodes from overlapping (35px collision radius)
|
||||
- **Decay Rate**: Slow decay (0.0228) keeps the simulation running smoothly
|
||||
- **Velocity Decay**: High decay (0.4) prevents excessive movement
|
||||
|
||||
## Animation System
|
||||
|
||||
### Event Arrival Animation
|
||||
|
||||
When a new event arrives, a golden animation dot travels along the event chain:
|
||||
|
||||
```725:809:spore-ui/public/scripts/components/EventComponent.js
|
||||
animateEventMessage(topic, data) {
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
const parts = data.parts || topic.split('/').filter(p => p);
|
||||
if (parts.length === 0) return;
|
||||
|
||||
// Wait for the next tick to ensure nodes exist
|
||||
setTimeout(() => {
|
||||
// Build the chain: center -> first -> second -> ... -> last
|
||||
const chain = ['center', ...parts];
|
||||
|
||||
// Animate along each segment of the chain
|
||||
let delay = 0;
|
||||
for (let i = 0; i < chain.length - 1; i++) {
|
||||
const sourceId = chain[i];
|
||||
const targetId = chain[i + 1];
|
||||
|
||||
const sourceNode = this.graphNodes.find(n => n.id === sourceId);
|
||||
const targetNode = this.graphNodes.find(n => n.id === targetId);
|
||||
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
setTimeout(() => {
|
||||
this.animateDotAlongLink(sourceNode, targetNode, g);
|
||||
}, delay);
|
||||
|
||||
// Add delay between segments (staggered animation)
|
||||
delay += 400; // Duration (300ms) + small gap (100ms)
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
animateDotAlongLink(sourceNode, targetNode, g) {
|
||||
if (!sourceNode || !targetNode || !g) return;
|
||||
|
||||
// Calculate positions
|
||||
const dx = targetNode.x - sourceNode.x;
|
||||
const dy = targetNode.y - sourceNode.y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (length === 0) return;
|
||||
|
||||
// Normalize direction
|
||||
const nx = dx / length;
|
||||
const ny = dy / length;
|
||||
|
||||
// Calculate node radii
|
||||
const sourceRadius = sourceNode.type === 'center' ? 18 : 14;
|
||||
const targetRadius = targetNode.type === 'center' ? 18 : 14;
|
||||
|
||||
// Start from edge of source node
|
||||
const startX = sourceNode.x + nx * sourceRadius;
|
||||
const startY = sourceNode.y + ny * sourceRadius;
|
||||
|
||||
// End at edge of target node
|
||||
const endX = targetNode.x - nx * targetRadius;
|
||||
const endY = targetNode.y - ny * targetRadius;
|
||||
|
||||
// Create animation dot
|
||||
const animationGroup = g.append('g').attr('class', 'animation-group');
|
||||
|
||||
const dot = animationGroup.append('circle')
|
||||
.attr('r', 4)
|
||||
.attr('fill', '#FFD700')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2)
|
||||
.attr('cx', startX)
|
||||
.attr('cy', startY);
|
||||
|
||||
// Animate the dot along the path
|
||||
dot.transition()
|
||||
.duration(300)
|
||||
.ease(d3.easeLinear)
|
||||
.attr('cx', endX)
|
||||
.attr('cy', endY)
|
||||
.on('end', function() {
|
||||
// Fade out after reaching destination
|
||||
dot.transition()
|
||||
.duration(100)
|
||||
.style('opacity', 0)
|
||||
.remove();
|
||||
animationGroup.remove();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Animation Characteristics
|
||||
- **Golden Dot**: Visual indicator traveling along event chains
|
||||
- **Staggered Timing**: Each segment of a multi-part topic animates sequentially
|
||||
- **Duration**: 300ms per segment with 100ms delay between segments
|
||||
- **Smooth Motion**: Linear easing for consistent speed
|
||||
|
||||
## WebSocket Integration
|
||||
|
||||
The Events view subscribes to all WebSocket messages through the gateway:
|
||||
|
||||
```1256:1280:spore-ui/public/scripts/view-models.js
|
||||
// Set up WebSocket event listeners
|
||||
setupWebSocketListeners() {
|
||||
if (!window.wsClient) {
|
||||
// Retry after a short delay to allow wsClient to initialize
|
||||
setTimeout(() => this.setupWebSocketListeners(), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for all websocket messages
|
||||
window.wsClient.on('message', (data) => {
|
||||
const topic = data.topic || data.type;
|
||||
|
||||
if (topic) {
|
||||
this.addTopic(topic, data);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for connection status changes
|
||||
window.wsClient.on('connected', () => {
|
||||
logger.info('EventViewModel: WebSocket connected');
|
||||
});
|
||||
|
||||
window.wsClient.on('disconnected', () => {
|
||||
logger.debug('EventViewModel: WebSocket disconnected');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Event Types Tracked
|
||||
|
||||
All WebSocket messages are captured and displayed, including:
|
||||
- **cluster/update**: Node discovery and cluster membership changes
|
||||
- **cluster/event**: Arbitrary events from cluster members
|
||||
- **api/tasks**: Task execution notifications
|
||||
- **api/config**: Configuration updates
|
||||
- **node/***: Node-specific events
|
||||
- Any custom topics sent by SPORE nodes
|
||||
|
||||
## Empty State
|
||||
|
||||
When no events have been received, the view displays a helpful message:
|
||||
|
||||
```604:632:spore-ui/public/scripts/components/EventComponent.js
|
||||
showEmptyState() {
|
||||
if (!this.isInitialized || !this.svg) return;
|
||||
|
||||
const g = this.svg.select('g');
|
||||
if (g.empty()) return;
|
||||
|
||||
// Remove any existing message
|
||||
g.selectAll('.empty-state').remove();
|
||||
|
||||
// Account for the initial zoom transform (scale(1.4) translate(-200, -150))
|
||||
// We need to place the text in the center of the transformed coordinate space
|
||||
const transformX = -200;
|
||||
const transformY = -150;
|
||||
const scale = 1.4;
|
||||
|
||||
// Calculate centered position in transformed space
|
||||
const x = ((this.width / 2) - transformX) / scale;
|
||||
const y = ((this.height / 2) - transformY) / scale;
|
||||
|
||||
const emptyMsg = g.append('text')
|
||||
.attr('class', 'empty-state')
|
||||
.attr('x', x)
|
||||
.attr('y', y)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('font-size', '18px')
|
||||
.attr('fill', 'var(--text-secondary)')
|
||||
.attr('font-weight', '500')
|
||||
.text('Waiting for websocket events...');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Performance Considerations
|
||||
1. **Event Accumulation**: Events accumulate over time; consider clearing the view for long-running sessions
|
||||
2. **Many Events**: With hundreds of unique topics, the graph may become cluttered
|
||||
3. **Animation**: Rapid event bursts may cause overlapping animations
|
||||
|
||||
### Usage Tips
|
||||
1. **Rearrange Regularly**: Use the rearrange button to clean up the layout after many events
|
||||
2. **Drag Center Node**: Move the entire graph to see different areas of the visualization
|
||||
3. **Zoom Out**: Zoom out to see the overall event pattern across the cluster
|
||||
4. **Interactive Exploration**: Hover over links to see event frequencies
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Events Showing
|
||||
- **Check WebSocket Connection**: Verify the gateway WebSocket is connected
|
||||
- **Check Node Activity**: Ensure SPORE nodes are actively sending events
|
||||
- **Browser Console**: Check for any JavaScript errors
|
||||
|
||||
### Graph Not Updating
|
||||
- **Force Refresh**: Reload the page to reinitialize the WebSocket connection
|
||||
- **Check Gateway**: Verify the SPORE Gateway is running and accessible
|
||||
|
||||
### Performance Issues
|
||||
- **Clear Events**: Reload the page to clear accumulated events
|
||||
- **Reduce Events**: Limit the event rate in your SPORE nodes
|
||||
- **Browser Settings**: Ensure hardware acceleration is enabled
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Dependencies
|
||||
- **D3.js v7**: Force-directed graph simulation and SVG manipulation
|
||||
- **SPORE Gateway**: WebSocket connection to cluster event stream
|
||||
- **Component Framework**: Built on the SPORE UI component architecture
|
||||
|
||||
### Browser Compatibility
|
||||
- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions)
|
||||
- **SVG Support**: Requires modern browser with full SVG support
|
||||
- **WebSocket Support**: Native WebSocket API required
|
||||
|
||||
### View Structure
|
||||
|
||||
```254:262:spore-ui/public/index.html
|
||||
<div id="events-view" class="view-content">
|
||||
<div class="view-section" style="height: 100%; display: flex; flex-direction: column;">
|
||||
<div id="events-graph-container" style="flex: 1; min-height: 0;">
|
||||
<div class="loading">
|
||||
<div>Waiting for websocket events...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The Events view provides a unique perspective on cluster activity, making it easy to observe the flow of events through the SPORE system in real-time.
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,69 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,78 +0,0 @@
|
||||
# SPORE UI Documentation
|
||||
|
||||
This directory contains detailed documentation for the SPORE UI frontend application.
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Documentation
|
||||
|
||||
- **[Framework](./FRAMEWORK_README.md)**: Component-based architecture, View Models, Event Bus, and framework conventions
|
||||
- **[Discovery](./DISCOVERY.md)**: Node discovery system and cluster management
|
||||
- **[Topology WebSocket Update](./TOPOLOGY_WEBSOCKET_UPDATE.md)**: Real-time topology visualization updates
|
||||
- **[Logging](./LOGGING.md)**: Logging system and debugging utilities
|
||||
|
||||
### Feature Documentation
|
||||
|
||||
- **[Events](./Events.md)**: Real-time WebSocket event visualization with interactive force-directed graph
|
||||
- **[Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md)**: Integration with the firmware registry system
|
||||
|
||||
## Feature Overview
|
||||
|
||||
### Events Feature
|
||||
The Events feature provides real-time visualization of WebSocket events streaming through the SPORE cluster. It displays events as an interactive force-directed graph, showing the flow and relationships between different event topics.
|
||||
|
||||
**Key Capabilities:**
|
||||
- Real-time event tracking and visualization
|
||||
- Interactive force-directed graph layout
|
||||
- Event counting and frequency visualization
|
||||
- Animated event flow visualization
|
||||
- Topic hierarchy display
|
||||
|
||||
See [Events.md](./Events.md) for complete documentation.
|
||||
|
||||
### Framework Architecture
|
||||
The SPORE UI uses a component-based architecture with:
|
||||
- View Models for state management
|
||||
- Components for UI rendering
|
||||
- Event Bus for pub/sub communication
|
||||
- API Client for backend integration
|
||||
|
||||
See [FRAMEWORK_README.md](./FRAMEWORK_README.md) for architecture details.
|
||||
|
||||
### Discovery System
|
||||
The discovery system enables automatic detection of SPORE nodes on the network, maintaining a real-time view of cluster members.
|
||||
|
||||
See [DISCOVERY.md](./DISCOVERY.md) for implementation details.
|
||||
|
||||
### Topology Visualization
|
||||
The topology view provides an interactive network graph showing relationships between cluster nodes in real-time.
|
||||
|
||||
See [TOPOLOGY_WEBSOCKET_UPDATE.md](./TOPOLOGY_WEBSOCKET_UPDATE.md) for detailed documentation.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Most Popular Pages
|
||||
1. [Framework Architecture](./FRAMEWORK_README.md) - Start here for understanding the codebase
|
||||
2. [Events Feature](./Events.md) - Real-time event visualization
|
||||
3. [Firmware Registry Integration](./FIRMWARE_REGISTRY_INTEGRATION.md) - Firmware management
|
||||
|
||||
### Developer Resources
|
||||
- Component development: See [FRAMEWORK_README.md](./FRAMEWORK_README.md)
|
||||
- Debugging: See [LOGGING.md](./LOGGING.md)
|
||||
- Testing: See individual component documentation
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features or documentation:
|
||||
1. Follow the existing documentation structure
|
||||
2. Include code examples and diagrams where helpful
|
||||
3. Update this README when adding new documentation files
|
||||
4. Maintain consistency with existing documentation style
|
||||
|
||||
## Related Documentation
|
||||
|
||||
For information about other SPORE components:
|
||||
- **SPORE Gateway**: See `spore-gateway/README.md`
|
||||
- **SPORE Registry**: See `spore-registry/README.md`
|
||||
- **SPORE Embedded**: See `spore/README.md`
|
||||
|
||||
266
docs/STATE_PRESERVATION.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# SPORE UI State Preservation System
|
||||
|
||||
## Overview
|
||||
|
||||
The SPORE UI framework now includes an advanced state preservation system that prevents UI state loss during data refreshes. This system ensures that user interactions like expanded cards, active tabs, and other UI state are maintained when data is updated from the server.
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **UI State Persistence**
|
||||
- **Expanded Cards**: When cluster member cards are expanded, their state is preserved across data refreshes
|
||||
- **Active Tabs**: Active tab selections within node detail views are maintained
|
||||
- **User Interactions**: All user-initiated UI changes are stored and restored automatically
|
||||
|
||||
### 2. **Smart Data Updates**
|
||||
- **Change Detection**: The system detects when data has actually changed and only updates what's necessary
|
||||
- **Partial Updates**: Components can update specific data without re-rendering the entire UI
|
||||
- **State Preservation**: UI state is automatically preserved during all data operations
|
||||
|
||||
### 3. **Efficient Rendering**
|
||||
- **No Full Re-renders**: Components avoid unnecessary full re-renders when only data changes
|
||||
- **Granular Updates**: Only changed properties trigger UI updates
|
||||
- **Performance Optimization**: Reduced DOM manipulation and improved user experience
|
||||
|
||||
## Architecture
|
||||
|
||||
### Enhanced ViewModel Class
|
||||
|
||||
The base `ViewModel` class now includes:
|
||||
|
||||
```javascript
|
||||
class ViewModel {
|
||||
// UI State Management
|
||||
setUIState(key, value) // Store UI state
|
||||
getUIState(key) // Retrieve UI state
|
||||
getAllUIState() // Get all stored UI state
|
||||
clearUIState(key) // Clear specific or all UI state
|
||||
|
||||
// Change Detection
|
||||
hasChanged(property) // Check if property changed
|
||||
getPrevious(property) // Get previous value
|
||||
|
||||
// Batch Updates
|
||||
batchUpdate(updates, options) // Update multiple properties with state preservation
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Component Class
|
||||
|
||||
The base `Component` class now includes:
|
||||
|
||||
```javascript
|
||||
class Component {
|
||||
// UI State Management
|
||||
setUIState(key, value) // Store local UI state
|
||||
getUIState(key) // Get local or view model state
|
||||
getAllUIState() // Get merged state
|
||||
restoreUIState() // Restore state from view model
|
||||
|
||||
// Partial Updates
|
||||
updatePartial(property, newValue, previousValue) // Handle partial updates
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### 1. **Cluster Members Component**
|
||||
|
||||
The `ClusterMembersComponent` demonstrates state preservation:
|
||||
|
||||
```javascript
|
||||
class ClusterMembersComponent extends Component {
|
||||
setupViewModelListeners() {
|
||||
// Listen with change detection
|
||||
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
|
||||
}
|
||||
|
||||
handleMembersUpdate(newMembers, previousMembers) {
|
||||
if (this.shouldPreserveState(newMembers, previousMembers)) {
|
||||
// Partial update preserves UI state
|
||||
this.updateMembersPartially(newMembers, previousMembers);
|
||||
} else {
|
||||
// Full re-render only when necessary
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
shouldPreserveState(newMembers, previousMembers) {
|
||||
// Check if member structure allows state preservation
|
||||
if (newMembers.length !== previousMembers.length) return false;
|
||||
|
||||
const newIps = new Set(newMembers.map(m => m.ip));
|
||||
const prevIps = new Set(previousMembers.map(m => m.ip));
|
||||
|
||||
return newIps.size === prevIps.size &&
|
||||
[...newIps].every(ip => prevIps.has(ip));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. **Node Details Component**
|
||||
|
||||
The `NodeDetailsComponent` preserves active tab state:
|
||||
|
||||
```javascript
|
||||
class NodeDetailsComponent extends Component {
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToProperty('activeTab', this.handleActiveTabUpdate.bind(this));
|
||||
}
|
||||
|
||||
handleActiveTabUpdate(newTab, previousTab) {
|
||||
// Update tab UI without full re-render
|
||||
this.updateActiveTab(newTab, previousTab);
|
||||
}
|
||||
|
||||
updateActiveTab(newTab) {
|
||||
// Update only the tab UI, preserving other state
|
||||
const tabButtons = this.findAllElements('.tab-button');
|
||||
const tabContents = this.findAllElements('.tab-content');
|
||||
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
const activeButton = this.findElement(`[data-tab="${newTab}"]`);
|
||||
const activeContent = this.findElement(`#${newTab}-tab`);
|
||||
|
||||
if (activeButton) activeButton.classList.add('active');
|
||||
if (activeContent) activeContent.classList.add('active');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### 1. **Storing UI State**
|
||||
|
||||
```javascript
|
||||
// In a component
|
||||
this.setUIState('expandedCard', memberIp);
|
||||
this.setUIState('activeTab', 'firmware');
|
||||
|
||||
// In a view model
|
||||
this.setUIState('userPreferences', { theme: 'dark', layout: 'compact' });
|
||||
```
|
||||
|
||||
### 2. **Retrieving UI State**
|
||||
|
||||
```javascript
|
||||
// Get specific state
|
||||
const expandedCard = this.getUIState('expandedCard');
|
||||
const activeTab = this.getUIState('activeTab');
|
||||
|
||||
// Get all state
|
||||
const allState = this.getAllUIState();
|
||||
```
|
||||
|
||||
### 3. **Batch Updates with State Preservation**
|
||||
|
||||
```javascript
|
||||
// Update data while preserving UI state
|
||||
this.viewModel.batchUpdate({
|
||||
members: newMembers,
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
}, { preserveUIState: true });
|
||||
```
|
||||
|
||||
### 4. **Smart Updates**
|
||||
|
||||
```javascript
|
||||
// Use smart update to preserve state
|
||||
await this.viewModel.smartUpdate();
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
### 1. **Improved User Experience**
|
||||
- Users don't lose their place in the interface
|
||||
- Expanded cards remain expanded
|
||||
- Active tabs stay selected
|
||||
- No jarring UI resets
|
||||
|
||||
### 2. **Better Performance**
|
||||
- Reduced unnecessary DOM manipulation
|
||||
- Efficient partial updates
|
||||
- Optimized rendering cycles
|
||||
|
||||
### 3. **Maintainable Code**
|
||||
- Clear separation of concerns
|
||||
- Consistent state management patterns
|
||||
- Easy to extend and modify
|
||||
|
||||
## Testing
|
||||
|
||||
Use the `test-state-preservation.html` file to test the state preservation system:
|
||||
|
||||
1. **Expand cluster member cards**
|
||||
2. **Change active tabs in node details**
|
||||
3. **Trigger data refresh**
|
||||
4. **Verify state is preserved**
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From Old System
|
||||
|
||||
If you're upgrading from the old system:
|
||||
|
||||
1. **Update ViewModel Listeners**: Change from `this.render.bind(this)` to specific update handlers
|
||||
2. **Add State Management**: Use `setUIState()` and `getUIState()` for UI state
|
||||
3. **Implement Partial Updates**: Override `updatePartial()` method for efficient updates
|
||||
4. **Use Smart Updates**: Replace direct data updates with `smartUpdate()` calls
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Old Code:**
|
||||
```javascript
|
||||
this.subscribeToProperty('members', this.render.bind(this));
|
||||
|
||||
async handleRefresh() {
|
||||
await this.viewModel.updateClusterMembers();
|
||||
}
|
||||
```
|
||||
|
||||
**New Code:**
|
||||
```javascript
|
||||
this.subscribeToProperty('members', this.handleMembersUpdate.bind(this));
|
||||
|
||||
async handleRefresh() {
|
||||
await this.viewModel.smartUpdate();
|
||||
}
|
||||
|
||||
handleMembersUpdate(newMembers, previousMembers) {
|
||||
if (this.shouldPreserveState(newMembers, previousMembers)) {
|
||||
this.updateMembersPartially(newMembers, previousMembers);
|
||||
} else {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Store UI State**: Use `setUIState()` for any user interaction
|
||||
2. **Implement Partial Updates**: Override `updatePartial()` for efficient updates
|
||||
3. **Use Change Detection**: Leverage `hasChanged()` to avoid unnecessary updates
|
||||
4. **Batch Related Updates**: Use `batchUpdate()` for multiple property changes
|
||||
5. **Test State Preservation**: Verify that UI state is maintained during data refreshes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **State Not Preserved**: Ensure you're using `setUIState()` and `getUIState()`
|
||||
2. **Full Re-renders**: Check if `shouldPreserveState()` logic is correct
|
||||
3. **Performance Issues**: Verify you're using partial updates instead of full renders
|
||||
|
||||
### Debug Tips
|
||||
|
||||
1. **Enable Console Logging**: Check browser console for state preservation logs
|
||||
2. **Use State Indicators**: Monitor state changes in the test interface
|
||||
3. **Verify Change Detection**: Ensure `hasChanged()` is working correctly
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **State Synchronization**: Real-time state sync across multiple browser tabs
|
||||
- **Advanced Change Detection**: Deep object comparison for complex data structures
|
||||
- **State Persistence**: Save UI state to localStorage for session persistence
|
||||
- **State Rollback**: Ability to revert to previous UI states
|
||||
@@ -1,248 +0,0 @@
|
||||
# Topology Component WebSocket Integration
|
||||
|
||||
## Summary
|
||||
Enhanced the topology graph component to support real-time node additions and removals via WebSocket connections. The topology view now automatically updates when nodes join or leave the cluster without requiring manual refresh. Existing nodes update their properties (status, labels) smoothly in place without being removed and re-added.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. TopologyViewModel (`spore-ui/public/scripts/view-models.js`)
|
||||
|
||||
Added `setupWebSocketListeners()` method to the TopologyViewModel class:
|
||||
|
||||
- **Listens to `clusterUpdate` events**: When cluster membership changes, the topology graph is automatically rebuilt with the new node data
|
||||
- **Listens to `nodeDiscovery` events**: When a new node is discovered or becomes stale, triggers a topology update
|
||||
- **Listens to connection status**: Automatically refreshes topology when WebSocket reconnects
|
||||
- **Async graph updates**: Rebuilds graph data asynchronously from WebSocket data to avoid blocking the UI
|
||||
|
||||
Enhanced `buildEnhancedGraphData()` method to preserve node state:
|
||||
|
||||
- **Position preservation**: Existing nodes retain their x, y coordinates across updates
|
||||
- **Velocity preservation**: D3 simulation velocity (vx, vy) is maintained for smooth physics
|
||||
- **Fixed position preservation**: Manually dragged nodes (fx, fy) stay in place
|
||||
- **New nodes only**: Only newly discovered nodes get random initial positions
|
||||
- **Result**: Nodes no longer "jump" or get removed/re-added when their properties update
|
||||
|
||||
### 2. TopologyGraphComponent (`spore-ui/public/scripts/components/TopologyGraphComponent.js`)
|
||||
|
||||
#### Added WebSocket Setup
|
||||
- Added `setupWebSocketListeners()` method that calls the view model's WebSocket setup during component initialization
|
||||
- Integrated into the `initialize()` lifecycle method
|
||||
|
||||
#### Improved Dynamic Updates (D3.js Enter/Exit Pattern)
|
||||
Refactored the graph rendering to use D3's data binding patterns for smooth transitions:
|
||||
|
||||
- **`updateLinks()`**: Uses enter/exit pattern to add/remove links with fade transitions
|
||||
- **`updateNodes()`**: Uses enter/exit pattern to add/remove nodes with fade transitions
|
||||
- New nodes fade in (300ms transition)
|
||||
- Removed nodes fade out (300ms transition)
|
||||
- Existing nodes smoothly update their properties
|
||||
- **`updateLinkLabels()`**: Dynamically updates link latency labels
|
||||
- **`updateSimulation()`**: Handles D3 force simulation updates
|
||||
- Creates new simulation on first render
|
||||
- Updates existing simulation with new node/link data on subsequent renders
|
||||
- Maintains smooth physics-based layout
|
||||
- **`addLegend()`**: Fixed to prevent duplicate legend creation
|
||||
|
||||
#### Key Improvements
|
||||
- **Incremental updates**: Instead of recreating the entire graph, only modified nodes/links are added or removed
|
||||
- **Smooth animations**: 300ms fade transitions for adding/removing elements
|
||||
- **In-place updates**: Existing nodes update their properties without being removed/re-added
|
||||
- **Preserved interactions**: Click, hover, and drag interactions work seamlessly with dynamic updates
|
||||
- **Efficient rendering**: D3's data binding with key functions ensures optimal DOM updates
|
||||
- **Intelligent simulation**: Uses different alpha values (0.1 for updates, 0.3 for additions/removals) to minimize disruption
|
||||
- **Drag-aware updates**: WebSocket updates are deferred while dragging and applied after drag completes
|
||||
- **Uninterrupted dragging**: Drag operations are never interrupted by incoming updates
|
||||
- **Rearrange button**: Convenient UI control to reset node layout and clear manual positioning
|
||||
|
||||
## How It Works
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
WebSocket Server (spore-ui backend)
|
||||
↓ (cluster_update / node_discovery events)
|
||||
WebSocketClient (api-client.js)
|
||||
↓ (emits clusterUpdate / nodeDiscovery events)
|
||||
TopologyViewModel.setupWebSocketListeners()
|
||||
↓ (builds graph data, updates state)
|
||||
TopologyGraphComponent subscriptions
|
||||
↓ (renderGraph() called automatically)
|
||||
├─ If dragging: queue update in pendingUpdate
|
||||
└─ If not dragging: apply update immediately
|
||||
D3.js enter/exit pattern
|
||||
↓ (smooth visual updates)
|
||||
Updated Topology Graph
|
||||
```
|
||||
|
||||
### Simplified Update Architecture
|
||||
|
||||
**Core Principle**: The D3 simulation is the single source of truth for positions.
|
||||
|
||||
#### How It Works:
|
||||
|
||||
1. **Drag Deferral**:
|
||||
- `isDragging` flag blocks updates during drag
|
||||
- Updates queued in `pendingUpdate` and applied after drag ends
|
||||
- Dragged positions saved in `draggedNodePositions` Map for persistence
|
||||
|
||||
2. **Position Merging** (in `updateNodes()`):
|
||||
- When simulation exists: copy live positions from simulation nodes to new data
|
||||
- This preserves ongoing animations and velocities
|
||||
- Then apply dragged positions (if any) as overrides
|
||||
- Result: Always use most current position state
|
||||
|
||||
3. **Smart Simulation Updates** (in `updateSimulation()`):
|
||||
- **Structural changes** (nodes added/removed): restart with alpha=0.3
|
||||
- **Property changes** (status, labels): DON'T restart - just update data
|
||||
- Simulation continues naturally for property-only changes
|
||||
- No unnecessary disruptions to ongoing animations
|
||||
|
||||
This ensures:
|
||||
- ✅ Simulation is authoritative for positions
|
||||
- ✅ No position jumping during animations
|
||||
- ✅ Property updates don't disrupt node movement
|
||||
- ✅ Dragged positions always respected
|
||||
- ✅ Simple, clean logic with one source of truth
|
||||
|
||||
### WebSocket Events Handled
|
||||
|
||||
1. **`clusterUpdate`** (from `cluster_update` message type)
|
||||
- Payload: `{ members: [...], primaryNode: string, totalNodes: number, timestamp: string }`
|
||||
- Action: Rebuilds graph with current cluster state
|
||||
|
||||
2. **`nodeDiscovery`** (from `node_discovery` message type)
|
||||
- Payload: `{ action: 'discovered' | 'stale', nodeIp: string, timestamp: string }`
|
||||
- Action: Triggers topology refresh after 500ms delay
|
||||
|
||||
3. **`connected`** (WebSocket connection established)
|
||||
- Action: Triggers topology refresh after 1000ms delay
|
||||
|
||||
4. **`disconnected`** (WebSocket connection lost)
|
||||
- Action: Logs disconnection (no action taken)
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Real-time Updates**: Topology reflects cluster state changes immediately
|
||||
2. **Smooth Transitions**: Nodes and links fade in/out gracefully
|
||||
3. **Better UX**: No manual refresh needed
|
||||
4. **Efficient**: Only updates changed elements, not entire graph
|
||||
5. **Resilient**: Automatically refreshes on reconnection
|
||||
6. **Consistent**: Uses same WebSocket infrastructure as ClusterStatusComponent
|
||||
|
||||
## Testing
|
||||
|
||||
To test the WebSocket integration:
|
||||
|
||||
1. **Start the application**:
|
||||
```bash
|
||||
cd spore-ui
|
||||
node index-standalone.js
|
||||
```
|
||||
|
||||
2. **Open the UI** and navigate to the Topology view
|
||||
|
||||
3. **Add a node**: Start a new SPORE device on the network
|
||||
- Watch it appear in the topology graph within seconds
|
||||
- Node should fade in smoothly
|
||||
|
||||
4. **Remove a node**: Stop a SPORE device
|
||||
- Watch it fade out from the topology graph
|
||||
- Connected links should also disappear
|
||||
|
||||
5. **Status changes**: Change node status (active → inactive → dead)
|
||||
- Node colors should update automatically
|
||||
- Status indicators should change
|
||||
|
||||
6. **Drag during updates**:
|
||||
- Start dragging a node
|
||||
- While dragging, trigger a cluster update (add/remove/change another node)
|
||||
- Drag should continue smoothly without interruption
|
||||
- After releasing, the update should be applied immediately
|
||||
- **Important**: The dragged node should stay at its final position, not revert
|
||||
|
||||
7. **Position persistence after drag**:
|
||||
- Drag a node to a new position and release
|
||||
- Trigger multiple WebSocket updates (status changes, new nodes, etc.)
|
||||
- The dragged node should remain in its new position through all updates
|
||||
- Only when the node is removed should its position be forgotten
|
||||
|
||||
8. **Update during animation**:
|
||||
- Let the graph settle (simulation running, nodes animating to stable positions)
|
||||
- While nodes are still moving, trigger a WebSocket update (status change)
|
||||
- **Expected**: Nodes should continue their smooth animation without jumping
|
||||
- **No flickering**: Positions should not snap back and forth
|
||||
- Animation should feel continuous and natural
|
||||
|
||||
9. **Single node scenario**:
|
||||
- Start with multiple nodes in the topology
|
||||
- Remove nodes one by one until only one remains
|
||||
- **Expected**: Single node stays visible, no "loading" message
|
||||
- Graph should render correctly with just one node
|
||||
- Remove the last node
|
||||
- **Expected**: "No cluster members found" message appears
|
||||
|
||||
10. **Rearrange nodes**:
|
||||
- Drag nodes to custom positions manually
|
||||
- Click the "Rearrange" button in the top-left corner
|
||||
- **Expected**: All nodes reset to physics-based positions
|
||||
- Dragged positions cleared, simulation restarts
|
||||
- Nodes animate to a clean, evenly distributed layout
|
||||
|
||||
11. **WebSocket reconnection**:
|
||||
- Disconnect from network briefly
|
||||
- Reconnect
|
||||
- Topology should refresh automatically
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Architecture
|
||||
- **Single Source of Truth**: D3 simulation manages all position state
|
||||
- **Key Functions**: D3 data binding uses node IPs as keys to track identity
|
||||
- **Transition Duration**: 300ms for fade in/out animations
|
||||
|
||||
### Position Management (Simplified!)
|
||||
- **updateNodes()**: Copies live positions from simulation to new data before binding
|
||||
- **No complex syncing**: Simulation state flows naturally to new data
|
||||
- **Dragged positions**: Override via `draggedNodePositions` Map (always respected)
|
||||
|
||||
### Simulation Behavior
|
||||
- **Structural changes** (add/remove nodes): Restart with alpha=0.3
|
||||
- **Property changes** (status, labels): No restart - data updated in-place
|
||||
- **Drag operations**: Simulation updates blocked entirely
|
||||
- **Result**: Smooth animations for property updates, controlled restart for structure changes
|
||||
|
||||
### Drag Management
|
||||
- **isDragging flag**: Blocks all updates during drag
|
||||
- **pendingUpdate**: Queues one update, applied 50ms after drag ends
|
||||
- **draggedNodePositions Map**: Persists manual positions across all updates
|
||||
- **Cleanup**: Map entries removed when nodes deleted
|
||||
|
||||
### Performance
|
||||
- **No unnecessary restarts**: Property-only updates don't disrupt simulation
|
||||
- **Efficient merging**: Position data copied via Map lookup (O(n))
|
||||
- **Memory efficient**: Only active nodes tracked, old entries cleaned up
|
||||
- **Smooth animations**: Velocity and momentum preserved across updates
|
||||
|
||||
### Edge Cases Handled
|
||||
- **Single node**: Graph renders correctly with just one node
|
||||
- **Transient states**: Loading/no-data states don't clear existing SVG
|
||||
- **Update races**: SVG preserved even if loading state triggered during render
|
||||
- **Empty to non-empty**: Smooth transition from loading to first node
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Possible improvements for future iterations:
|
||||
|
||||
1. **Diff-based updates**: Only rebuild graph when node/link structure actually changes
|
||||
2. **Visual indicators**: Show "new node" or "leaving node" badges temporarily
|
||||
3. **Connection health**: Real-time latency updates on links without full rebuild
|
||||
4. **Throttling**: Debounce rapid successive updates
|
||||
5. **Persistent layout**: Save and restore user-arranged topology layouts
|
||||
6. **Zoom to node**: Auto-zoom to newly added nodes with animation
|
||||
|
||||
## Related Files
|
||||
|
||||
- `spore-ui/public/scripts/view-models.js` - TopologyViewModel class
|
||||
- `spore-ui/public/scripts/components/TopologyGraphComponent.js` - Topology visualization component
|
||||
- `spore-ui/public/scripts/api-client.js` - WebSocketClient class
|
||||
- `spore-ui/index-standalone.js` - WebSocket server implementation
|
||||
|
||||
223
docs/VIEW_SWITCHING_FIXES.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# View Switching Fixes for Member Card Issues
|
||||
|
||||
## Problem Description
|
||||
|
||||
When switching between the cluster and firmware views, member cards were experiencing:
|
||||
- **Wrong UI state**: Expanded cards, active tabs, and other UI state was being lost
|
||||
- **Flickering**: Visual glitches and rapid re-rendering during view switches
|
||||
- **Broken functionality**: Member cards not working properly after view switches
|
||||
- **Inefficient rendering**: Components were completely unmounted and remounted on every view switch
|
||||
- **Incorrect state restoration**: UI state was incorrectly restored on first load (all cards expanded, wrong tabs active)
|
||||
|
||||
## Root Causes Identified
|
||||
|
||||
1. **Aggressive DOM Manipulation**: Complete component unmounting/remounting on every view switch
|
||||
2. **Race Conditions**: Multiple async operations and timeouts interfering with each other
|
||||
3. **State Loss**: UI state not properly preserved across view switches
|
||||
4. **Rapid Navigation**: Multiple rapid clicks could cause navigation conflicts
|
||||
5. **CSS Transition Conflicts**: Multiple transitions causing visual flickering
|
||||
6. **No Component Caching**: Every view switch created new component instances
|
||||
7. **Complex State Restoration**: Attempting to restore UI state caused incorrect behavior on first load
|
||||
|
||||
## Fixes Implemented
|
||||
|
||||
### 1. **Component Caching System** (`framework.js`)
|
||||
|
||||
- **Component Cache**: Components are created once and cached, never re-created
|
||||
- **Pause/Resume Pattern**: Components are paused (not unmounted) when switching away
|
||||
- **Pre-initialization**: Components are created during route registration for better performance
|
||||
- **Simple Show/Hide**: Components are just shown/hidden without touching UI state
|
||||
|
||||
### 2. **Enhanced Navigation System** (`framework.js`)
|
||||
|
||||
- **Debounced Navigation**: Added 300ms cooldown between navigation requests
|
||||
- **Navigation Queue**: Queues navigation requests when one is already in progress
|
||||
- **Smooth Transitions**: Added opacity transitions to prevent abrupt view changes
|
||||
- **No Component Destruction**: Components are kept alive and just paused/resumed
|
||||
|
||||
### 3. **Simplified State Management** (`view-models.js`)
|
||||
|
||||
- **No UI State Persistence**: Removed complex localStorage state restoration
|
||||
- **Clean State on Load**: Components start with default state (collapsed cards, status tab)
|
||||
- **No State Corruption**: Eliminates incorrect state restoration on first load
|
||||
|
||||
### 4. **Enhanced Component Lifecycle** (`components.js`)
|
||||
|
||||
- **Pause/Resume Methods**: Components can be paused and resumed without losing state
|
||||
- **Default State**: Member cards always start collapsed, tabs start on 'status'
|
||||
- **No State Restoration**: Components maintain their current state without external interference
|
||||
- **Render Guards**: Prevents multiple simultaneous render operations
|
||||
- **View Switch Detection**: Skips rendering during view transitions
|
||||
- **Improved Unmounting**: Better cleanup of timeouts and event listeners
|
||||
- **State Tracking**: Tracks if data has already been loaded to prevent unnecessary reloads
|
||||
|
||||
### 5. **CSS Improvements** (`styles.css`)
|
||||
|
||||
- **Smooth Transitions**: Added fade-in/fade-out animations for view switching
|
||||
- **Reduced Transition Times**: Shortened member card transitions from 0.3s to 0.2s
|
||||
- **Better Animations**: Improved expand/collapse animations for member cards
|
||||
- **Loading States**: Added fade-in animations for loading, error, and empty states
|
||||
|
||||
### 6. **View Model Enhancements**
|
||||
|
||||
- **Smart Updates**: Only updates changed data to minimize re-renders
|
||||
- **Change Detection**: Compares data before triggering updates
|
||||
- **Clean Initialization**: No complex state restoration logic
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Component Caching Flow
|
||||
|
||||
1. **Route Registration**: Components are created and cached during app initialization
|
||||
2. **Navigation**: When switching views, current component is paused (not unmounted)
|
||||
3. **State Preservation**: All component state, DOM, and event listeners remain intact
|
||||
4. **Resume**: When returning to a view, component is resumed from paused state
|
||||
5. **No Re-rendering**: Components maintain their exact state and appearance
|
||||
6. **Simple Show/Hide**: No complex state restoration, just show/hide components
|
||||
|
||||
### Pause/Resume Pattern
|
||||
|
||||
```javascript
|
||||
// Component is paused instead of unmounted
|
||||
onPause() {
|
||||
// Clear timers, pause operations
|
||||
// Component state and DOM remain intact
|
||||
}
|
||||
|
||||
onResume() {
|
||||
// Restore timers, resume operations
|
||||
// No re-rendering needed
|
||||
}
|
||||
```
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
1. **Cooldown Check**: 300ms minimum between navigation requests
|
||||
2. **Queue Management**: Multiple requests queued and processed sequentially
|
||||
3. **Pause Current**: Current component paused (opacity: 0)
|
||||
4. **Show New View**: New view becomes visible with fade-in animation
|
||||
5. **Resume Component**: Cached component resumed from paused state
|
||||
6. **No Unmounting**: Components are never destroyed during view switches
|
||||
7. **No State Touch**: UI state is not modified during view switches
|
||||
|
||||
### State Management
|
||||
|
||||
- **Default State**: Member cards start collapsed, tabs start on 'status'
|
||||
- **No Persistence**: No localStorage state restoration
|
||||
- **Clean Initialization**: Components always start with predictable state
|
||||
- **No State Corruption**: Eliminates incorrect state restoration issues
|
||||
|
||||
### Render Optimization
|
||||
|
||||
- **No Re-rendering**: Components maintain their exact state across view switches
|
||||
- **Pause/Resume**: Components are paused instead of unmounted
|
||||
- **State Persistence**: All UI state preserved in memory (not localStorage)
|
||||
- **Change Detection**: Only updates changed data when resuming
|
||||
- **Default Behavior**: Always starts with clean, predictable state
|
||||
|
||||
## Testing
|
||||
|
||||
Use the test page `test-view-switching.html` to verify fixes:
|
||||
|
||||
1. **Rapid Switching Test**: Clicks navigation tabs rapidly to test cooldown
|
||||
2. **State Preservation Test**: Expands cards, switches views, verifies state restoration
|
||||
3. **Component Caching Test**: Verify components are not re-created on view switches
|
||||
4. **Default State Test**: Verify components start with correct default state
|
||||
5. **Console Monitoring**: Check console for detailed operation logs
|
||||
|
||||
## Expected Results
|
||||
|
||||
After implementing these fixes:
|
||||
|
||||
- ✅ **No More Re-rendering**: Components are cached and never re-created
|
||||
- ✅ **No More Flickering**: Smooth transitions between views
|
||||
- ✅ **Correct Default State**: Member cards start collapsed, tabs start on 'status'
|
||||
- ✅ **No State Corruption**: No incorrect state restoration on first load
|
||||
- ✅ **Stable Navigation**: No more broken member cards after view switches
|
||||
- ✅ **Better Performance**: No unnecessary component creation/destruction
|
||||
- ✅ **Improved UX**: Smoother, more professional feel
|
||||
- ✅ **Memory Efficiency**: Components reused instead of recreated
|
||||
- ✅ **Predictable Behavior**: Components always start with clean state
|
||||
|
||||
## Configuration
|
||||
|
||||
### Navigation Cooldown
|
||||
```javascript
|
||||
this.navigationCooldown = 300; // 300ms between navigation requests
|
||||
```
|
||||
|
||||
### Component Caching
|
||||
```javascript
|
||||
// Components are automatically cached during route registration
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
```
|
||||
|
||||
### Transition Timing
|
||||
```css
|
||||
.view-content {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
```
|
||||
|
||||
### Member Card Transitions
|
||||
```css
|
||||
.member-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Benefits
|
||||
|
||||
### 1. **Performance**
|
||||
- No component recreation on view switches
|
||||
- Faster view transitions
|
||||
- Reduced memory allocation/deallocation
|
||||
|
||||
### 2. **State Management**
|
||||
- Clean, predictable default state
|
||||
- No state corruption on first load
|
||||
- Consistent user experience
|
||||
|
||||
### 3. **Maintainability**
|
||||
- Cleaner component lifecycle
|
||||
- No complex state restoration logic
|
||||
- Easier debugging and testing
|
||||
- More predictable behavior
|
||||
|
||||
### 4. **User Experience**
|
||||
- No flickering or visual glitches
|
||||
- Instant view switching
|
||||
- Maintained user context
|
||||
- Predictable component behavior
|
||||
|
||||
## Key Changes Made
|
||||
|
||||
### Removed Complex State Restoration
|
||||
- ❌ `preserveUIState()` method
|
||||
- ❌ `restoreUIState()` method
|
||||
- ❌ localStorage state persistence
|
||||
- ❌ Complex tab state restoration
|
||||
- ❌ Expanded card state restoration
|
||||
|
||||
### Simplified Component Behavior
|
||||
- ✅ Components start with default state
|
||||
- ✅ Member cards always start collapsed
|
||||
- ✅ Tabs always start on 'status'
|
||||
- ✅ No external state interference
|
||||
- ✅ Clean, predictable initialization
|
||||
|
||||
### Maintained Performance Benefits
|
||||
- ✅ Component caching still works
|
||||
- ✅ No re-rendering on view switches
|
||||
- ✅ Smooth transitions
|
||||
- ✅ Better memory efficiency
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Virtual Scrolling**: For large numbers of member cards
|
||||
2. **Animation Preferences**: User-configurable transition speeds
|
||||
3. **State Sync**: Real-time state synchronization across multiple tabs
|
||||
4. **Performance Metrics**: Track and optimize render performance
|
||||
5. **Lazy Loading**: Load components only when first accessed
|
||||
6. **Memory Management**: Intelligent cache cleanup for unused components
|
||||
7. **User Preferences**: Allow users to set default states if desired
|
||||
1184
index-standalone.js
589
index.js
@@ -1,45 +1,568 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
|
||||
// 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 fs = require('fs');
|
||||
const dgram = require('dgram');
|
||||
const SporeApiClient = require('./src/client');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
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
|
||||
}));
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up periodic tasks
|
||||
setInterval(() => {
|
||||
cleanupStaleNodes();
|
||||
if (!sporeClient || !primaryNodeIp || !discoveredNodes.has(primaryNodeIp)) {
|
||||
updateSporeClient();
|
||||
}
|
||||
}, 5000); // Check every 5 seconds
|
||||
|
||||
// Serve static files from public directory
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// 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) => {
|
||||
// Serve the main HTML page
|
||||
app.get('/', (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 (!sporeClient) {
|
||||
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 sporeClient.getClusterStatus();
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cluster members:', error);
|
||||
res.status(500).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 {
|
||||
if (!sporeClient) {
|
||||
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 sporeClient.getTaskStatus();
|
||||
res.json(taskStatus);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task status:', error);
|
||||
res.status(500).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 (!sporeClient) {
|
||||
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 sporeClient.getSystemStatus();
|
||||
res.json(systemStatus);
|
||||
} catch (error) {
|
||||
console.error('Error fetching system status:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch system status',
|
||||
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
|
||||
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(`Frontend connects to spore-gateway for API and WebSocket functionality`);
|
||||
console.log(`Make sure spore-gateway is running on port 3001`);
|
||||
});
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Server is running on http://localhost:${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);
|
||||
});
|
||||
47
package-lock.json
generated
@@ -9,10 +9,8 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-fileupload": "^1.4.3",
|
||||
"ws": "^8.18.3"
|
||||
"express-fileupload": "^1.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -136,19 +134,6 @@
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
@@ -544,15 +529,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -876,27 +852,6 @@
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
18
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "spore-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "SPORE Cluster Management UI",
|
||||
"description": "## TODO",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
@@ -11,27 +11,13 @@
|
||||
"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": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"express-fileupload": "^1.4.3",
|
||||
"ws": "^8.18.3"
|
||||
"express-fileupload": "^1.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
130
public/api-client.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// API Client for communicating with the backend
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
this.baseUrl = 'http://localhost:3001'; // Backend server URL
|
||||
}
|
||||
|
||||
async getClusterMembers() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/cluster/members`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveryInfo() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/node/status/${encodeURIComponent(ip)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksStatus() {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tasks/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
window.apiClient = new ApiClient();
|
||||
99
public/app.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('=== SPORE UI Application Initialization ===');
|
||||
|
||||
// Initialize the framework (but don't navigate yet)
|
||||
console.log('App: Creating framework instance...');
|
||||
const app = window.app;
|
||||
|
||||
// Create view models
|
||||
console.log('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
const firmwareViewModel = new FirmwareViewModel();
|
||||
console.log('App: View models created:', { clusterViewModel, firmwareViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
console.log('App: Members subscription triggered:', members);
|
||||
if (members && members.length > 0) {
|
||||
// Extract node information for firmware view
|
||||
const nodes = members.map(member => ({
|
||||
ip: member.ip,
|
||||
hostname: member.hostname || member.ip
|
||||
}));
|
||||
firmwareViewModel.updateAvailableNodes(nodes);
|
||||
console.log('App: Updated firmware view model with nodes:', nodes);
|
||||
} else {
|
||||
firmwareViewModel.updateAvailableNodes([]);
|
||||
console.log('App: Cleared firmware view model nodes');
|
||||
}
|
||||
});
|
||||
|
||||
// Register routes with their view models
|
||||
console.log('App: Registering routes...');
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
console.log('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Set up navigation event listeners
|
||||
console.log('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
// Set up periodic updates for cluster view with state preservation
|
||||
// setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
|
||||
// Now navigate to the default route
|
||||
console.log('App: Navigating to default route...');
|
||||
app.navigateTo('cluster');
|
||||
|
||||
console.log('=== SPORE UI Application initialization completed ===');
|
||||
});
|
||||
|
||||
// Set up periodic updates with state preservation
|
||||
function setupPeriodicUpdates() {
|
||||
// Auto-refresh cluster members every 30 seconds using smart update
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
|
||||
// Use smart update if available, otherwise fall back to regular update
|
||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||
console.log('App: Performing smart update to preserve UI state...');
|
||||
viewModel.smartUpdate();
|
||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
console.log('App: Performing regular update...');
|
||||
viewModel.updateClusterMembers();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state)
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') {
|
||||
viewModel.updatePrimaryNodeDisplay();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.app) {
|
||||
console.log('App: Cleaning up cached components...');
|
||||
window.app.cleanup();
|
||||
}
|
||||
});
|
||||
1768
public/components.js
Normal file
190
public/debug-cluster-load.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Cluster Load</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.debug-panel { background: #f0f0f0; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
.debug-button { padding: 8px 16px; margin: 5px; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; }
|
||||
.debug-button:hover { background: #0056b3; }
|
||||
.log { background: #000; color: #0f0; padding: 10px; margin: 10px 0; border-radius: 3px; font-family: monospace; max-height: 300px; overflow-y: auto; }
|
||||
.cluster-container { border: 1px solid #ccc; padding: 15px; margin: 10px 0; border-radius: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Debug Cluster Load</h1>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Debug Controls</h3>
|
||||
<button class="debug-button" onclick="testContainerFind()">🔍 Test Container Find</button>
|
||||
<button class="debug-button" onclick="testViewModel()">📊 Test ViewModel</button>
|
||||
<button class="debug-button" onclick="testComponent()">🧩 Test Component</button>
|
||||
<button class="debug-button" onclick="testAPICall()">📡 Test API Call</button>
|
||||
<button class="debug-button" onclick="clearLog()">🧹 Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Container Elements</h3>
|
||||
<div id="cluster-view" class="cluster-container">
|
||||
<div class="primary-node-info">
|
||||
<h4>Primary Node</h4>
|
||||
<div id="primary-node-ip">🔍 Discovering...</div>
|
||||
<button class="primary-node-refresh">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<h4>Cluster Members</h4>
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-panel">
|
||||
<h3>Debug Log</h3>
|
||||
<div id="debug-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
let debugLog = [];
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = `[${timestamp}] ${message}`;
|
||||
|
||||
const logContainer = document.getElementById('debug-log');
|
||||
logContainer.innerHTML += logEntry + '\n';
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
|
||||
debugLog.push({ timestamp, message, type });
|
||||
console.log(logEntry);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('debug-log').innerHTML = '';
|
||||
debugLog = [];
|
||||
}
|
||||
|
||||
// Test container finding
|
||||
function testContainerFind() {
|
||||
log('🔍 Testing container finding...');
|
||||
|
||||
const clusterView = document.getElementById('cluster-view');
|
||||
const primaryNodeInfo = document.querySelector('.primary-node-info');
|
||||
const clusterMembersContainer = document.getElementById('cluster-members-container');
|
||||
|
||||
log(`Cluster view found: ${!!clusterView} (ID: ${clusterView?.id})`);
|
||||
log(`Primary node info found: ${!!primaryNodeInfo}`);
|
||||
log(`Cluster members container found: ${!!clusterMembersContainer} (ID: ${clusterMembersContainer?.id})`);
|
||||
log(`Cluster members container innerHTML: ${clusterMembersContainer?.innerHTML?.substring(0, 100)}...`);
|
||||
}
|
||||
|
||||
// Test view model
|
||||
function testViewModel() {
|
||||
log('📊 Testing ViewModel...');
|
||||
|
||||
try {
|
||||
const viewModel = new ClusterViewModel();
|
||||
log('✅ ClusterViewModel created successfully');
|
||||
|
||||
log(`Initial members: ${viewModel.get('members')?.length || 0}`);
|
||||
log(`Initial loading: ${viewModel.get('isLoading')}`);
|
||||
log(`Initial error: ${viewModel.get('error')}`);
|
||||
|
||||
return viewModel;
|
||||
} catch (error) {
|
||||
log(`❌ ViewModel creation failed: ${error.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test component
|
||||
function testComponent() {
|
||||
log('🧩 Testing Component...');
|
||||
|
||||
try {
|
||||
const viewModel = new ClusterViewModel();
|
||||
const eventBus = new EventBus();
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
log('✅ Dependencies created, creating ClusterMembersComponent...');
|
||||
|
||||
const component = new ClusterMembersComponent(container, viewModel, eventBus);
|
||||
log('✅ ClusterMembersComponent created successfully');
|
||||
|
||||
log('Mounting component...');
|
||||
component.mount();
|
||||
log('✅ Component mounted');
|
||||
|
||||
return { component, viewModel, eventBus };
|
||||
} catch (error) {
|
||||
log(`❌ Component test failed: ${error.message}`, 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Test API call
|
||||
async function testAPICall() {
|
||||
log('📡 Testing API call...');
|
||||
|
||||
try {
|
||||
if (!window.apiClient) {
|
||||
log('❌ API client not available');
|
||||
return;
|
||||
}
|
||||
|
||||
log('Calling getClusterMembers...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
log(`✅ API call successful: ${response.members?.length || 0} members`);
|
||||
|
||||
if (response.members && response.members.length > 0) {
|
||||
response.members.forEach(member => {
|
||||
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ API call failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize debug interface
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('🚀 Debug interface initialized');
|
||||
log('💡 Use the debug controls above to test different aspects of the cluster loading');
|
||||
});
|
||||
|
||||
// Mock API client if not available
|
||||
if (!window.apiClient) {
|
||||
log('⚠️ Creating mock API client for testing');
|
||||
window.apiClient = {
|
||||
getClusterMembers: async () => {
|
||||
log('📡 Mock API: getClusterMembers called');
|
||||
return {
|
||||
members: [
|
||||
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
|
||||
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
|
||||
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
|
||||
]
|
||||
};
|
||||
},
|
||||
getDiscoveryInfo: async () => {
|
||||
log('📡 Mock API: getDiscoveryInfo called');
|
||||
return {
|
||||
primaryNode: '192.168.1.100',
|
||||
clientInitialized: true,
|
||||
totalNodes: 3
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
124
public/debug.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Framework</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.debug-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.log { background: #f5f5f5; padding: 10px; margin: 10px 0; font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Framework Debug</h1>
|
||||
|
||||
<div class="debug-section">
|
||||
<h2>Console Log</h2>
|
||||
<div id="console-log" class="log"></div>
|
||||
<button onclick="clearLog()">Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h2>Test Cluster View</h2>
|
||||
<div id="cluster-view">
|
||||
<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" 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>
|
||||
<button class="refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="testClusterView()">Test Cluster View</button>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
|
||||
<script>
|
||||
// Override console.log to capture output
|
||||
const originalLog = console.log;
|
||||
const originalError = console.error;
|
||||
const logElement = document.getElementById('console-log');
|
||||
|
||||
function addToLog(message, type = 'log') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.style.color = type === 'error' ? 'red' : 'black';
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
logElement.appendChild(logEntry);
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
|
||||
console.log = function(...args) {
|
||||
originalLog.apply(console, args);
|
||||
addToLog(args.join(' '));
|
||||
};
|
||||
|
||||
console.error = function(...args) {
|
||||
originalError.apply(console, args);
|
||||
addToLog(args.join(' '), 'error');
|
||||
};
|
||||
|
||||
function clearLog() {
|
||||
logElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// Test cluster view
|
||||
function testClusterView() {
|
||||
try {
|
||||
console.log('Testing cluster view...');
|
||||
|
||||
// Create view model
|
||||
const clusterVM = new ClusterViewModel();
|
||||
console.log('ClusterViewModel created:', clusterVM);
|
||||
|
||||
// Create component
|
||||
const container = document.getElementById('cluster-view');
|
||||
const clusterComponent = new ClusterViewComponent(container, clusterVM, null);
|
||||
console.log('ClusterViewComponent created:', clusterComponent);
|
||||
|
||||
// Mount component
|
||||
clusterComponent.mount();
|
||||
console.log('Component mounted');
|
||||
|
||||
// Test data loading
|
||||
console.log('Testing data loading...');
|
||||
clusterVM.updateClusterMembers();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error testing cluster view:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize framework
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('DOM loaded, initializing framework...');
|
||||
|
||||
if (window.app) {
|
||||
console.log('Framework app found:', window.app);
|
||||
window.app.init();
|
||||
console.log('Framework initialized');
|
||||
} else {
|
||||
console.error('Framework app not found');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
344
public/deploy-button-test.html
Normal file
@@ -0,0 +1,344 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deploy Button Test - Isolated</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
.test-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.firmware-actions {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.deploy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.deploy-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.node-select {
|
||||
background: #2d3748;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.cluster-members {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.member-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.debug-info {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🚀 Deploy Button Test - Isolated</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Scenario: Deploy Button State</h2>
|
||||
<p>This test isolates the deploy button functionality to debug the issue.</p>
|
||||
</div>
|
||||
|
||||
<div class="firmware-actions">
|
||||
<h3>🚀 Firmware Update</h3>
|
||||
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span>All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span>Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button onclick="document.getElementById('global-firmware-file').click()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span id="file-info">No file selected</span>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
🚀 Deploy Firmware
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cluster-members">
|
||||
<h3>Cluster Members</h3>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
<button onclick="addTestNode()">Add Test Node</button>
|
||||
<button onclick="removeAllNodes()">Remove All Nodes</button>
|
||||
</div>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>Debug Information</h3>
|
||||
<div id="debug-output">Waiting for actions...</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simulate the cluster members functionality
|
||||
let testNodes = [];
|
||||
|
||||
function addTestNode() {
|
||||
const nodeCount = testNodes.length + 1;
|
||||
const newNode = {
|
||||
ip: `192.168.1.${100 + nodeCount}`,
|
||||
hostname: `TestNode${nodeCount}`,
|
||||
status: 'active',
|
||||
latency: Math.floor(Math.random() * 50) + 10
|
||||
};
|
||||
testNodes.push(newNode);
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function removeAllNodes() {
|
||||
testNodes = [];
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function displayClusterMembers() {
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No cluster members found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const membersHTML = testNodes.map(node => {
|
||||
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
|
||||
const statusText = node.status === 'active' ? 'Online' : 'Offline';
|
||||
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
|
||||
|
||||
return `
|
||||
<div class="member-card" data-member-ip="${node.ip}">
|
||||
<div class="member-name">${node.hostname}</div>
|
||||
<div class="member-ip">${node.ip}</div>
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
<div class="member-latency">Latency: ${node.latency}ms</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = membersHTML;
|
||||
}
|
||||
|
||||
function populateNodeSelect() {
|
||||
const select = document.getElementById('specific-node-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">Select a node...</option>';
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = "";
|
||||
option.textContent = "No nodes available";
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
testNodes.forEach(node => {
|
||||
const option = document.createElement('option');
|
||||
option.value = node.ip;
|
||||
option.textContent = `${node.hostname} (${node.ip})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDeployButton() {
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
if (!deployBtn || !fileInput) return;
|
||||
|
||||
const hasFile = fileInput.files && fileInput.files.length > 0;
|
||||
const hasAvailableNodes = testNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (targetType.value === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (targetType.value === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
const debugInfo = {
|
||||
hasFile,
|
||||
targetType: targetType?.value,
|
||||
hasAvailableNodes,
|
||||
specificNodeValue: specificNodeSelect?.value,
|
||||
isValidTarget,
|
||||
memberCardsCount: testNodes.length
|
||||
};
|
||||
|
||||
console.log('updateDeployButton debug:', debugInfo);
|
||||
|
||||
deployBtn.disabled = !hasFile || !isValidTarget;
|
||||
|
||||
// Update button text to provide better feedback
|
||||
if (!hasAvailableNodes) {
|
||||
deployBtn.textContent = '🚀 Deploy (No nodes available)';
|
||||
deployBtn.title = 'No cluster nodes are currently available for deployment';
|
||||
} else if (!hasFile) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a firmware file to deploy';
|
||||
} else if (!isValidTarget) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a valid target for deployment';
|
||||
} else {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Ready to deploy firmware';
|
||||
}
|
||||
|
||||
updateDebugInfo();
|
||||
}
|
||||
|
||||
function updateDebugInfo() {
|
||||
const debugOutput = document.getElementById('debug-output');
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
const debugInfo = {
|
||||
hasFile: fileInput.files && fileInput.files.length > 0,
|
||||
targetType: targetType?.value,
|
||||
hasAvailableNodes: testNodes.length > 0,
|
||||
specificNodeValue: specificNodeSelect?.value,
|
||||
deployButtonDisabled: deployBtn.disabled,
|
||||
deployButtonText: deployBtn.textContent,
|
||||
testNodesCount: testNodes.length
|
||||
};
|
||||
|
||||
debugOutput.innerHTML = `<pre>${JSON.stringify(debugInfo, null, 2)}</pre>`;
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Setup target selection
|
||||
const targetRadios = document.querySelectorAll('input[name="target-type"]');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
targetRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
console.log('Target radio changed to:', radio.value);
|
||||
|
||||
if (radio.value === 'specific') {
|
||||
specificNodeSelect.style.visibility = 'visible';
|
||||
specificNodeSelect.style.opacity = '1';
|
||||
populateNodeSelect();
|
||||
} else {
|
||||
specificNodeSelect.style.visibility = 'hidden';
|
||||
specificNodeSelect.style.opacity = '0';
|
||||
}
|
||||
|
||||
console.log('Calling updateDeployButton after target change');
|
||||
updateDeployButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Setup specific node select change handler
|
||||
if (specificNodeSelect) {
|
||||
specificNodeSelect.addEventListener('change', (event) => {
|
||||
console.log('Specific node select changed to:', event.target.value);
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Setup file input change handler
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
}
|
||||
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 728 B |
@@ -1,16 +1,5 @@
|
||||
// SPORE UI Framework - Component-based architecture with pub/sub system
|
||||
|
||||
// Lightweight logger with level gating
|
||||
const logger = {
|
||||
debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } },
|
||||
info: (...args) => console.info(...args),
|
||||
warn: (...args) => console.warn(...args),
|
||||
error: (...args) => console.error(...args),
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
window.logger = window.logger || logger;
|
||||
}
|
||||
|
||||
// Event Bus for pub/sub communication
|
||||
class EventBus {
|
||||
constructor() {
|
||||
@@ -88,7 +77,7 @@ class ViewModel {
|
||||
|
||||
// Set data property and notify listeners
|
||||
set(property, value) {
|
||||
logger.debug(`ViewModel: Setting property '${property}' to:`, value);
|
||||
console.log(`ViewModel: Setting property '${property}' to:`, value);
|
||||
|
||||
// Check if the value has actually changed
|
||||
const hasChanged = this._data[property] !== value;
|
||||
@@ -100,39 +89,38 @@ class ViewModel {
|
||||
// Update the data
|
||||
this._data[property] = value;
|
||||
|
||||
logger.debug(`ViewModel: Property '${property}' changed, notifying listeners...`);
|
||||
console.log(`ViewModel: Property '${property}' changed, notifying listeners...`);
|
||||
this._notifyListeners(property, value, this._previousData[property]);
|
||||
} else {
|
||||
logger.debug(`ViewModel: Property '${property}' unchanged, skipping notification`);
|
||||
console.log(`ViewModel: Property '${property}' unchanged, skipping notification`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set multiple properties at once with change detection
|
||||
setMultiple(properties) {
|
||||
const changedProperties = {};
|
||||
const unchangedProperties = {};
|
||||
|
||||
// Determine changes and update previousData snapshot per key
|
||||
Object.keys(properties).forEach(key => {
|
||||
const newValue = properties[key];
|
||||
const oldValue = this._data[key];
|
||||
if (oldValue !== newValue) {
|
||||
this._previousData[key] = oldValue;
|
||||
changedProperties[key] = newValue;
|
||||
if (this._data[key] !== properties[key]) {
|
||||
changedProperties[key] = properties[key];
|
||||
} else {
|
||||
unchangedProperties[key] = properties[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Apply all properties
|
||||
// Set all properties
|
||||
Object.keys(properties).forEach(key => {
|
||||
this._data[key] = properties[key];
|
||||
});
|
||||
|
||||
// Notify listeners only for changed properties with accurate previous values
|
||||
// Notify listeners only for changed properties
|
||||
Object.keys(changedProperties).forEach(key => {
|
||||
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||
this._notifyListeners(key, changedProperties[key], this._previousData[key]);
|
||||
});
|
||||
|
||||
if (Object.keys(changedProperties).length > 0) {
|
||||
logger.debug(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
|
||||
console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,20 +145,20 @@ class ViewModel {
|
||||
|
||||
// Notify listeners of property changes
|
||||
_notifyListeners(property, value, previousValue) {
|
||||
logger.debug(`ViewModel: _notifyListeners called for property '${property}'`);
|
||||
console.log(`ViewModel: _notifyListeners called for property '${property}'`);
|
||||
if (this._listeners.has(property)) {
|
||||
const callbacks = this._listeners.get(property);
|
||||
logger.debug(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
|
||||
console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
|
||||
callbacks.forEach((callback, index) => {
|
||||
try {
|
||||
logger.debug(`ViewModel: Calling listener ${index} for property '${property}'`);
|
||||
console.log(`ViewModel: Calling listener ${index} for property '${property}'`);
|
||||
callback(value, previousValue);
|
||||
} catch (error) {
|
||||
console.error(`Error in property listener for ${property}:`, error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.debug(`ViewModel: No listeners found for property '${property}'`);
|
||||
console.log(`ViewModel: No listeners found for property '${property}'`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,33 +213,36 @@ class ViewModel {
|
||||
|
||||
// Batch update with change detection
|
||||
batchUpdate(updates, options = {}) {
|
||||
const { notifyChanges = true } = options;
|
||||
const { preserveUIState = true, notifyChanges = true } = options;
|
||||
|
||||
// Track which keys actually change and what the previous values were
|
||||
const changedKeys = [];
|
||||
Object.keys(updates).forEach(key => {
|
||||
const newValue = updates[key];
|
||||
const oldValue = this._data[key];
|
||||
if (oldValue !== newValue) {
|
||||
this._previousData[key] = oldValue;
|
||||
this._data[key] = newValue;
|
||||
changedKeys.push(key);
|
||||
} else {
|
||||
// Still apply to ensure consistency if needed
|
||||
this._data[key] = newValue;
|
||||
}
|
||||
});
|
||||
if (preserveUIState) {
|
||||
// Store current UI state
|
||||
const currentUIState = new Map(this._uiState);
|
||||
|
||||
// Apply updates
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._data[key] = updates[key];
|
||||
});
|
||||
|
||||
// Restore UI state
|
||||
this._uiState = currentUIState;
|
||||
} else {
|
||||
// Apply updates normally
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._data[key] = updates[key];
|
||||
});
|
||||
}
|
||||
|
||||
// Notify listeners for changed keys
|
||||
// Notify listeners if requested
|
||||
if (notifyChanges) {
|
||||
changedKeys.forEach(key => {
|
||||
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||
Object.keys(updates).forEach(key => {
|
||||
this._notifyListeners(key, updates[key], this._previousData[key]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base Component class
|
||||
// Base Component class with enhanced state preservation
|
||||
class Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
@@ -277,13 +268,13 @@ class Component {
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
logger.debug(`${this.constructor.name}: Starting mount...`);
|
||||
console.log(`${this.constructor.name}: Starting mount...`);
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
|
||||
logger.debug(`${this.constructor.name}: Mounted successfully`);
|
||||
console.log(`${this.constructor.name}: Mounted successfully`);
|
||||
}
|
||||
|
||||
// Unmount the component
|
||||
@@ -294,14 +285,14 @@ class Component {
|
||||
this.cleanupEventListeners();
|
||||
this.cleanupViewModelListeners();
|
||||
|
||||
logger.debug(`${this.constructor.name} unmounted`);
|
||||
console.log(`${this.constructor.name} unmounted`);
|
||||
}
|
||||
|
||||
// Pause the component (keep alive but pause activity)
|
||||
pause() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
logger.debug(`${this.constructor.name}: Pausing component`);
|
||||
console.log(`${this.constructor.name}: Pausing component`);
|
||||
|
||||
// Pause any active timers or animations
|
||||
if (this.updateInterval) {
|
||||
@@ -320,7 +311,7 @@ class Component {
|
||||
resume() {
|
||||
if (!this.isMounted || !this.isPaused) return;
|
||||
|
||||
logger.debug(`${this.constructor.name}: Resuming component`);
|
||||
console.log(`${this.constructor.name}: Resuming component`);
|
||||
|
||||
this.isPaused = false;
|
||||
|
||||
@@ -377,7 +368,7 @@ class Component {
|
||||
// Partial update method for efficient data updates
|
||||
updatePartial(property, newValue, previousValue) {
|
||||
// Override in subclasses to implement partial updates
|
||||
logger.debug(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
|
||||
console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
|
||||
}
|
||||
|
||||
// UI State Management Methods
|
||||
@@ -466,22 +457,22 @@ class Component {
|
||||
|
||||
// Helper method to set innerHTML safely
|
||||
setHTML(selector, html) {
|
||||
logger.debug(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
|
||||
console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
|
||||
|
||||
let element;
|
||||
if (selector === '') {
|
||||
// Empty selector means set HTML on the component's container itself
|
||||
element = this.container;
|
||||
logger.debug(`${this.constructor.name}: Using component container for empty selector`);
|
||||
console.log(`${this.constructor.name}: Using component container for empty selector`);
|
||||
} else {
|
||||
// Find element within the component's container
|
||||
element = this.findElement(selector);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
logger.debug(`${this.constructor.name}: Element found, setting innerHTML`);
|
||||
console.log(`${this.constructor.name}: Element found, setting innerHTML`);
|
||||
element.innerHTML = html;
|
||||
logger.debug(`${this.constructor.name}: innerHTML set successfully`);
|
||||
console.log(`${this.constructor.name}: innerHTML set successfully`);
|
||||
} else {
|
||||
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
|
||||
}
|
||||
@@ -530,80 +521,6 @@ class Component {
|
||||
element.disabled = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Reusable render helpers
|
||||
renderLoading(customHtml) {
|
||||
const html = customHtml || `
|
||||
<div class="loading">
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
this.setHTML('', html);
|
||||
}
|
||||
|
||||
renderError(message) {
|
||||
const safe = this.escapeHtml(String(message || 'An error occurred'));
|
||||
const html = `
|
||||
<div class="error">
|
||||
<strong>Error:</strong><br>
|
||||
${safe}
|
||||
</div>
|
||||
`;
|
||||
this.setHTML('', html);
|
||||
}
|
||||
|
||||
renderEmpty(customHtml) {
|
||||
const html = customHtml || `
|
||||
<div class="empty-state">
|
||||
<div>No data</div>
|
||||
</div>
|
||||
`;
|
||||
this.setHTML('', html);
|
||||
}
|
||||
|
||||
// Basic HTML escaping for dynamic values
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Tab helpers
|
||||
setupTabs(container = this.container, options = {}) {
|
||||
const { onChange } = options;
|
||||
const tabButtons = container.querySelectorAll('.tab-button');
|
||||
const tabContents = container.querySelectorAll('.tab-content');
|
||||
tabButtons.forEach(button => {
|
||||
this.addEventListener(button, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
const targetTab = button.dataset.tab;
|
||||
this.setActiveTab(targetTab, container);
|
||||
if (typeof onChange === 'function') {
|
||||
try { onChange(targetTab); } catch (_) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
tabContents.forEach(content => {
|
||||
this.addEventListener(content, 'click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setActiveTab(tabName, container = this.container) {
|
||||
const tabButtons = container.querySelectorAll('.tab-button');
|
||||
const tabContents = container.querySelectorAll('.tab-content');
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
const activeButton = container.querySelector(`[data-tab="${tabName}"]`);
|
||||
const activeContent = container.querySelector(`#${tabName}-tab`);
|
||||
if (activeButton) activeButton.classList.add('active');
|
||||
if (activeContent) activeContent.classList.add('active');
|
||||
logger.debug(`${this.constructor.name}: Active tab set to '${tabName}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Application class to manage components and routing
|
||||
@@ -616,7 +533,7 @@ class App {
|
||||
this.navigationInProgress = false;
|
||||
this.navigationQueue = [];
|
||||
this.lastNavigationTime = 0;
|
||||
this.navigationCooldown = (window.CONSTANTS && window.CONSTANTS.TIMING.NAV_COOLDOWN_MS) || 300; // cooldown between navigations
|
||||
this.navigationCooldown = 300; // 300ms cooldown between navigations
|
||||
|
||||
// Component cache to keep components alive
|
||||
this.componentCache = new Map();
|
||||
@@ -627,8 +544,8 @@ class App {
|
||||
registerRoute(name, componentClass, containerId, viewModel = null) {
|
||||
this.routes.set(name, { componentClass, containerId, viewModel });
|
||||
|
||||
// Defer instantiation until navigation to reduce startup work
|
||||
// this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
// Pre-initialize component in cache for better performance
|
||||
this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
}
|
||||
|
||||
// Pre-initialize component in cache
|
||||
@@ -643,21 +560,21 @@ class App {
|
||||
|
||||
// Store in cache
|
||||
this.componentCache.set(name, component);
|
||||
logger.debug(`App: Pre-initialized component for route '${name}'`);
|
||||
console.log(`App: Pre-initialized component for route '${name}'`);
|
||||
}
|
||||
|
||||
// Navigate to a route
|
||||
navigateTo(routeName, updateUrl = true) {
|
||||
navigateTo(routeName) {
|
||||
// Check cooldown period
|
||||
const now = Date.now();
|
||||
if (now - this.lastNavigationTime < this.navigationCooldown) {
|
||||
logger.debug(`App: Navigation cooldown active, skipping route '${routeName}'`);
|
||||
console.log(`App: Navigation cooldown active, skipping route '${routeName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If navigation is already in progress, queue this request
|
||||
if (this.navigationInProgress) {
|
||||
logger.debug(`App: Navigation in progress, queuing route '${routeName}'`);
|
||||
console.log(`App: Navigation in progress, queuing route '${routeName}'`);
|
||||
if (!this.navigationQueue.includes(routeName)) {
|
||||
this.navigationQueue.push(routeName);
|
||||
}
|
||||
@@ -666,67 +583,32 @@ class App {
|
||||
|
||||
// If trying to navigate to the same route, do nothing
|
||||
if (this.currentView && this.currentView.routeName === routeName) {
|
||||
logger.debug(`App: Already on route '${routeName}', skipping navigation`);
|
||||
console.log(`App: Already on route '${routeName}', skipping navigation`);
|
||||
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;
|
||||
|
||||
try {
|
||||
logger.debug(`App: Navigating to route '${routeName}'`);
|
||||
console.log(`App: Navigating to route '${routeName}'`);
|
||||
const route = this.routes.get(routeName);
|
||||
if (!route) {
|
||||
console.error(`Route '${routeName}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
|
||||
console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
|
||||
|
||||
// Get or create component from cache
|
||||
let component = this.componentCache.get(routeName);
|
||||
if (!component) {
|
||||
logger.debug(`App: Component not in cache, creating new instance for '${routeName}'`);
|
||||
console.log(`App: Component not in cache, creating new instance for '${routeName}'`);
|
||||
const container = document.getElementById(route.containerId);
|
||||
if (!container) {
|
||||
console.error(`Container '${route.containerId}' not found`);
|
||||
@@ -741,12 +623,12 @@ class App {
|
||||
|
||||
// Hide current view smoothly
|
||||
if (this.currentView) {
|
||||
logger.debug('App: Hiding current view');
|
||||
console.log('App: Hiding current view');
|
||||
await this.hideCurrentView();
|
||||
}
|
||||
|
||||
// Show new view
|
||||
logger.debug(`App: Showing new view '${routeName}'`);
|
||||
console.log(`App: Showing new view '${routeName}'`);
|
||||
await this.showView(routeName, component);
|
||||
|
||||
// Update navigation state
|
||||
@@ -758,7 +640,7 @@ class App {
|
||||
// Mark view as cached for future use
|
||||
this.cachedViews.add(routeName);
|
||||
|
||||
logger.debug(`App: Navigation to '${routeName}' completed`);
|
||||
console.log(`App: Navigation to '${routeName}' completed`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('App: Navigation failed:', error);
|
||||
@@ -768,7 +650,7 @@ class App {
|
||||
// Process any queued navigation requests
|
||||
if (this.navigationQueue.length > 0) {
|
||||
const nextRoute = this.navigationQueue.shift();
|
||||
logger.debug(`App: Processing queued navigation to '${nextRoute}'`);
|
||||
console.log(`App: Processing queued navigation to '${nextRoute}'`);
|
||||
setTimeout(() => this.navigateTo(nextRoute), 100);
|
||||
}
|
||||
}
|
||||
@@ -780,47 +662,39 @@ class App {
|
||||
|
||||
// If component is mounted, pause it instead of unmounting
|
||||
if (this.currentView.isMounted) {
|
||||
logger.debug('App: Pausing current view instead of unmounting');
|
||||
console.log('App: Pausing current view instead of unmounting');
|
||||
this.currentView.pause();
|
||||
}
|
||||
|
||||
// Fade out the container
|
||||
if (this.currentView.container) {
|
||||
this.currentView.container.style.opacity = '0';
|
||||
this.currentView.container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150}ms ease-out`;
|
||||
this.currentView.container.style.transition = 'opacity 0.15s ease-out';
|
||||
}
|
||||
|
||||
// Wait for fade out to complete
|
||||
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_OUT_MS) || 150));
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// Show view smoothly
|
||||
async showView(routeName, component) {
|
||||
const container = component.container;
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Ensure component is mounted (but not necessarily active)
|
||||
if (!component.isMounted) {
|
||||
logger.debug(`App: Mounting component for '${routeName}'`);
|
||||
console.log(`App: Mounting component for '${routeName}'`);
|
||||
component.mount();
|
||||
} else {
|
||||
logger.debug(`App: Resuming component for '${routeName}'`);
|
||||
console.log(`App: Resuming component for '${routeName}'`);
|
||||
component.resume();
|
||||
}
|
||||
|
||||
// Fade in the container
|
||||
container.style.opacity = '0';
|
||||
container.style.transition = `opacity ${(window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_IN_MS) || 200}ms ease-in`;
|
||||
container.style.transition = 'opacity 0.2s ease-in';
|
||||
|
||||
// Small delay to ensure smooth transition
|
||||
await new Promise(resolve => setTimeout(resolve, (window.CONSTANTS && window.CONSTANTS.TIMING.VIEW_FADE_DELAY_MS) || 50));
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Fade in
|
||||
container.style.opacity = '1';
|
||||
@@ -829,7 +703,7 @@ class App {
|
||||
// Update navigation state
|
||||
updateNavigation(activeRoute) {
|
||||
// Remove active class from all nav tabs
|
||||
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.NAV_TAB) || '.nav-tab').forEach(tab => {
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
@@ -840,7 +714,7 @@ class App {
|
||||
}
|
||||
|
||||
// Hide all view contents with smooth transition
|
||||
document.querySelectorAll((window.CONSTANTS && window.CONSTANTS.SELECTORS.VIEW_CONTENT) || '.view-content').forEach(view => {
|
||||
document.querySelectorAll('.view-content').forEach(view => {
|
||||
view.classList.remove('active');
|
||||
view.style.opacity = '0';
|
||||
view.style.transition = 'opacity 0.15s ease-out';
|
||||
@@ -875,7 +749,7 @@ class App {
|
||||
|
||||
// Initialize the application
|
||||
init() {
|
||||
logger.debug('SPORE UI Framework initialized');
|
||||
console.log('SPORE UI Framework initialized');
|
||||
|
||||
// Note: Navigation is now handled by the app initialization
|
||||
// to ensure routes are registered before navigation
|
||||
@@ -883,36 +757,21 @@ 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', (e) => {
|
||||
e.preventDefault(); // Prevent default link behavior
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
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)
|
||||
cleanup() {
|
||||
logger.debug('App: Cleaning up cached components...');
|
||||
console.log('App: Cleaning up cached components...');
|
||||
|
||||
this.componentCache.forEach((component, routeName) => {
|
||||
if (component.isMounted) {
|
||||
logger.debug(`App: Unmounting cached component '${routeName}'`);
|
||||
console.log(`App: Unmounting cached component '${routeName}'`);
|
||||
component.unmount();
|
||||
}
|
||||
});
|
||||
@@ -1,154 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</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">
|
||||
<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 class="container">
|
||||
<div class="main-navigation">
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="cluster-status">🚀 Cluster Online</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="random-primary-switcher">
|
||||
<button class="random-primary-toggle" id="random-primary-toggle" title="Select random primary node">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M1 4v6h6M23 20v-6h-6" />
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="theme-switcher">
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<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>
|
||||
|
||||
<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">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">
|
||||
<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 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>
|
||||
</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>
|
||||
<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">
|
||||
<div class="loading">
|
||||
<div>Loading cluster members...</div>
|
||||
@@ -156,140 +49,74 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="topology-view" class="view-content">
|
||||
<div id="topology-graph-container">
|
||||
<div class="loading">
|
||||
<div>Loading network topology...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<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 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 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 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 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>
|
||||
</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="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 class="firmware-nodes-list" id="firmware-nodes-list">
|
||||
<!-- Nodes will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,381 +0,0 @@
|
||||
// API Client for communicating with the backend
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
// Auto-detect server URL based on current location
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// If accessing from localhost, use localhost:3001
|
||||
// If accessing from another device, use the same hostname but port 3001
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
this.baseUrl = 'http://localhost:3001';
|
||||
} else {
|
||||
// Use the same hostname but port 3001
|
||||
this.baseUrl = `http://${currentHost}:3001`;
|
||||
}
|
||||
|
||||
logger.debug('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 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' });
|
||||
}
|
||||
|
||||
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: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// 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,224 +0,0 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
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();
|
||||
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) => {
|
||||
logger.debug('App: Members subscription triggered:', members);
|
||||
if (members && members.length > 0) {
|
||||
// Extract node information for firmware view
|
||||
const nodes = members.map(member => ({
|
||||
ip: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
labels: member.labels || {}
|
||||
}));
|
||||
firmwareViewModel.updateAvailableNodes(nodes);
|
||||
logger.debug('App: Updated firmware view model with nodes:', nodes);
|
||||
} else {
|
||||
firmwareViewModel.updateAvailableNodes([]);
|
||||
logger.debug('App: Cleared firmware view model nodes');
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
logger.debug('App: Initializing cluster status component...');
|
||||
const clusterStatusComponent = new ClusterStatusComponent(
|
||||
document.querySelector('.cluster-status'),
|
||||
clusterViewModel,
|
||||
app.eventBus
|
||||
);
|
||||
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();
|
||||
|
||||
// Now navigate to the default route
|
||||
logger.debug('App: Navigating to default route...');
|
||||
app.navigateTo('cluster');
|
||||
|
||||
logger.debug('=== SPORE UI Application initialization completed ===');
|
||||
});
|
||||
|
||||
// Burger menu toggle for mobile
|
||||
(function setupBurgerMenu(){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const nav = document.querySelector('.main-navigation');
|
||||
const burger = document.getElementById('burger-btn');
|
||||
const navLeft = nav ? nav.querySelector('.nav-left') : null;
|
||||
if (!nav || !burger || !navLeft) return;
|
||||
burger.addEventListener('click', function(e){
|
||||
e.preventDefault();
|
||||
nav.classList.toggle('mobile-open');
|
||||
});
|
||||
// Close menu when a nav tab is clicked
|
||||
navLeft.addEventListener('click', function(e){
|
||||
const btn = e.target.closest('.nav-tab');
|
||||
if (btn && nav.classList.contains('mobile-open')) {
|
||||
nav.classList.remove('mobile-open');
|
||||
}
|
||||
});
|
||||
// Close menu on outside click
|
||||
document.addEventListener('click', function(e){
|
||||
if (!nav.contains(e.target) && nav.classList.contains('mobile-open')) {
|
||||
nav.classList.remove('mobile-open');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Set up periodic updates
|
||||
function setupPeriodicUpdates() {
|
||||
// Auto-refresh cluster members every 30 seconds using smart update
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
|
||||
// Use smart update if available, otherwise fall back to regular update
|
||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||
logger.debug('App: Performing smart update...');
|
||||
viewModel.smartUpdate();
|
||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
logger.debug('App: Performing regular update...');
|
||||
viewModel.updateClusterMembers();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state)
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') {
|
||||
viewModel.updatePrimaryNodeDisplay();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
logger.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
logger.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.app) {
|
||||
logger.debug('App: Cleaning up cached components...');
|
||||
window.app.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,16 +0,0 @@
|
||||
(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);
|
||||
})();
|
||||
});
|
||||
};
|
||||
})();
|
||||
@@ -1,177 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,711 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,354 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,811 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,67 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
// 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
|
||||
});
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,271 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,391 +0,0 @@
|
||||
// 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();
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,27 +0,0 @@
|
||||
(function(){
|
||||
const TIMING = {
|
||||
NAV_COOLDOWN_MS: 300,
|
||||
VIEW_FADE_OUT_MS: 150,
|
||||
VIEW_FADE_IN_MS: 200,
|
||||
VIEW_FADE_DELAY_MS: 50,
|
||||
AUTO_REFRESH_MS: 30000,
|
||||
PRIMARY_NODE_REFRESH_MS: 10000,
|
||||
LOAD_GUARD_MS: 10000
|
||||
};
|
||||
|
||||
const SELECTORS = {
|
||||
NAV_TAB: '.nav-tab',
|
||||
VIEW_CONTENT: '.view-content',
|
||||
CLUSTER_STATUS: '.cluster-status'
|
||||
};
|
||||
|
||||
const CLASSES = {
|
||||
CLUSTER_STATUS_ONLINE: 'cluster-status-online',
|
||||
CLUSTER_STATUS_OFFLINE: 'cluster-status-offline',
|
||||
CLUSTER_STATUS_CONNECTING: 'cluster-status-connecting',
|
||||
CLUSTER_STATUS_ERROR: 'cluster-status-error',
|
||||
CLUSTER_STATUS_DISCOVERING: 'cluster-status-discovering'
|
||||
};
|
||||
|
||||
window.CONSTANTS = window.CONSTANTS || { TIMING, SELECTORS, CLASSES };
|
||||
})();
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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 +0,0 @@
|
||||
// intentionally empty placeholder
|
||||
@@ -1,120 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
104
public/simple-test.html
Normal file
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Simple Framework Test</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button { margin: 5px; padding: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Simple Framework Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>API Test</h2>
|
||||
<button onclick="testAPI()">Test API Connection</button>
|
||||
<div id="api-result"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Framework Test</h2>
|
||||
<button onclick="testFramework()">Test Framework</button>
|
||||
<div id="framework-result"></div>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
|
||||
<script>
|
||||
async function testAPI() {
|
||||
const resultDiv = document.getElementById('api-result');
|
||||
resultDiv.innerHTML = 'Testing...';
|
||||
|
||||
try {
|
||||
// Test cluster members API
|
||||
const members = await window.apiClient.getClusterMembers();
|
||||
console.log('Members:', members);
|
||||
|
||||
// Test discovery API
|
||||
const discovery = await window.apiClient.getDiscoveryInfo();
|
||||
console.log('Discovery:', discovery);
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<div class="success">
|
||||
✅ API Test Successful!<br>
|
||||
Cluster Members: ${members.members?.length || 0}<br>
|
||||
Primary Node: ${discovery.primaryNode || 'None'}<br>
|
||||
Total Nodes: ${discovery.totalNodes || 0}
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('API test failed:', error);
|
||||
resultDiv.innerHTML = `<div class="error">❌ API Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function testFramework() {
|
||||
const resultDiv = document.getElementById('framework-result');
|
||||
resultDiv.innerHTML = 'Testing...';
|
||||
|
||||
try {
|
||||
// Test framework classes
|
||||
if (typeof EventBus !== 'undefined' &&
|
||||
typeof ViewModel !== 'undefined' &&
|
||||
typeof Component !== 'undefined') {
|
||||
|
||||
// Create a simple view model
|
||||
const vm = new ViewModel();
|
||||
vm.set('test', 'Hello World');
|
||||
|
||||
if (vm.get('test') === 'Hello World') {
|
||||
resultDiv.innerHTML = `
|
||||
<div class="success">
|
||||
✅ Framework Test Successful!<br>
|
||||
EventBus: ${typeof EventBus}<br>
|
||||
ViewModel: ${typeof ViewModel}<br>
|
||||
Component: ${typeof Component}<br>
|
||||
ViewModel test: ${vm.get('test')}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
throw new Error('ViewModel get/set not working');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Framework classes not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Framework test failed:', error);
|
||||
resultDiv.innerHTML = `<div class="error">❌ Framework Test Failed: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Page loaded, framework ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1689
public/styles.css
Normal file
351
public/test-caching-system.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - Component Caching Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.test-info {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.test-button {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
color: #4ade80;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.test-button:hover {
|
||||
background: rgba(74, 222, 128, 0.3);
|
||||
border-color: rgba(74, 222, 128, 0.5);
|
||||
}
|
||||
.test-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.test-results {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="test-info">
|
||||
<h3>🧪 Component Caching System Test</h3>
|
||||
<p>This page tests the new component caching system to verify that components are not re-rendered on view switches.</p>
|
||||
<p><strong>Note:</strong> Components now start with clean default state (collapsed cards, status tab) and don't restore previous UI state.</p>
|
||||
<div>
|
||||
<button class="test-button" onclick="testComponentCaching()">Test Component Caching</button>
|
||||
<button class="test-button" onclick="testDefaultState()">Test Default State</button>
|
||||
<button class="test-button" onclick="testPerformance()">Test Performance</button>
|
||||
<button class="test-button" onclick="clearTestData()">Clear Test Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-navigation">
|
||||
<div class="nav-left">
|
||||
<button class="nav-tab active" data-view="cluster">🌐 Cluster</button>
|
||||
<button class="nav-tab" data-view="firmware">📦 Firmware</button>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<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>
|
||||
<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">
|
||||
<div class="loading">
|
||||
<div>Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firmware-view" class="view-content">
|
||||
<div class="firmware-section">
|
||||
<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>
|
||||
</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 class="test-results" id="test-results">
|
||||
<h4>Test Results:</h4>
|
||||
<div id="test-output">Run a test to see results...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
<script>
|
||||
// Test tracking variables
|
||||
let componentCreationCount = 0;
|
||||
let componentMountCount = 0;
|
||||
let componentUnmountCount = 0;
|
||||
let componentPauseCount = 0;
|
||||
let componentResumeCount = 0;
|
||||
let testStartTime = 0;
|
||||
|
||||
// Override console.log to track component operations
|
||||
const originalLog = console.log;
|
||||
console.log = function(...args) {
|
||||
const message = args.join(' ');
|
||||
|
||||
// Track component operations
|
||||
if (message.includes('Constructor called')) {
|
||||
componentCreationCount++;
|
||||
} else if (message.includes('Mounting...')) {
|
||||
componentMountCount++;
|
||||
} else if (message.includes('Unmounting...')) {
|
||||
componentUnmountCount++;
|
||||
} else if (message.includes('Pausing...')) {
|
||||
componentPauseCount++;
|
||||
} else if (message.includes('Resuming...')) {
|
||||
componentResumeCount++;
|
||||
}
|
||||
|
||||
// Call original console.log
|
||||
originalLog.apply(console, args);
|
||||
};
|
||||
|
||||
// Test functions
|
||||
function testComponentCaching() {
|
||||
console.log('🧪 Testing component caching system...');
|
||||
resetTestCounts();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing component caching...<br>';
|
||||
|
||||
// Test rapid view switching
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
const firmwareTab = document.querySelector('[data-view="firmware"]');
|
||||
|
||||
let switchCount = 0;
|
||||
const maxSwitches = 10;
|
||||
|
||||
const rapidSwitch = setInterval(() => {
|
||||
if (switchCount >= maxSwitches) {
|
||||
clearInterval(rapidSwitch);
|
||||
analyzeResults();
|
||||
return;
|
||||
}
|
||||
|
||||
if (switchCount % 2 === 0) {
|
||||
firmwareTab.click();
|
||||
results.innerHTML += `Switch ${switchCount + 1}: Cluster → Firmware<br>`;
|
||||
} else {
|
||||
clusterTab.click();
|
||||
results.innerHTML += `Switch ${switchCount + 1}: Firmware → Cluster<br>`;
|
||||
}
|
||||
|
||||
switchCount++;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function testDefaultState() {
|
||||
console.log('🧪 Testing default state...');
|
||||
resetTestCounts();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing default state...<br>';
|
||||
|
||||
// Switch to cluster view
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
clusterTab.click();
|
||||
results.innerHTML += 'Switched to Cluster View.<br>';
|
||||
|
||||
// Check if default state is applied (collapsed cards, status tab)
|
||||
setTimeout(() => {
|
||||
const memberCards = document.querySelectorAll('.member-card');
|
||||
const statusTab = document.querySelector('.nav-tab.active[data-view="status"]');
|
||||
|
||||
if (memberCards.length > 0) {
|
||||
results.innerHTML += 'Checking default state:<br>';
|
||||
results.innerHTML += `- Member cards are collapsed: ${memberCards.every(card => !card.classList.contains('expanded'))}<br>`;
|
||||
results.innerHTML += `- Status tab is active: ${statusTab && statusTab.classList.contains('active')}<br>`;
|
||||
analyzeResults();
|
||||
} else {
|
||||
results.innerHTML += 'No member cards found to check default state<br>';
|
||||
analyzeResults();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function testPerformance() {
|
||||
console.log('🧪 Testing performance...');
|
||||
resetTestCounts();
|
||||
testStartTime = performance.now();
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Testing performance with rapid switching...<br>';
|
||||
|
||||
// Perform rapid view switching
|
||||
const clusterTab = document.querySelector('[data-view="cluster"]');
|
||||
const firmwareTab = document.querySelector('[data-view="firmware"]');
|
||||
|
||||
let switchCount = 0;
|
||||
const maxSwitches = 20;
|
||||
|
||||
const performanceTest = setInterval(() => {
|
||||
if (switchCount >= maxSwitches) {
|
||||
clearInterval(performanceTest);
|
||||
const totalTime = performance.now() - testStartTime;
|
||||
results.innerHTML += `Performance test completed in ${totalTime.toFixed(2)}ms<br>`;
|
||||
analyzeResults();
|
||||
return;
|
||||
}
|
||||
|
||||
if (switchCount % 2 === 0) {
|
||||
firmwareTab.click();
|
||||
} else {
|
||||
clusterTab.click();
|
||||
}
|
||||
|
||||
switchCount++;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function resetTestCounts() {
|
||||
componentCreationCount = 0;
|
||||
componentMountCount = 0;
|
||||
componentUnmountCount = 0;
|
||||
componentPauseCount = 0;
|
||||
componentResumeCount = 0;
|
||||
}
|
||||
|
||||
function analyzeResults() {
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML += '<br><strong>Test Analysis:</strong><br>';
|
||||
results.innerHTML += `Component Creations: ${componentCreationCount}<br>`;
|
||||
results.innerHTML += `Component Mounts: ${componentMountCount}<br>`;
|
||||
results.innerHTML += `Component Unmounts: ${componentUnmountCount}<br>`;
|
||||
results.innerHTML += `Component Pauses: ${componentPauseCount}<br>`;
|
||||
results.innerHTML += `Component Resumes: ${componentResumeCount}<br><br>`;
|
||||
|
||||
// Analyze results
|
||||
if (componentCreationCount <= 2) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components are properly cached (not re-created)<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being re-created on view switches<br>';
|
||||
}
|
||||
|
||||
if (componentUnmountCount === 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components are never unmounted during view switches<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Components are being unmounted during view switches<br>';
|
||||
}
|
||||
|
||||
if (componentPauseCount > 0 && componentResumeCount > 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Pause/Resume pattern is working correctly<br>';
|
||||
} else {
|
||||
results.innerHTML += '❌ <strong>FAIL:</strong> Pause/Resume pattern is not working<br>';
|
||||
}
|
||||
|
||||
// New test for default state behavior
|
||||
if (componentCreationCount <= 2 && componentUnmountCount === 0) {
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Component caching system is working correctly<br>';
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> Components start with clean default state<br>';
|
||||
results.innerHTML += '✅ <strong>PASS:</strong> No complex state restoration causing issues<br>';
|
||||
}
|
||||
}
|
||||
|
||||
function clearTestData() {
|
||||
console.log('🧪 Clearing test data...');
|
||||
localStorage.removeItem('spore_cluster_expanded_cards');
|
||||
localStorage.removeItem('spore_cluster_active_tabs');
|
||||
console.log('🧪 Test data cleared');
|
||||
|
||||
const results = document.getElementById('test-output');
|
||||
results.innerHTML = 'Test data cleared. Run a test to see results...';
|
||||
}
|
||||
|
||||
// Add test info to console
|
||||
console.log('🧪 SPORE UI Component Caching Test Page Loaded');
|
||||
console.log('🧪 Use the test buttons above to verify the caching system works');
|
||||
console.log('🧪 Expected: Components should be created once and cached, never re-created');
|
||||
console.log('🧪 Expected: Components start with clean default state (collapsed cards, status tab)');
|
||||
console.log('🧪 Expected: No complex state restoration causing incorrect behavior');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
351
public/test-deploy-button.html
Normal file
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deploy Button Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #1a202c;
|
||||
color: white;
|
||||
}
|
||||
.test-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.firmware-actions {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.target-options {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-input-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.deploy-btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.deploy-btn:disabled {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.node-select {
|
||||
background: #2d3748;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.no-nodes-message {
|
||||
color: #fbbf24;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
border-radius: 4px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
border: 1px solid rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
.cluster-members {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.member-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.status-online {
|
||||
color: #4ade80;
|
||||
}
|
||||
.status-offline {
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🚀 Deploy Button Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Scenario: Deploy Button State</h2>
|
||||
<p>This test demonstrates the deploy button behavior when:</p>
|
||||
<ul>
|
||||
<li>No file is selected</li>
|
||||
<li>No nodes are available</li>
|
||||
<li>File is selected but no target is chosen</li>
|
||||
<li>File is selected and target is chosen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="firmware-actions">
|
||||
<h3>🚀 Firmware Update</h3>
|
||||
|
||||
<div class="target-options">
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="all" checked>
|
||||
<span>All Nodes</span>
|
||||
</label>
|
||||
<label class="target-option">
|
||||
<input type="radio" name="target-type" value="specific">
|
||||
<span>Specific Node</span>
|
||||
<select id="specific-node-select" class="node-select" style="visibility: hidden; opacity: 0;">
|
||||
<option value="">Select a node...</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="global-firmware-file" accept=".bin,.hex" style="display: none;">
|
||||
<button onclick="document.getElementById('global-firmware-file').click()">
|
||||
📁 Choose File
|
||||
</button>
|
||||
<span id="file-info">No file selected</span>
|
||||
</div>
|
||||
|
||||
<button class="deploy-btn" id="deploy-btn" disabled>
|
||||
🚀 Deploy Firmware
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cluster-members">
|
||||
<h3>Cluster Members</h3>
|
||||
<div id="cluster-members-container">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
<button onclick="addTestNode()">Add Test Node</button>
|
||||
<button onclick="removeAllNodes()">Remove All Nodes</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Instructions</h2>
|
||||
<ol>
|
||||
<li>Select "Specific Node" radio button - notice the deploy button remains disabled</li>
|
||||
<li>Click "Add Test Node" to simulate cluster discovery</li>
|
||||
<li>Select "Specific Node" again - now you should see nodes in the dropdown</li>
|
||||
<li>Select a file - deploy button should remain disabled until you select a node</li>
|
||||
<li>Select a specific node - deploy button should now be enabled</li>
|
||||
<li>Click "Remove All Nodes" to test the "no nodes available" state</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simulate the cluster members functionality
|
||||
let testNodes = [];
|
||||
|
||||
function addTestNode() {
|
||||
const nodeCount = testNodes.length + 1;
|
||||
const newNode = {
|
||||
ip: `192.168.1.${100 + nodeCount}`,
|
||||
hostname: `TestNode${nodeCount}`,
|
||||
status: 'active',
|
||||
latency: Math.floor(Math.random() * 50) + 10
|
||||
};
|
||||
testNodes.push(newNode);
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
}
|
||||
|
||||
function removeAllNodes() {
|
||||
testNodes = [];
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
}
|
||||
|
||||
function displayClusterMembers() {
|
||||
const container = document.getElementById('cluster-members-container');
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
container.innerHTML = '<div class="loading">No cluster members found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const membersHTML = testNodes.map(node => {
|
||||
const statusClass = node.status === 'active' ? 'status-online' : 'status-offline';
|
||||
const statusText = node.status === 'active' ? 'Online' : 'Offline';
|
||||
const statusIcon = node.status === 'active' ? '🟢' : '🔴';
|
||||
|
||||
return `
|
||||
<div class="member-card" data-member-ip="${node.ip}">
|
||||
<div class="member-name">${node.hostname}</div>
|
||||
<div class="member-ip">${node.ip}</div>
|
||||
<div class="member-status ${statusClass}">
|
||||
${statusIcon} ${statusText}
|
||||
</div>
|
||||
<div class="member-latency">Latency: ${node.latency}ms</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = membersHTML;
|
||||
}
|
||||
|
||||
function populateNodeSelect() {
|
||||
const select = document.getElementById('specific-node-select');
|
||||
if (!select) return;
|
||||
|
||||
select.innerHTML = '<option value="">Select a node...</option>';
|
||||
|
||||
if (testNodes.length === 0) {
|
||||
const option = document.createElement('option');
|
||||
option.value = "";
|
||||
option.textContent = "No nodes available";
|
||||
option.disabled = true;
|
||||
select.appendChild(option);
|
||||
return;
|
||||
}
|
||||
|
||||
testNodes.forEach(node => {
|
||||
const option = document.createElement('option');
|
||||
option.value = node.ip;
|
||||
option.textContent = `${node.hostname} (${node.ip})`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function updateDeployButton() {
|
||||
const deployBtn = document.getElementById('deploy-btn');
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
const targetType = document.querySelector('input[name="target-type"]:checked');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
if (!deployBtn || !fileInput) return;
|
||||
|
||||
const hasFile = fileInput.files && fileInput.files.length > 0;
|
||||
const hasAvailableNodes = testNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (targetType.value === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (targetType.value === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && specificNodeSelect.value && specificNodeSelect.value !== "";
|
||||
}
|
||||
|
||||
deployBtn.disabled = !hasFile || !isValidTarget;
|
||||
|
||||
// Update button text to provide better feedback
|
||||
if (!hasAvailableNodes) {
|
||||
deployBtn.textContent = '🚀 Deploy (No nodes available)';
|
||||
deployBtn.title = 'No cluster nodes are currently available for deployment';
|
||||
} else if (!hasFile) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a firmware file to deploy';
|
||||
} else if (!isValidTarget) {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Please select a valid target for deployment';
|
||||
} else {
|
||||
deployBtn.textContent = '🚀 Deploy Firmware';
|
||||
deployBtn.title = 'Ready to deploy firmware';
|
||||
}
|
||||
}
|
||||
|
||||
// Setup event listeners
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Setup target selection
|
||||
const targetRadios = document.querySelectorAll('input[name="target-type"]');
|
||||
const specificNodeSelect = document.getElementById('specific-node-select');
|
||||
|
||||
targetRadios.forEach(radio => {
|
||||
radio.addEventListener('change', () => {
|
||||
if (radio.value === 'specific') {
|
||||
specificNodeSelect.style.visibility = 'visible';
|
||||
specificNodeSelect.style.opacity = '1';
|
||||
populateNodeSelect();
|
||||
|
||||
// Check if there are any nodes available and show appropriate message
|
||||
if (testNodes.length === 0) {
|
||||
// Show a message that no nodes are available
|
||||
const noNodesMsg = document.createElement('div');
|
||||
noNodesMsg.className = 'no-nodes-message';
|
||||
noNodesMsg.textContent = 'No cluster nodes are currently available';
|
||||
|
||||
// Remove any existing message
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
|
||||
specificNodeSelect.parentNode.appendChild(noNodesMsg);
|
||||
} else {
|
||||
// Remove any existing no-nodes message
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
specificNodeSelect.style.visibility = 'hidden';
|
||||
specificNodeSelect.style.opacity = '0';
|
||||
|
||||
// Remove any no-nodes message when hiding
|
||||
const existingMsg = specificNodeSelect.parentNode.querySelector('.no-nodes-message');
|
||||
if (existingMsg) {
|
||||
existingMsg.remove();
|
||||
}
|
||||
}
|
||||
updateDeployButton();
|
||||
});
|
||||
});
|
||||
|
||||
// Setup specific node select change handler
|
||||
if (specificNodeSelect) {
|
||||
specificNodeSelect.addEventListener('change', updateDeployButton);
|
||||
}
|
||||
|
||||
// Setup file input change handler
|
||||
const fileInput = document.getElementById('global-firmware-file');
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener('change', (event) => {
|
||||
const file = event.target.files[0];
|
||||
const fileInfo = document.getElementById('file-info');
|
||||
|
||||
if (file) {
|
||||
fileInfo.textContent = `${file.name} (${(file.size / 1024).toFixed(1)}KB)`;
|
||||
} else {
|
||||
fileInfo.textContent = 'No file selected';
|
||||
}
|
||||
|
||||
updateDeployButton();
|
||||
});
|
||||
}
|
||||
|
||||
// Initial setup
|
||||
displayClusterMembers();
|
||||
populateNodeSelect();
|
||||
updateDeployButton();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
131
public/test-framework.html
Normal file
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Framework Test</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; }
|
||||
.success { color: green; }
|
||||
.error { color: red; }
|
||||
button { margin: 5px; padding: 10px; }
|
||||
input { margin: 5px; padding: 5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SPORE UI Framework Test</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Framework Initialization Test</h2>
|
||||
<div id="framework-status">Checking...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Event Bus Test</h2>
|
||||
<button id="publish-btn">Publish Test Event</button>
|
||||
<div id="event-log"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>View Model Test</h2>
|
||||
<input type="text" id="name-input" placeholder="Enter name">
|
||||
<button id="update-btn">Update Name</button>
|
||||
<div id="name-display">Name: (not set)</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Component Test</h2>
|
||||
<div id="test-component">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
<button id="refresh-btn">Refresh Component</button>
|
||||
</div>
|
||||
|
||||
<script src="framework.js"></script>
|
||||
<script>
|
||||
// Test framework initialization
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Testing framework...');
|
||||
|
||||
// Test 1: Framework initialization
|
||||
if (window.app && window.app.eventBus) {
|
||||
document.getElementById('framework-status').innerHTML =
|
||||
'<span class="success">✅ Framework initialized successfully</span>';
|
||||
} else {
|
||||
document.getElementById('framework-status').innerHTML =
|
||||
'<span class="error">❌ Framework failed to initialize</span>';
|
||||
}
|
||||
|
||||
// Test 2: Event Bus
|
||||
const eventLog = document.getElementById('event-log');
|
||||
const unsubscribe = window.app.eventBus.subscribe('test-event', (data) => {
|
||||
eventLog.innerHTML += `<div>📡 Event received: ${JSON.stringify(data)}</div>`;
|
||||
});
|
||||
|
||||
document.getElementById('publish-btn').addEventListener('click', () => {
|
||||
window.app.eventBus.publish('test-event', {
|
||||
message: 'Hello from test!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Test 3: View Model
|
||||
const testVM = new ViewModel();
|
||||
testVM.setEventBus(window.app.eventBus);
|
||||
|
||||
testVM.subscribe('name', (value) => {
|
||||
document.getElementById('name-display').textContent = `Name: ${value || '(not set)'}`;
|
||||
});
|
||||
|
||||
document.getElementById('update-btn').addEventListener('click', () => {
|
||||
const name = document.getElementById('name-input').value;
|
||||
testVM.set('name', name);
|
||||
});
|
||||
|
||||
// Test 4: Component
|
||||
class TestComponent extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const refreshBtn = document.getElementById('refresh-btn');
|
||||
if (refreshBtn) {
|
||||
this.addEventListener(refreshBtn, 'click', this.handleRefresh.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const data = this.viewModel ? this.viewModel.get('data') : null;
|
||||
if (data) {
|
||||
this.setHTML('', `<div class="success">✅ Component data: ${data}</div>`);
|
||||
} else {
|
||||
this.setHTML('', `<div class="loading">Loading component data...</div>`);
|
||||
}
|
||||
}
|
||||
|
||||
handleRefresh() {
|
||||
if (this.viewModel) {
|
||||
this.viewModel.set('data', `Refreshed at ${new Date().toLocaleTimeString()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testComponentVM = new ViewModel();
|
||||
testComponentVM.setEventBus(window.app.eventBus);
|
||||
testComponentVM.set('data', 'Initial component data');
|
||||
|
||||
const testComponent = new TestComponent(
|
||||
document.getElementById('test-component'),
|
||||
testComponentVM,
|
||||
window.app.eventBus
|
||||
);
|
||||
|
||||
testComponent.mount();
|
||||
|
||||
console.log('Framework test completed');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
192
public/test-refresh.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Refresh Button</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; background: #1a1a1a; color: white; }
|
||||
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #333; border-radius: 8px; }
|
||||
.log { background: #2a2a2a; padding: 10px; margin: 10px 0; font-family: monospace; border-radius: 4px; max-height: 300px; overflow-y: auto; }
|
||||
.test-button { background: #4a90e2; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin: 5px; }
|
||||
.test-button:hover { background: #357abd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🔍 Test Refresh Button Functionality</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Test Controls</h3>
|
||||
<button class="test-button" onclick="testRefreshButton()">🧪 Test Refresh Button</button>
|
||||
<button class="test-button" onclick="testAPICall()">📡 Test API Call</button>
|
||||
<button class="test-button" onclick="testComponent()">🧩 Test Component</button>
|
||||
<button class="test-button" onclick="clearLog()">🧹 Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Cluster View (Simplified)</h3>
|
||||
<div id="cluster-view" class="cluster-container">
|
||||
<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>
|
||||
</div>
|
||||
</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">
|
||||
<div class="loading">Loading cluster members...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h3>Debug Log</h3>
|
||||
<div id="debug-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
let debugLog = [];
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = `[${timestamp}] ${type.toUpperCase()}: ${message}`;
|
||||
debugLog.push(logEntry);
|
||||
|
||||
const logElement = document.getElementById('debug-log');
|
||||
if (logElement) {
|
||||
logElement.innerHTML = debugLog.map(entry => `<div>${entry}</div>`).join('');
|
||||
logElement.scrollTop = logElement.scrollHeight;
|
||||
}
|
||||
|
||||
console.log(logEntry);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
debugLog = [];
|
||||
document.getElementById('debug-log').innerHTML = '';
|
||||
}
|
||||
|
||||
function testRefreshButton() {
|
||||
log('Testing refresh button functionality...');
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-cluster-btn');
|
||||
if (refreshBtn) {
|
||||
log('Found refresh button, testing click event...');
|
||||
|
||||
// Test if the button is clickable
|
||||
refreshBtn.click();
|
||||
log('Refresh button clicked');
|
||||
|
||||
// Check if the button state changed
|
||||
setTimeout(() => {
|
||||
if (refreshBtn.disabled) {
|
||||
log('Button was disabled (good sign)', 'success');
|
||||
} else {
|
||||
log('Button was not disabled (potential issue)', 'warning');
|
||||
}
|
||||
|
||||
// Check button text
|
||||
if (refreshBtn.innerHTML.includes('Refreshing')) {
|
||||
log('Button text changed to "Refreshing" (good sign)', 'success');
|
||||
} else {
|
||||
log('Button text did not change (potential issue)', 'warning');
|
||||
}
|
||||
}, 100);
|
||||
|
||||
} else {
|
||||
log('Refresh button not found!', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function testAPICall() {
|
||||
log('Testing API call to cluster members endpoint...');
|
||||
|
||||
fetch('http://localhost:3001/api/cluster/members')
|
||||
.then(response => {
|
||||
log(`API response status: ${response.status}`, 'info');
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
log(`API response data: ${JSON.stringify(data, null, 2)}`, 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
log(`API call failed: ${error.message}`, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function testComponent() {
|
||||
log('Testing component initialization...');
|
||||
|
||||
try {
|
||||
// Create a simple test component
|
||||
const container = document.getElementById('cluster-view');
|
||||
const viewModel = new ClusterViewModel();
|
||||
const component = new ClusterMembersComponent(container, viewModel, new EventBus());
|
||||
|
||||
log('Component created successfully', 'success');
|
||||
log(`Component container: ${!!component.container}`, 'info');
|
||||
log(`Component viewModel: ${!!component.viewModel}`, 'info');
|
||||
|
||||
// Test mounting
|
||||
component.mount();
|
||||
log('Component mounted successfully', 'success');
|
||||
|
||||
// Test finding elements
|
||||
const refreshBtn = component.findElement('.refresh-btn');
|
||||
log(`Found refresh button: ${!!refreshBtn}`, 'info');
|
||||
|
||||
// Test event listener setup
|
||||
component.setupEventListeners();
|
||||
log('Event listeners set up successfully', 'success');
|
||||
|
||||
// Clean up
|
||||
component.unmount();
|
||||
log('Component unmounted successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
log(`Component test failed: ${error.message}`, 'error');
|
||||
console.error('Component test error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('Page loaded, ready for testing');
|
||||
|
||||
// Test if the refresh button exists
|
||||
const refreshBtn = document.getElementById('refresh-cluster-btn');
|
||||
if (refreshBtn) {
|
||||
log('Refresh button found on page load', 'success');
|
||||
} else {
|
||||
log('Refresh button NOT found on page load', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
log(`Global error: ${event.error}`, 'error');
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
log(`Unhandled promise rejection: ${event.reason}`, 'error');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
419
public/test-state-preservation.html
Normal file
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SPORE UI - State Preservation Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<style>
|
||||
.test-panel {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.test-button.danger {
|
||||
background: #dc3545;
|
||||
}
|
||||
|
||||
.test-button.danger:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.test-button.success {
|
||||
background: #28a745;
|
||||
}
|
||||
|
||||
.test-button.success:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
background: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.test-info h4 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.state-indicator {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.state-preserved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.state-lost {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.log-info { background: #d1ecf1; color: #0c5460; }
|
||||
.log-success { background: #d4edda; color: #155724; }
|
||||
.log-warning { background: #fff3cd; color: #856404; }
|
||||
.log-error { background: #f8d7da; color: #721c24; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🧪 SPORE UI State Preservation Test</h1>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Test Controls</h3>
|
||||
<div class="test-controls">
|
||||
<button class="test-button" onclick="testStatePreservation()">
|
||||
🔄 Test Data Refresh (Preserve State)
|
||||
</button>
|
||||
<button class="test-button danger" onclick="testFullRerender()">
|
||||
🗑️ Test Full Re-render (Lose State)
|
||||
</button>
|
||||
<button class="test-button success" onclick="expandAllCards()">
|
||||
📖 Expand All Cards
|
||||
</button>
|
||||
<button class="test-button" onclick="changeAllTabs()">
|
||||
🏷️ Change All Tabs
|
||||
</button>
|
||||
<button class="test-button" onclick="testManualDataLoad()">
|
||||
📡 Test Manual Data Load
|
||||
</button>
|
||||
<button class="test-button" onclick="debugComponentState()">
|
||||
🐛 Debug Component State
|
||||
</button>
|
||||
<button class="test-button" onclick="testManualRefresh()">
|
||||
🔧 Test Manual Refresh
|
||||
</button>
|
||||
<button class="test-button" onclick="clearLog()">
|
||||
🧹 Clear Log
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-info">
|
||||
<h4>What This Test Demonstrates:</h4>
|
||||
<ul>
|
||||
<li><strong>State Preservation:</strong> When data is refreshed, expanded cards and active tabs are maintained</li>
|
||||
<li><strong>Partial Updates:</strong> Only changed data is updated, not entire components</li>
|
||||
<li><strong>UI State Persistence:</strong> User interactions (expanded cards, active tabs) are preserved across refreshes</li>
|
||||
<li><strong>Smart Updates:</strong> The system detects when data has actually changed and only updates what's necessary</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Current State Indicators</h3>
|
||||
<div>
|
||||
<strong>Expanded Cards:</strong>
|
||||
<span class="state-indicator" id="expanded-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Active Tabs:</strong>
|
||||
<span class="state-indicator" id="active-tabs-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Last Update:</strong>
|
||||
<span class="state-indicator" id="last-update">Never</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-panel">
|
||||
<h3>Test Log</h3>
|
||||
<div class="log-container" id="test-log">
|
||||
<div class="log-entry log-info">Test log initialized. Use the test controls above to test state preservation.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include the actual SPORE UI components for testing -->
|
||||
<div id="cluster-view" class="view-content active">
|
||||
<div class="primary-node-info">
|
||||
<h3>Primary Node</h3>
|
||||
<div id="primary-node-ip">🔍 Discovering...</div>
|
||||
<button class="primary-node-refresh">🔄 Refresh</button>
|
||||
</div>
|
||||
|
||||
<div id="cluster-members-container">
|
||||
<h3>Cluster Members</h3>
|
||||
<button class="refresh-btn">🔄 Refresh Members</button>
|
||||
<div id="members-list">
|
||||
<!-- Members will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Include SPORE UI framework and components -->
|
||||
<script src="framework.js"></script>
|
||||
<script src="view-models.js"></script>
|
||||
<script src="components.js"></script>
|
||||
<script src="api-client.js"></script>
|
||||
|
||||
<script>
|
||||
// Test state preservation functionality
|
||||
let testLog = [];
|
||||
let expandedCardsCount = 0;
|
||||
let activeTabsCount = 0;
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = document.createElement('div');
|
||||
logEntry.className = `log-entry log-${type}`;
|
||||
logEntry.textContent = `[${timestamp}] ${message}`;
|
||||
|
||||
const logContainer = document.getElementById('test-log');
|
||||
logContainer.appendChild(logEntry);
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
|
||||
testLog.push({ timestamp, message, type });
|
||||
}
|
||||
|
||||
function updateStateIndicators() {
|
||||
document.getElementById('expanded-count').textContent = expandedCardsCount;
|
||||
document.getElementById('active-tabs-count').textContent = activeTabsCount;
|
||||
document.getElementById('last-update').textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function testStatePreservation() {
|
||||
log('🧪 Testing state preservation during data refresh...', 'info');
|
||||
|
||||
// Simulate a data refresh that preserves state
|
||||
setTimeout(() => {
|
||||
log('✅ Data refresh completed with state preservation', 'success');
|
||||
log('📊 Expanded cards maintained: ' + expandedCardsCount, 'info');
|
||||
log('🏷️ Active tabs maintained: ' + activeTabsCount, 'info');
|
||||
updateStateIndicators();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function testFullRerender() {
|
||||
log('🗑️ Testing full re-render (this would lose state in old system)...', 'warning');
|
||||
|
||||
// Simulate what would happen in the old system
|
||||
setTimeout(() => {
|
||||
log('❌ Full re-render completed - state would be lost in old system', 'error');
|
||||
log('💡 In new system, this preserves state automatically', 'info');
|
||||
updateStateIndicators();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function expandAllCards() {
|
||||
log('📖 Expanding all cluster member cards...', 'info');
|
||||
expandedCardsCount = 3; // Simulate 3 expanded cards
|
||||
updateStateIndicators();
|
||||
log('✅ All cards expanded. State will be preserved during refreshes.', 'success');
|
||||
}
|
||||
|
||||
function changeAllTabs() {
|
||||
log('🏷️ Changing all active tabs to different values...', 'info');
|
||||
activeTabsCount = 3; // Simulate 3 active tabs
|
||||
updateStateIndicators();
|
||||
log('✅ All tabs changed. Active tab states will be preserved during refreshes.', 'success');
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('test-log').innerHTML = '';
|
||||
testLog = [];
|
||||
log('🧹 Test log cleared', 'info');
|
||||
}
|
||||
|
||||
// Test manual data loading
|
||||
async function testManualDataLoad() {
|
||||
log('📡 Testing manual data load...', 'info');
|
||||
|
||||
try {
|
||||
// Test if we can manually trigger the cluster view model
|
||||
if (window.app && window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
log('✅ Found cluster view model, attempting to load data...', 'info');
|
||||
|
||||
if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
await viewModel.updateClusterMembers();
|
||||
log('✅ Manual data load completed', 'success');
|
||||
} else {
|
||||
log('❌ updateClusterMembers method not found on view model', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster view model found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Manual data load failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Debug component state
|
||||
function debugComponentState() {
|
||||
log('🐛 Debugging component state...', 'info');
|
||||
|
||||
try {
|
||||
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
|
||||
const component = window.app.currentView.clusterMembersComponent;
|
||||
log('✅ Found cluster members component, checking state...', 'info');
|
||||
|
||||
if (component.debugState && typeof component.debugState === 'function') {
|
||||
const state = component.debugState();
|
||||
log('📊 Component state:', 'info');
|
||||
log(` - Members: ${state.members?.length || 0}`, 'info');
|
||||
log(` - Loading: ${state.isLoading}`, 'info');
|
||||
log(` - Error: ${state.error || 'none'}`, 'info');
|
||||
log(` - Expanded cards: ${state.expandedCards?.size || 0}`, 'info');
|
||||
log(` - Active tabs: ${state.activeTabs?.size || 0}`, 'info');
|
||||
} else {
|
||||
log('❌ debugState method not found on component', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster members component found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Debug failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Test manual refresh
|
||||
async function testManualRefresh() {
|
||||
log('🔧 Testing manual refresh...', 'info');
|
||||
|
||||
try {
|
||||
if (window.app && window.app.currentView && window.app.currentView.clusterMembersComponent) {
|
||||
const component = window.app.currentView.clusterMembersComponent;
|
||||
log('✅ Found cluster members component, testing manual refresh...', 'info');
|
||||
|
||||
if (component.manualRefresh && typeof component.manualRefresh === 'function') {
|
||||
await component.manualRefresh();
|
||||
log('✅ Manual refresh completed', 'success');
|
||||
} else {
|
||||
log('❌ manualRefresh method not found on component', 'error');
|
||||
}
|
||||
} else {
|
||||
log('❌ No cluster members component found', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ Manual refresh failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize test
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
log('🚀 SPORE UI State Preservation Test initialized', 'success');
|
||||
log('💡 This demonstrates how the new system preserves UI state during data refreshes', 'info');
|
||||
updateStateIndicators();
|
||||
|
||||
// Test API client functionality
|
||||
testAPIClient();
|
||||
});
|
||||
|
||||
// Test API client functionality
|
||||
async function testAPIClient() {
|
||||
try {
|
||||
log('🧪 Testing API client functionality...', 'info');
|
||||
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
log(`✅ API client test successful. Found ${response.members?.length || 0} cluster members`, 'success');
|
||||
|
||||
if (response.members && response.members.length > 0) {
|
||||
response.members.forEach(member => {
|
||||
log(`📱 Member: ${member.hostname || member.ip} (${member.status})`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Test discovery info
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
log(`🔍 Discovery info: Primary node ${discoveryInfo.primaryNode || 'none'}, Total nodes: ${discoveryInfo.totalNodes}`, 'info');
|
||||
|
||||
} catch (error) {
|
||||
log(`❌ API client test failed: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock API client for testing
|
||||
if (!window.apiClient) {
|
||||
window.apiClient = {
|
||||
getClusterMembers: async () => {
|
||||
return {
|
||||
members: [
|
||||
{ ip: '192.168.1.100', hostname: 'Node-1', status: 'active', latency: 15 },
|
||||
{ ip: '192.168.1.101', hostname: 'Node-2', status: 'active', latency: 22 },
|
||||
{ ip: '192.168.1.102', hostname: 'Node-3', status: 'offline', latency: null }
|
||||
]
|
||||
};
|
||||
},
|
||||
getDiscoveryInfo: async () => {
|
||||
return {
|
||||
primaryNode: '192.168.1.100',
|
||||
clientInitialized: true,
|
||||
totalNodes: 3
|
||||
};
|
||||
},
|
||||
getNodeStatus: async (ip) => {
|
||||
return {
|
||||
freeHeap: 102400,
|
||||
chipId: 'ESP32-' + ip.split('.').pop(),
|
||||
sdkVersion: 'v4.4.2',
|
||||
cpuFreqMHz: 240,
|
||||
flashChipSize: 4194304,
|
||||
api: [
|
||||
{ method: 1, uri: '/status' },
|
||||
{ method: 2, uri: '/config' }
|
||||
]
|
||||
};
|
||||
},
|
||||
getTasksStatus: async () => {
|
||||
return [
|
||||
{ name: 'Heartbeat', running: true, interval: 5000, enabled: true },
|
||||
{ name: 'DataSync', running: false, interval: 30000, enabled: true }
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
public/test-tabs.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tab Test</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Tab Active State Test</h1>
|
||||
|
||||
<div class="tabs-container">
|
||||
<div class="tabs-header">
|
||||
<button class="tab-button active" data-tab="status">Status</button>
|
||||
<button class="tab-button" data-tab="endpoints">Endpoints</button>
|
||||
<button class="tab-button" data-tab="tasks">Tasks</button>
|
||||
<button class="tab-button" data-tab="firmware">Firmware</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="status-tab">
|
||||
<h3>Status Tab</h3>
|
||||
<p>This is the status tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="endpoints-tab">
|
||||
<h3>Endpoints Tab</h3>
|
||||
<p>This is the endpoints tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tasks-tab">
|
||||
<h3>Tasks Tab</h3>
|
||||
<p>This is the tasks tab content.</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="firmware-tab">
|
||||
<h3>Firmware Tab</h3>
|
||||
<p>This is the firmware tab content.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Simple tab functionality test
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.tab-button');
|
||||
const tabContents = document.querySelectorAll('.tab-content');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const targetTab = this.dataset.tab;
|
||||
|
||||
// Remove active class from all buttons and contents
|
||||
tabButtons.forEach(btn => btn.classList.remove('active'));
|
||||
tabContents.forEach(content => content.classList.remove('active'));
|
||||
|
||||
// Add active class to clicked button and corresponding content
|
||||
this.classList.add('active');
|
||||
const targetContent = document.querySelector(`#${targetTab}-tab`);
|
||||
if (targetContent) {
|
||||
targetContent.classList.add('active');
|
||||
}
|
||||
|
||||
console.log('Tab switched to:', targetTab);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
public/test-view-switching.html
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
2
public/vendor/d3.v7.min.js
vendored
387
public/view-models.js
Normal file
@@ -0,0 +1,387 @@
|
||||
// View Models for SPORE UI Components
|
||||
|
||||
// Cluster View Model with enhanced state preservation
|
||||
class ClusterViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
members: [],
|
||||
primaryNode: null,
|
||||
totalNodes: 0,
|
||||
clientInitialized: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
expandedCards: new Map(),
|
||||
activeTabs: new Map(), // Store active tab for each node
|
||||
lastUpdateTime: null
|
||||
});
|
||||
}
|
||||
|
||||
// Update cluster members with state preservation
|
||||
async updateClusterMembers() {
|
||||
try {
|
||||
console.log('ClusterViewModel: updateClusterMembers called');
|
||||
|
||||
// Store current UI state before update
|
||||
const currentUIState = this.getAllUIState();
|
||||
const currentExpandedCards = this.get('expandedCards');
|
||||
const currentActiveTabs = this.get('activeTabs');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
console.log('ClusterViewModel: Fetching cluster members...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
console.log('ClusterViewModel: Got response:', response);
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
members: response.members || [],
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore expanded cards and active tabs
|
||||
this.set('expandedCards', currentExpandedCards);
|
||||
this.set('activeTabs', currentActiveTabs);
|
||||
|
||||
// Update primary node display
|
||||
console.log('ClusterViewModel: Updating primary node display...');
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
console.log('ClusterViewModel: updateClusterMembers completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update primary node display with state preservation
|
||||
async updatePrimaryNodeDisplay() {
|
||||
try {
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
const updates = {};
|
||||
|
||||
if (discoveryInfo.primaryNode) {
|
||||
updates.primaryNode = discoveryInfo.primaryNode;
|
||||
updates.clientInitialized = discoveryInfo.clientInitialized;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else if (discoveryInfo.totalNodes > 0) {
|
||||
updates.primaryNode = discoveryInfo.nodes[0]?.ip;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else {
|
||||
updates.primaryNode = null;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = 0;
|
||||
}
|
||||
|
||||
this.batchUpdate(updates, { preserveUIState: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch discovery info:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Select random primary node
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
const result = await window.apiClient.selectRandomPrimaryNode();
|
||||
|
||||
if (result.success) {
|
||||
// Update the display after a short delay
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 1500);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message || 'Random selection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select random primary node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Store expanded card state
|
||||
storeExpandedCard(memberIp, content) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.set(memberIp, content);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`expanded_${memberIp}`, content);
|
||||
}
|
||||
|
||||
// Get expanded card state
|
||||
getExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
return expandedCards.get(memberIp);
|
||||
}
|
||||
|
||||
// Clear expanded card state
|
||||
clearExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.delete(memberIp);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also clear from UI state
|
||||
this.clearUIState(`expanded_${memberIp}`);
|
||||
}
|
||||
|
||||
// Store active tab for a specific node
|
||||
storeActiveTab(memberIp, tabName) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
activeTabs.set(memberIp, tabName);
|
||||
this.set('activeTabs', activeTabs);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`activeTab_${memberIp}`, tabName);
|
||||
}
|
||||
|
||||
// Get active tab for a specific node
|
||||
getActiveTab(memberIp) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab
|
||||
}
|
||||
|
||||
// Check if data has actually changed to avoid unnecessary updates
|
||||
hasDataChanged(newData, dataType) {
|
||||
const currentData = this.get(dataType);
|
||||
|
||||
if (Array.isArray(newData) && Array.isArray(currentData)) {
|
||||
if (newData.length !== currentData.length) return true;
|
||||
|
||||
// Compare each member's key properties
|
||||
return newData.some((newMember, index) => {
|
||||
const currentMember = currentData[index];
|
||||
return !currentMember ||
|
||||
newMember.ip !== currentMember.ip ||
|
||||
newMember.status !== currentMember.status ||
|
||||
newMember.latency !== currentMember.latency;
|
||||
});
|
||||
}
|
||||
|
||||
return newData !== currentData;
|
||||
}
|
||||
|
||||
// Smart update that only updates changed data
|
||||
async smartUpdate() {
|
||||
try {
|
||||
console.log('ClusterViewModel: Performing smart update...');
|
||||
|
||||
// Fetch new data
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
const newMembers = response.members || [];
|
||||
|
||||
// Check if members data has actually changed
|
||||
if (this.hasDataChanged(newMembers, 'members')) {
|
||||
console.log('ClusterViewModel: Members data changed, updating...');
|
||||
await this.updateClusterMembers();
|
||||
} else {
|
||||
console.log('ClusterViewModel: Members data unchanged, skipping update');
|
||||
// Still update primary node display as it might have changed
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Smart update failed:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node Details View Model with enhanced state preservation
|
||||
class NodeDetailsViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
nodeStatus: null,
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
activeTab: 'status',
|
||||
nodeIp: null
|
||||
});
|
||||
}
|
||||
|
||||
// Load node details with state preservation
|
||||
async loadNodeDetails(ip) {
|
||||
try {
|
||||
// Store current UI state
|
||||
const currentActiveTab = this.get('activeTab');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
this.set('nodeIp', ip);
|
||||
|
||||
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
nodeStatus: nodeStatus
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore active tab
|
||||
this.set('activeTab', currentActiveTab);
|
||||
|
||||
// Load tasks data
|
||||
await this.loadTasksData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load node details:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load tasks data with state preservation
|
||||
async loadTasksData() {
|
||||
try {
|
||||
const response = await window.apiClient.getTasksStatus();
|
||||
this.set('tasks', response || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load tasks:', error);
|
||||
this.set('tasks', []);
|
||||
}
|
||||
}
|
||||
|
||||
// Set active tab with state persistence
|
||||
setActiveTab(tabName) {
|
||||
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
||||
this.set('activeTab', tabName);
|
||||
|
||||
// Store in UI state for persistence
|
||||
this.setUIState('activeTab', tabName);
|
||||
}
|
||||
|
||||
// Upload firmware
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const result = await window.apiClient.uploadFirmware(file, nodeIp);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Firmware upload failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware View Model
|
||||
class FirmwareViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
selectedFile: null,
|
||||
targetType: 'all',
|
||||
specificNode: null,
|
||||
availableNodes: [],
|
||||
uploadProgress: null,
|
||||
uploadResults: [],
|
||||
isUploading: false
|
||||
});
|
||||
}
|
||||
|
||||
// Set selected file
|
||||
setSelectedFile(file) {
|
||||
this.set('selectedFile', file);
|
||||
}
|
||||
|
||||
// Set target type
|
||||
setTargetType(type) {
|
||||
this.set('targetType', type);
|
||||
}
|
||||
|
||||
// Set specific node
|
||||
setSpecificNode(nodeIp) {
|
||||
this.set('specificNode', nodeIp);
|
||||
}
|
||||
|
||||
// Update available nodes
|
||||
updateAvailableNodes(nodes) {
|
||||
this.set('availableNodes', nodes);
|
||||
}
|
||||
|
||||
// Start upload
|
||||
startUpload() {
|
||||
this.set('isUploading', true);
|
||||
this.set('uploadProgress', {
|
||||
current: 0,
|
||||
total: 0,
|
||||
status: 'Preparing...'
|
||||
});
|
||||
this.set('uploadResults', []);
|
||||
}
|
||||
|
||||
// Update upload progress
|
||||
updateUploadProgress(current, total, status) {
|
||||
this.set('uploadProgress', {
|
||||
current,
|
||||
total,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
// Add upload result
|
||||
addUploadResult(result) {
|
||||
const results = this.get('uploadResults');
|
||||
results.push(result);
|
||||
this.set('uploadResults', results);
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
completeUpload() {
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Reset upload state
|
||||
resetUploadState() {
|
||||
this.set('selectedFile', null);
|
||||
this.set('uploadProgress', null);
|
||||
this.set('uploadResults', []);
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Check if deploy button should be enabled
|
||||
isDeployEnabled() {
|
||||
const hasFile = this.get('selectedFile') !== null;
|
||||
const availableNodes = this.get('availableNodes');
|
||||
const hasAvailableNodes = availableNodes && availableNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (this.get('targetType') === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (this.get('targetType') === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && this.get('specificNode');
|
||||
}
|
||||
|
||||
return hasFile && isValidTarget && !this.get('isUploading');
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation View Model
|
||||
class NavigationViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
activeView: 'cluster',
|
||||
views: ['cluster', 'firmware']
|
||||
});
|
||||
}
|
||||
|
||||
// Set active view
|
||||
setActiveView(viewName) {
|
||||
this.set('activeView', viewName);
|
||||
}
|
||||
|
||||
// Get active view
|
||||
getActiveView() {
|
||||
return this.get('activeView');
|
||||
}
|
||||
}
|
||||
@@ -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_HEARTBEAT messages');
|
||||
console.log('💡 Use: npm run test-heartbeat broadcast');
|
||||
console.log('💡 Start the backend server and send CLUSTER_DISCOVERY messages');
|
||||
console.log('💡 Use: npm run test-discovery broadcast');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -84,14 +84,6 @@ class SporeApiClient {
|
||||
return this.request('GET', '/api/node/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node endpoints
|
||||
* @returns {Promise<Object>} endpoints response
|
||||
*/
|
||||
async getCapabilities() {
|
||||
return this.request('GET', '/api/node/endpoints');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cluster discovery information
|
||||
* @returns {Promise<Object>} Cluster discovery response
|
||||
|
||||
124
test/demo-discovery.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/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);
|
||||
102
test/demo-frontend.js
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/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);
|
||||
@@ -1,132 +0,0 @@
|
||||
// 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
@@ -1,232 +0,0 @@
|
||||
#!/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
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@@ -1,846 +0,0 @@
|
||||
#!/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 };
|
||||
@@ -1,285 +0,0 @@
|
||||
#!/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 };
|
||||
@@ -1,273 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,212 +0,0 @@
|
||||
#!/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
|
||||
};
|
||||
77
test/test-discovery.js
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/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);
|
||||
137
test/test-random-selection.js
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/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);
|
||||