Compare commits
7 Commits
01350ac233
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a0c49a29bc | |||
| 06fc5e6747 | |||
| be6d9bb98a | |||
| 7784365361 | |||
| 858be416eb | |||
| 7b2f600f8c | |||
| 9d5d0e4d67 |
56
.cursor/rules/cleancode.mdc
Normal file
56
.cursor/rules/cleancode.mdc
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
# Clean Code Guidelines
|
||||||
|
|
||||||
|
## Constants Over Magic Numbers
|
||||||
|
- Replace hard-coded values with named constants
|
||||||
|
- Use descriptive constant names that explain the value's purpose
|
||||||
|
- Keep constants at the top of the file or in a dedicated constants file
|
||||||
|
|
||||||
|
## Meaningful Names
|
||||||
|
- Variables, functions, and classes should reveal their purpose
|
||||||
|
- Names should explain why something exists and how it's used
|
||||||
|
- Avoid abbreviations unless they're universally understood
|
||||||
|
|
||||||
|
## Smart Comments
|
||||||
|
- Don't comment on what the code does - make the code self-documenting
|
||||||
|
- Use comments to explain why something is done a certain way
|
||||||
|
- Document APIs, complex algorithms, and non-obvious side effects
|
||||||
|
|
||||||
|
## Single Responsibility
|
||||||
|
- Each function should do exactly one thing
|
||||||
|
- Functions should be small and focused
|
||||||
|
- If a function needs a comment to explain what it does, it should be split
|
||||||
|
|
||||||
|
## DRY (Don't Repeat Yourself)
|
||||||
|
- Extract repeated code into reusable functions
|
||||||
|
- Share common logic through proper abstraction
|
||||||
|
- Maintain single sources of truth
|
||||||
|
|
||||||
|
## Clean Structure
|
||||||
|
- Keep related code together
|
||||||
|
- Organize code in a logical hierarchy
|
||||||
|
- Use consistent file and folder naming conventions
|
||||||
|
|
||||||
|
## Encapsulation
|
||||||
|
- Hide implementation details
|
||||||
|
- Expose clear interfaces
|
||||||
|
- Move nested conditionals into well-named functions
|
||||||
|
|
||||||
|
## Code Quality Maintenance
|
||||||
|
- Refactor continuously
|
||||||
|
- Fix technical debt early
|
||||||
|
- Leave code cleaner than you found it
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Write tests before fixing bugs
|
||||||
|
- Keep tests readable and maintainable
|
||||||
|
- Test edge cases and error conditions
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
- Write clear commit messages
|
||||||
|
- Make small, focused commits
|
||||||
|
- Use meaningful branch names
|
||||||
111
.cursor/rules/gitflow.mdc
Normal file
111
.cursor/rules/gitflow.mdc
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
description: Gitflow Workflow Rules. These rules should be applied when performing git operations.
|
||||||
|
---
|
||||||
|
# Gitflow Workflow Rules
|
||||||
|
|
||||||
|
## Main Branches
|
||||||
|
|
||||||
|
### main (or master)
|
||||||
|
- Contains production-ready code
|
||||||
|
- Never commit directly to main
|
||||||
|
- Only accepts merges from:
|
||||||
|
- hotfix/* branches
|
||||||
|
- release/* branches
|
||||||
|
- Must be tagged with version number after each merge
|
||||||
|
|
||||||
|
### develop
|
||||||
|
- Main development branch
|
||||||
|
- Contains latest delivered development changes
|
||||||
|
- Source branch for feature branches
|
||||||
|
- Never commit directly to develop
|
||||||
|
|
||||||
|
## Supporting Branches
|
||||||
|
|
||||||
|
### feature/*
|
||||||
|
- Branch from: develop
|
||||||
|
- Merge back into: develop
|
||||||
|
- Naming convention: feature/[issue-id]-descriptive-name
|
||||||
|
- Example: feature/123-user-authentication
|
||||||
|
- Must be up-to-date with develop before creating PR
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
### release/*
|
||||||
|
- Branch from: develop
|
||||||
|
- Merge back into:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- Naming convention: release/vX.Y.Z
|
||||||
|
- Example: release/v1.2.0
|
||||||
|
- Only bug fixes, documentation, and release-oriented tasks
|
||||||
|
- No new features
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
### hotfix/*
|
||||||
|
- Branch from: main
|
||||||
|
- Merge back into:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
- Naming convention: hotfix/vX.Y.Z
|
||||||
|
- Example: hotfix/v1.2.1
|
||||||
|
- Only for urgent production fixes
|
||||||
|
- Delete after merge
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
- Format: `type(scope): description`
|
||||||
|
- Types:
|
||||||
|
- feat: New feature
|
||||||
|
- fix: Bug fix
|
||||||
|
- docs: Documentation changes
|
||||||
|
- style: Formatting, missing semicolons, etc.
|
||||||
|
- refactor: Code refactoring
|
||||||
|
- test: Adding tests
|
||||||
|
- chore: Maintenance tasks
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
- MAJOR version for incompatible API changes
|
||||||
|
- MINOR version for backwards-compatible functionality
|
||||||
|
- PATCH version for backwards-compatible bug fixes
|
||||||
|
|
||||||
|
## Pull Request Rules
|
||||||
|
|
||||||
|
1. All changes must go through Pull Requests
|
||||||
|
2. Required approvals: minimum 1
|
||||||
|
3. CI checks must pass
|
||||||
|
4. No direct commits to protected branches (main, develop)
|
||||||
|
5. Branch must be up to date before merging
|
||||||
|
6. Delete branch after merge
|
||||||
|
|
||||||
|
## Branch Protection Rules
|
||||||
|
|
||||||
|
### main & develop
|
||||||
|
- Require pull request reviews
|
||||||
|
- Require status checks to pass
|
||||||
|
- Require branches to be up to date
|
||||||
|
- Include administrators in restrictions
|
||||||
|
- No force pushes
|
||||||
|
- No deletions
|
||||||
|
|
||||||
|
## Release Process
|
||||||
|
|
||||||
|
1. Create release branch from develop
|
||||||
|
2. Bump version numbers
|
||||||
|
3. Fix any release-specific issues
|
||||||
|
4. Create PR to main
|
||||||
|
5. After merge to main:
|
||||||
|
- Tag release
|
||||||
|
- Merge back to develop
|
||||||
|
- Delete release branch
|
||||||
|
|
||||||
|
## Hotfix Process
|
||||||
|
|
||||||
|
1. Create hotfix branch from main
|
||||||
|
2. Fix the issue
|
||||||
|
3. Bump patch version
|
||||||
|
4. Create PR to main
|
||||||
|
5. After merge to main:
|
||||||
|
- Tag release
|
||||||
|
- Merge back to develop
|
||||||
|
- Delete hotfix branch
|
||||||
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.cursor
|
||||||
|
*.md
|
||||||
|
node_modules
|
||||||
|
README.md
|
||||||
|
docs
|
||||||
|
|
||||||
41
Dockerfile
Normal file
41
Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
# Install wget for health checks
|
||||||
|
RUN apk --no-cache add wget
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependencies from builder
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY server ./server
|
||||||
|
COPY presets ./presets
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
# 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 8080
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["node", "server/index.js"]
|
||||||
|
|
||||||
54
Makefile
Normal file
54
Makefile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
.PHONY: install build run clean docker-build docker-run docker-push docker-build-multiarch docker-push-multiarch
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
install:
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the application (if needed)
|
||||||
|
build: install
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
run:
|
||||||
|
node server/index.js
|
||||||
|
|
||||||
|
# Start in development mode
|
||||||
|
dev:
|
||||||
|
node server/index.js
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -f package-lock.json
|
||||||
|
|
||||||
|
# Docker variables
|
||||||
|
DOCKER_REGISTRY ?=
|
||||||
|
IMAGE_NAME = wirelos/spore-ledlab
|
||||||
|
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 8080:8080 --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 \
|
||||||
|
.
|
||||||
|
|
||||||
71
README.md
71
README.md
@@ -23,7 +23,8 @@ LEDLab is a tool for streaming animations to LED matrices connected to SPORE nod
|
|||||||
|
|
||||||
The Node.js server provides the backend for SPORE LEDLab:
|
The Node.js server provides the backend for SPORE LEDLab:
|
||||||
|
|
||||||
- **UDP Discovery**: Listens on port 4210 to automatically discover SPORE nodes
|
- **Gateway Integration**: Subscribes to spore-gateway WebSocket for real-time cluster updates (requires spore-gateway to be running)
|
||||||
|
- **UDP Frame Streaming**: Sends animation frames to SPORE nodes via UDP on port 4210
|
||||||
- **WebSocket API**: Real-time bidirectional communication with the web UI
|
- **WebSocket API**: Real-time bidirectional communication with the web UI
|
||||||
- **Preset Management**: Manages animation presets with configurable parameters
|
- **Preset Management**: Manages animation presets with configurable parameters
|
||||||
- **Multi-Node Streaming**: Streams different presets to individual nodes simultaneously
|
- **Multi-Node Streaming**: Streams different presets to individual nodes simultaneously
|
||||||
@@ -50,6 +51,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
|
|||||||
Preset Engine
|
Preset Engine
|
||||||
|
|
|
|
||||||
Frame Generation (60fps)
|
Frame Generation (60fps)
|
||||||
|
Gateway WebSocket (Real-time cluster updates)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
@@ -58,6 +60,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
|
|||||||
|
|
||||||
- Node.js (v14 or higher)
|
- Node.js (v14 or higher)
|
||||||
- npm (v6 or higher)
|
- npm (v6 or higher)
|
||||||
|
- spore-gateway must be running on port 3001
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -83,7 +86,8 @@ This will install:
|
|||||||
spore-ledlab/
|
spore-ledlab/
|
||||||
├── server/ # Backend server
|
├── server/ # Backend server
|
||||||
│ ├── index.js # Main server & WebSocket handling
|
│ ├── index.js # Main server & WebSocket handling
|
||||||
│ └── udp-discovery.js # UDP node discovery
|
│ ├── gateway-client.js # Gateway client for node discovery
|
||||||
|
│ └── udp-discovery.js # UDP sender for frame streaming
|
||||||
├── presets/ # Animation preset implementations
|
├── presets/ # Animation preset implementations
|
||||||
│ ├── preset-registry.js
|
│ ├── preset-registry.js
|
||||||
│ ├── base-preset.js
|
│ ├── base-preset.js
|
||||||
@@ -117,17 +121,18 @@ npm run dev
|
|||||||
```
|
```
|
||||||
|
|
||||||
The server will:
|
The server will:
|
||||||
- Start the HTTP server on port 3000
|
- Start the HTTP server on port 8080 (default)
|
||||||
- Initialize WebSocket server
|
- Initialize WebSocket server
|
||||||
- Begin UDP discovery on port 4210
|
- Connect to spore-gateway for node discovery
|
||||||
- Serve the web UI at http://localhost:3000
|
- Initialize UDP sender for frame streaming
|
||||||
|
- Serve the web UI at http://localhost:8080
|
||||||
|
|
||||||
### Access the Web UI
|
### Access the Web UI
|
||||||
|
|
||||||
Open your browser and navigate to:
|
Open your browser and navigate to:
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:3000
|
http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure SPORE Nodes
|
### Configure SPORE Nodes
|
||||||
@@ -136,8 +141,30 @@ Ensure your SPORE nodes are:
|
|||||||
1. Connected to the same network as the LEDLab server
|
1. Connected to the same network as the LEDLab server
|
||||||
2. Running firmware with PixelStreamController support
|
2. Running firmware with PixelStreamController support
|
||||||
3. Listening on UDP port 4210
|
3. Listening on UDP port 4210
|
||||||
|
4. Discovered by spore-gateway
|
||||||
|
5. Labeled with `app: pixelstream` (LEDLab only displays nodes with this label)
|
||||||
|
|
||||||
The nodes will automatically appear in the LEDLab grid view once discovered.
|
The nodes will automatically appear in the LEDLab grid view once they are discovered by spore-gateway and have the `app: pixelstream` label.
|
||||||
|
|
||||||
|
### Node Labeling
|
||||||
|
|
||||||
|
To display nodes in LEDLab, you must set the `app: pixelstream` label on your SPORE nodes. This can be done via the spore-gateway API or from the SPORE node itself.
|
||||||
|
|
||||||
|
**From SPORE Node:**
|
||||||
|
Nodes should advertise their labels via the UDP heartbeat. The PixelStreamController firmware automatically sets the `app: pixelstream` label.
|
||||||
|
|
||||||
|
**Manual via Gateway:**
|
||||||
|
You can also set labels manually using the spore-gateway API or spore-ui interface.
|
||||||
|
|
||||||
|
### Disabling Filtering
|
||||||
|
|
||||||
|
To display all nodes without filtering, set the environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FILTER_APP_LABEL= npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Or leave it empty to show all discovered nodes.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -190,16 +217,26 @@ In the Settings view:
|
|||||||
|
|
||||||
### Server Configuration
|
### Server Configuration
|
||||||
|
|
||||||
Edit `server/index.js` to modify:
|
Edit `server/index.js` or use environment variables:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const PORT = 3000; // HTTP/WebSocket port
|
const PORT = 8080; // HTTP/WebSocket port
|
||||||
const UDP_PORT = 4210; // UDP discovery port
|
const UDP_PORT = 4210; // UDP frame streaming port
|
||||||
const DEFAULT_FPS = 20; // Default frame rate
|
const GATEWAY_URL = 'http://localhost:3001'; // spore-gateway URL
|
||||||
const MATRIX_WIDTH = 16; // Default matrix width
|
const FILTER_APP_LABEL = 'pixelstream'; // Filter nodes by app label
|
||||||
const MATRIX_HEIGHT = 16; // Default matrix height
|
const DEFAULT_FPS = 20; // Default frame rate
|
||||||
|
const MATRIX_WIDTH = 16; // Default matrix width
|
||||||
|
const MATRIX_HEIGHT = 16; // Default matrix height
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `PORT` - HTTP server port (default: 8080)
|
||||||
|
- `UDP_PORT` - UDP port for frame streaming (default: 4210)
|
||||||
|
- `GATEWAY_URL` - spore-gateway URL (default: http://localhost:3001)
|
||||||
|
- `FILTER_APP_LABEL` - Filter nodes by app label (default: pixelstream, set to empty string to disable filtering)
|
||||||
|
- `MATRIX_WIDTH` - Default matrix width (default: 16)
|
||||||
|
- `MATRIX_HEIGHT` - Default matrix height (default: 16)
|
||||||
|
|
||||||
### Adding Custom Presets
|
### Adding Custom Presets
|
||||||
|
|
||||||
1. Create a new preset file in `presets/`:
|
1. Create a new preset file in `presets/`:
|
||||||
@@ -323,14 +360,17 @@ See `presets/examples/README.md` for detailed documentation.
|
|||||||
|
|
||||||
### Nodes Not Appearing
|
### Nodes Not Appearing
|
||||||
|
|
||||||
|
- Ensure spore-gateway is running on port 3001
|
||||||
- Verify nodes are on the same network
|
- Verify nodes are on the same network
|
||||||
- Check firewall settings allow UDP port 4210
|
- Check firewall settings allow UDP port 4210
|
||||||
- Ensure nodes are running PixelStreamController firmware
|
- Ensure nodes are running PixelStreamController firmware
|
||||||
|
- Verify spore-gateway has discovered the nodes
|
||||||
|
- **IMPORTANT**: Nodes must have the `app: pixelstream` label set. LEDLab only displays nodes with this label. Check node labels in spore-gateway.
|
||||||
|
|
||||||
### WebSocket Connection Issues
|
### WebSocket Connection Issues
|
||||||
|
|
||||||
- Check browser console for errors
|
- Check browser console for errors
|
||||||
- Verify server is running on port 3000
|
- Verify server is running on port 8080 (default)
|
||||||
- Try refreshing the browser
|
- Try refreshing the browser
|
||||||
|
|
||||||
### Animation Not Streaming
|
### Animation Not Streaming
|
||||||
@@ -351,7 +391,8 @@ See `presets/examples/README.md` for detailed documentation.
|
|||||||
- Node.js
|
- Node.js
|
||||||
- Express.js (HTTP server)
|
- Express.js (HTTP server)
|
||||||
- ws (WebSocket server)
|
- ws (WebSocket server)
|
||||||
- UDP (Node discovery and frame streaming)
|
- HTTP client (for querying spore-gateway)
|
||||||
|
- UDP (Frame streaming only)
|
||||||
|
|
||||||
**Frontend:**
|
**Frontend:**
|
||||||
- Vanilla JavaScript (ES6+)
|
- Vanilla JavaScript (ES6+)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 226 KiB |
119
package-lock.json
generated
119
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dgram": "^1.0.1",
|
"axios": "^1.6.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,23 @@
|
|||||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -95,6 +112,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -140,6 +169,15 @@
|
|||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -159,13 +197,6 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dgram": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dgram/-/dgram-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-zJVFL1EWfKtE0z2VN6qfpn/a+qG1viEzcwJA0EjtzS76ONSE3sEyWBwEbo32hS4IFw/EWVuWN+8b89aPW6It2A==",
|
|
||||||
"deprecated": "npm is holding this package for security reasons. As it's a core Node module, we will not transfer it over to other users. You may safely remove the package from your dependencies.",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -225,6 +256,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -304,6 +350,42 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/forwarded": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -392,6 +474,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -583,6 +680,12 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
|
|||||||
@@ -90,61 +90,80 @@
|
|||||||
<div class="editor-layout-modern">
|
<div class="editor-layout-modern">
|
||||||
<!-- Left Panel: Preset Info & Layers -->
|
<!-- Left Panel: Preset Info & Layers -->
|
||||||
<div class="editor-left-panel">
|
<div class="editor-left-panel">
|
||||||
<!-- Preset Info -->
|
<!-- Preset -->
|
||||||
<div class="editor-section-compact">
|
<div class="editor-section-compact editor-preset-section">
|
||||||
<h3 class="section-title-compact">Preset Info</h3>
|
<div class="preset-header">
|
||||||
|
<h3 class="section-title-compact">Preset</h3>
|
||||||
|
<div class="preset-header-buttons">
|
||||||
|
<button class="btn btn-icon" id="editor-new-preset" title="New Preset">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14,2 14,8 20,8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
<polyline points="10,9 9,9 8,9"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon" id="editor-save-preset" title="Save Preset">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
||||||
|
<polyline points="17,21 17,13 7,13 7,21"/>
|
||||||
|
<polyline points="7,3 7,8 15,8"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon" id="editor-delete-preset" title="Delete Preset">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<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>
|
||||||
|
<button class="btn btn-icon" id="editor-export-json" title="Export JSON">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<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>
|
||||||
|
<label class="btn btn-icon" title="Import JSON">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||||
|
<polyline points="17,8 12,3 7,8"/>
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||||
|
</svg>
|
||||||
|
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preset Name -->
|
||||||
<div class="editor-input-wrapper">
|
<div class="editor-input-wrapper">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
<input type="text" id="editor-preset-name" value="New Custom Preset" />
|
<input type="text" id="editor-preset-name" value="New Custom Preset" />
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-input-wrapper">
|
|
||||||
<label>Description</label>
|
|
||||||
<textarea id="editor-preset-desc" rows="3">A custom configurable preset</textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Layer Section -->
|
<!-- Preset Actions -->
|
||||||
<div class="editor-section-compact">
|
<div class="preset-actions">
|
||||||
<h3 class="section-title-compact">Add Layer</h3>
|
<div class="preset-actions-row">
|
||||||
<div class="editor-input-wrapper">
|
<select class="preset-select" id="editor-load-preset">
|
||||||
<label>Layer Type</label>
|
<option value="">Load saved preset...</option>
|
||||||
<select id="editor-layer-type">
|
</select>
|
||||||
<option value="shape">Shape</option>
|
</div>
|
||||||
<option value="pattern">Pattern</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary btn-full-width" id="editor-add-layer">➕ Add Layer</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layers List -->
|
<!-- Layers List -->
|
||||||
<div class="editor-section-compact editor-layers-section">
|
<div class="editor-section-compact editor-layers-section">
|
||||||
<h3 class="section-title-compact">Layers</h3>
|
<div class="section-title-with-button">
|
||||||
|
<h3 class="section-title-compact">Layers</h3>
|
||||||
|
<button class="btn btn-primary btn-small" id="editor-add-layer">➕</button>
|
||||||
|
</div>
|
||||||
<div id="editor-layer-list" class="editor-layer-list-expandable">
|
<div id="editor-layer-list" class="editor-layer-list-expandable">
|
||||||
<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>
|
<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Manage Section -->
|
|
||||||
<div class="editor-section-compact">
|
|
||||||
<h3 class="section-title-compact">Manage</h3>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button class="btn btn-primary" id="editor-new-preset">📄 New</button>
|
|
||||||
<button class="btn btn-success" id="editor-save-preset">💾 Save</button>
|
|
||||||
<button class="btn btn-danger" id="editor-delete-preset">🗑️ Delete</button>
|
|
||||||
</div>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<select class="preset-select" id="editor-load-preset">
|
|
||||||
<option value="">Load saved preset...</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button class="btn btn-secondary" id="editor-export-json">📤 Export JSON</button>
|
|
||||||
<label class="btn btn-secondary" style="margin: 0; text-align: center;">
|
|
||||||
📥 Import JSON
|
|
||||||
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Panel: Preview & Controls -->
|
<!-- Right Panel: Preview & Controls -->
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ class PresetEditor {
|
|||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Preset metadata
|
// Preset metadata
|
||||||
const nameInput = document.getElementById('editor-preset-name');
|
const nameInput = document.getElementById('editor-preset-name');
|
||||||
const descInput = document.getElementById('editor-preset-desc');
|
|
||||||
|
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
nameInput.addEventListener('input', (e) => {
|
nameInput.addEventListener('input', (e) => {
|
||||||
@@ -75,26 +74,12 @@ class PresetEditor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descInput) {
|
|
||||||
descInput.addEventListener('input', (e) => {
|
|
||||||
this.configuration.description = e.target.value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add layer button
|
// Add layer button
|
||||||
const addLayerBtn = document.getElementById('editor-add-layer');
|
const addLayerBtn = document.getElementById('editor-add-layer');
|
||||||
if (addLayerBtn) {
|
if (addLayerBtn) {
|
||||||
addLayerBtn.addEventListener('click', () => this.addLayer());
|
addLayerBtn.addEventListener('click', () => this.addLayer());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Building block type selector
|
|
||||||
const typeSelector = document.getElementById('editor-layer-type');
|
|
||||||
if (typeSelector) {
|
|
||||||
typeSelector.addEventListener('change', (e) => {
|
|
||||||
this.showLayerTypeOptions(e.target.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save/Load buttons
|
// Save/Load buttons
|
||||||
const saveBtn = document.getElementById('editor-save-preset');
|
const saveBtn = document.getElementById('editor-save-preset');
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
@@ -157,14 +142,8 @@ class PresetEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addLayer() {
|
addLayer() {
|
||||||
const layerType = document.getElementById('editor-layer-type')?.value || 'shape';
|
// Default to shape layer type
|
||||||
|
const layer = this.createShapeLayer();
|
||||||
let layer;
|
|
||||||
if (layerType === 'pattern') {
|
|
||||||
layer = this.createPatternLayer();
|
|
||||||
} else {
|
|
||||||
layer = this.createShapeLayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.configuration.layers.push(layer);
|
this.configuration.layers.push(layer);
|
||||||
|
|
||||||
@@ -464,6 +443,14 @@ class PresetEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderShapeEditor(container, layer, index) {
|
renderShapeEditor(container, layer, index) {
|
||||||
|
// Layer type selector
|
||||||
|
this.addSelect(container, 'Layer Type', layer.type,
|
||||||
|
['shape', 'pattern'],
|
||||||
|
(value) => {
|
||||||
|
this.changeLayerType(index, value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Shape type
|
// Shape type
|
||||||
this.addSelect(container, 'Shape', layer.shape,
|
this.addSelect(container, 'Shape', layer.shape,
|
||||||
['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'],
|
['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'],
|
||||||
@@ -517,6 +504,14 @@ class PresetEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderPatternEditor(container, layer, index) {
|
renderPatternEditor(container, layer, index) {
|
||||||
|
// Layer type selector
|
||||||
|
this.addSelect(container, 'Layer Type', layer.type,
|
||||||
|
['shape', 'pattern'],
|
||||||
|
(value) => {
|
||||||
|
this.changeLayerType(index, value);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Pattern type
|
// Pattern type
|
||||||
this.addSelect(container, 'Pattern', layer.pattern,
|
this.addSelect(container, 'Pattern', layer.pattern,
|
||||||
['trail', 'radial', 'spiral'],
|
['trail', 'radial', 'spiral'],
|
||||||
@@ -774,6 +769,31 @@ class PresetEditor {
|
|||||||
this.renderLayerList();
|
this.renderLayerList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeLayerType(index, newType) {
|
||||||
|
const layer = this.configuration.layers[index];
|
||||||
|
|
||||||
|
if (layer.type === newType) return;
|
||||||
|
|
||||||
|
// Create a new layer of the specified type
|
||||||
|
let newLayer;
|
||||||
|
if (newType === 'pattern') {
|
||||||
|
newLayer = this.createPatternLayer();
|
||||||
|
} else {
|
||||||
|
newLayer = this.createShapeLayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve some properties if they exist
|
||||||
|
if (layer.intensity !== undefined) {
|
||||||
|
newLayer.intensity = layer.intensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the layer
|
||||||
|
this.configuration.layers[index] = newLayer;
|
||||||
|
|
||||||
|
this.renderLayerList();
|
||||||
|
this.refreshPreviewIfActive();
|
||||||
|
}
|
||||||
|
|
||||||
deleteLayer(index) {
|
deleteLayer(index) {
|
||||||
if (confirm('Delete this layer?')) {
|
if (confirm('Delete this layer?')) {
|
||||||
this.configuration.layers.splice(index, 1);
|
this.configuration.layers.splice(index, 1);
|
||||||
@@ -808,16 +828,11 @@ class PresetEditor {
|
|||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
const nameInput = document.getElementById('editor-preset-name');
|
const nameInput = document.getElementById('editor-preset-name');
|
||||||
const descInput = document.getElementById('editor-preset-desc');
|
|
||||||
|
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
nameInput.value = this.configuration.name;
|
nameInput.value = this.configuration.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (descInput) {
|
|
||||||
descInput.value = this.configuration.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear and re-render layer list
|
// Clear and re-render layer list
|
||||||
this.renderLayerList();
|
this.renderLayerList();
|
||||||
|
|
||||||
@@ -850,7 +865,6 @@ class PresetEditor {
|
|||||||
this.configuration = JSON.parse(JSON.stringify(preset));
|
this.configuration = JSON.parse(JSON.stringify(preset));
|
||||||
|
|
||||||
document.getElementById('editor-preset-name').value = this.configuration.name;
|
document.getElementById('editor-preset-name').value = this.configuration.name;
|
||||||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
|
||||||
|
|
||||||
this.renderLayerList();
|
this.renderLayerList();
|
||||||
this.refreshPreviewIfActive();
|
this.refreshPreviewIfActive();
|
||||||
@@ -868,7 +882,6 @@ class PresetEditor {
|
|||||||
|
|
||||||
this.configuration = this.getDefaultConfiguration();
|
this.configuration = this.getDefaultConfiguration();
|
||||||
document.getElementById('editor-preset-name').value = this.configuration.name;
|
document.getElementById('editor-preset-name').value = this.configuration.name;
|
||||||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
|
||||||
|
|
||||||
this.loadSavedPresets();
|
this.loadSavedPresets();
|
||||||
this.renderLayerList();
|
this.renderLayerList();
|
||||||
@@ -924,7 +937,6 @@ class PresetEditor {
|
|||||||
this.configuration = imported;
|
this.configuration = imported;
|
||||||
|
|
||||||
document.getElementById('editor-preset-name').value = this.configuration.name;
|
document.getElementById('editor-preset-name').value = this.configuration.name;
|
||||||
document.getElementById('editor-preset-desc').value = this.configuration.description;
|
|
||||||
|
|
||||||
this.renderLayerList();
|
this.renderLayerList();
|
||||||
this.refreshPreviewIfActive();
|
this.refreshPreviewIfActive();
|
||||||
|
|||||||
@@ -916,7 +916,6 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-secondary);
|
border: 1px solid var(--border-secondary);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -955,23 +954,23 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, var(--accent-primary) 0%, #22c55e 100%);
|
background: linear-gradient(135deg, rgba(74, 222, 128, 0.8) 0%, rgba(34, 197, 94, 0.8) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: var(--accent-primary);
|
border-color: rgba(74, 222, 128, 0.6);
|
||||||
box-shadow: 0 2px 8px rgba(74, 222, 128, 0.25);
|
box-shadow: 0 2px 8px rgba(74, 222, 128, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: linear-gradient(135deg, #22c55e 0%, var(--accent-primary) 100%);
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.9) 0%, rgba(74, 222, 128, 0.9) 100%);
|
||||||
border-color: #22c55e;
|
border-color: rgba(34, 197, 94, 0.8);
|
||||||
color: white;
|
color: white;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.35);
|
box-shadow: 0 3px 10px rgba(74, 222, 128, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:active {
|
.btn-primary:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
box-shadow: 0 2px 6px rgba(74, 222, 128, 0.25);
|
box-shadow: 0 1px 4px rgba(74, 222, 128, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-stop {
|
.btn-stop {
|
||||||
@@ -1004,15 +1003,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(255, 255, 255, 0.04) 100%);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-color: var(--border-secondary);
|
border-color: var(--border-secondary);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12) 0%, rgba(255, 255, 255, 0.08) 100%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-tertiary);
|
border-color: var(--border-tertiary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-small {
|
.btn-small {
|
||||||
@@ -1806,6 +1808,17 @@ body {
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-title-with-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-with-button .section-title-compact {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-section {
|
.editor-section {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -2012,6 +2025,132 @@ body {
|
|||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-preset-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions-row:first-child {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-actions-row .preset-select {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preset-section .editor-input-wrapper {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preset-section .editor-input-wrapper input {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preset-section .section-title-compact {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-header .section-title-compact {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--border-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon svg {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover svg {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:active svg {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon button color variations */
|
||||||
|
.btn-icon[id="editor-new-preset"]:hover {
|
||||||
|
border-color: rgba(74, 222, 128, 0.4);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon[id="editor-save-preset"]:hover {
|
||||||
|
border-color: rgba(76, 175, 80, 0.4);
|
||||||
|
color: var(--accent-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon[id="editor-delete-preset"]:hover {
|
||||||
|
border-color: rgba(248, 113, 113, 0.4);
|
||||||
|
color: var(--accent-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon[id="editor-export-json"]:hover,
|
||||||
|
.btn-icon[for="editor-import-json"]:hover {
|
||||||
|
border-color: rgba(96, 165, 250, 0.4);
|
||||||
|
color: var(--accent-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.editor-layer-buttons {
|
.editor-layer-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
@@ -2103,21 +2242,31 @@ body {
|
|||||||
|
|
||||||
/* Button Variants */
|
/* Button Variants */
|
||||||
.btn-success {
|
.btn-success {
|
||||||
background: linear-gradient(135deg, var(--accent-success) 0%, #45a049 100%);
|
background: linear-gradient(135deg, rgba(76, 175, 80, 0.8) 0%, rgba(69, 160, 73, 0.8) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: rgba(76, 175, 80, 0.6);
|
||||||
|
box-shadow: 0 2px 8px rgba(76, 175, 80, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-success:hover {
|
.btn-success:hover {
|
||||||
background: linear-gradient(135deg, #45a049 0%, #3d8b40 100%);
|
background: linear-gradient(135deg, rgba(69, 160, 73, 0.9) 0%, rgba(61, 139, 64, 0.9) 100%);
|
||||||
|
border-color: rgba(69, 160, 73, 0.8);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: linear-gradient(135deg, var(--accent-error) 0%, #dc2626 100%);
|
background: linear-gradient(135deg, rgba(248, 113, 113, 0.8) 0%, rgba(220, 38, 38, 0.8) 100%);
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: rgba(248, 113, 113, 0.6);
|
||||||
|
box-shadow: 0 2px 8px rgba(248, 113, 113, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover {
|
.btn-danger:hover {
|
||||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
background: linear-gradient(135deg, rgba(220, 38, 38, 0.9) 0%, rgba(185, 28, 28, 0.9) 100%);
|
||||||
|
border-color: rgba(220, 38, 38, 0.8);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 3px 10px rgba(248, 113, 113, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
|
|||||||
202
server/gateway-client.js
Normal file
202
server/gateway-client.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// Gateway Client - Communicates with spore-gateway for node discovery
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
class GatewayClient {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
|
||||||
|
this.filterAppLabel = options.filterAppLabel || 'pixelstream'; // Filter nodes by app label, set to null to disable
|
||||||
|
this.nodes = new Map(); // ip -> { lastSeen, status, hostname, port }
|
||||||
|
this.isRunning = false;
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 10;
|
||||||
|
this.reconnectDelay = 2000;
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = true;
|
||||||
|
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
|
||||||
|
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRunning = false;
|
||||||
|
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodes.clear();
|
||||||
|
console.log('Gateway client stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWebSocket() {
|
||||||
|
try {
|
||||||
|
// Convert http:// to ws:// for WebSocket
|
||||||
|
const wsUrl = this.gatewayUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws';
|
||||||
|
console.log(`Connecting to WebSocket: ${wsUrl}`);
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.on('open', () => {
|
||||||
|
console.log('WebSocket connected to gateway');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString());
|
||||||
|
this.handleWebSocketMessage(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('close', (code, reason) => {
|
||||||
|
console.log(`WebSocket connection closed: ${code} ${reason}`);
|
||||||
|
if (this.isRunning) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create WebSocket connection:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(message) {
|
||||||
|
if (message.topic === 'cluster/update') {
|
||||||
|
this.handleClusterUpdate(message.members, message.primaryNode, message.totalNodes);
|
||||||
|
} else if (message.topic === 'node/discovery') {
|
||||||
|
this.handleNodeDiscovery(message.action, message.nodeIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClusterUpdate(members, primaryNode, totalNodes) {
|
||||||
|
const newNodes = new Map();
|
||||||
|
|
||||||
|
if (members && Array.isArray(members)) {
|
||||||
|
members.forEach(node => {
|
||||||
|
// Filter for nodes with specified app label (if filtering is enabled)
|
||||||
|
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeIp = node.ip || node.IP;
|
||||||
|
newNodes.set(nodeIp, {
|
||||||
|
lastSeen: Date.now(),
|
||||||
|
status: node.status || node.Status || 'active',
|
||||||
|
hostname: node.hostname || node.Hostname || nodeIp,
|
||||||
|
port: node.port || node.Port || 4210,
|
||||||
|
isPrimary: (primaryNode === nodeIp),
|
||||||
|
labels: node.labels || node.Labels
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for newly discovered nodes
|
||||||
|
for (const [ip, nodeInfo] of newNodes.entries()) {
|
||||||
|
const existingNode = this.nodes.get(ip);
|
||||||
|
if (!existingNode) {
|
||||||
|
console.log(`Node discovered via gateway: ${ip} (${nodeInfo.hostname})`);
|
||||||
|
this.nodes.set(ip, nodeInfo);
|
||||||
|
} else if (existingNode.hostname !== nodeInfo.hostname) {
|
||||||
|
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
|
||||||
|
this.nodes.set(ip, nodeInfo);
|
||||||
|
} else {
|
||||||
|
// Update status and last seen
|
||||||
|
existingNode.status = nodeInfo.status;
|
||||||
|
existingNode.lastSeen = nodeInfo.lastSeen;
|
||||||
|
existingNode.isPrimary = nodeInfo.isPrimary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for lost nodes
|
||||||
|
for (const ip of this.nodes.keys()) {
|
||||||
|
if (!newNodes.has(ip)) {
|
||||||
|
console.log(`Node lost via gateway: ${ip}`);
|
||||||
|
this.nodes.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNodeDiscovery(action, nodeIp) {
|
||||||
|
if (action === 'discovered') {
|
||||||
|
// Node was discovered - actual data will come via cluster/update
|
||||||
|
console.log(`Node discovered event: ${nodeIp}`);
|
||||||
|
} else if (action === 'removed') {
|
||||||
|
console.log(`Node removed event: ${nodeIp}`);
|
||||||
|
this.nodes.delete(nodeIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
if (!this.isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error('Max WebSocket reconnection attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||||
|
|
||||||
|
console.log(`Attempting to reconnect WebSocket in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodes() {
|
||||||
|
return Array.from(this.nodes.entries()).map(([ip, node]) => ({
|
||||||
|
ip,
|
||||||
|
hostname: node.hostname || ip,
|
||||||
|
port: node.port,
|
||||||
|
status: node.status,
|
||||||
|
isPrimary: node.isPrimary,
|
||||||
|
...node
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
getNodeCount() {
|
||||||
|
return this.nodes.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAppLabel(node, appLabel) {
|
||||||
|
// Check if node has the app: <appLabel> label
|
||||||
|
if (!node.labels || typeof node.labels !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.labels.app === appLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = GatewayClient;
|
||||||
|
|
||||||
|
|
||||||
@@ -6,13 +6,16 @@ const WebSocket = require('ws');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Import services
|
// Import services
|
||||||
const UdpDiscovery = require('./udp-discovery');
|
const UdpSender = require('./udp-discovery');
|
||||||
|
const GatewayClient = require('./gateway-client');
|
||||||
const PresetRegistry = require('../presets/preset-registry');
|
const PresetRegistry = require('../presets/preset-registry');
|
||||||
|
|
||||||
class LEDLabServer {
|
class LEDLabServer {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.port = options.port || 8080;
|
this.port = options.port || 8080;
|
||||||
this.udpPort = options.udpPort || 4210;
|
this.udpPort = options.udpPort || 4210;
|
||||||
|
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
|
||||||
|
this.filterAppLabel = options.filterAppLabel || 'pixelstream';
|
||||||
this.matrixWidth = options.matrixWidth || 16;
|
this.matrixWidth = options.matrixWidth || 16;
|
||||||
this.matrixHeight = options.matrixHeight || 16;
|
this.matrixHeight = options.matrixHeight || 16;
|
||||||
this.fps = options.fps || 20;
|
this.fps = options.fps || 20;
|
||||||
@@ -21,7 +24,11 @@ class LEDLabServer {
|
|||||||
this.server = http.createServer(this.app);
|
this.server = http.createServer(this.app);
|
||||||
this.wss = new WebSocket.Server({ server: this.server });
|
this.wss = new WebSocket.Server({ server: this.server });
|
||||||
|
|
||||||
this.udpDiscovery = new UdpDiscovery(this.udpPort);
|
this.udpSender = new UdpSender(this.udpPort);
|
||||||
|
this.gatewayClient = new GatewayClient({
|
||||||
|
gatewayUrl: this.gatewayUrl,
|
||||||
|
filterAppLabel: this.filterAppLabel || 'pixelstream'
|
||||||
|
});
|
||||||
this.presetRegistry = new PresetRegistry();
|
this.presetRegistry = new PresetRegistry();
|
||||||
|
|
||||||
// Legacy single-stream support (kept for backwards compatibility)
|
// Legacy single-stream support (kept for backwards compatibility)
|
||||||
@@ -37,7 +44,7 @@ class LEDLabServer {
|
|||||||
|
|
||||||
this.setupExpress();
|
this.setupExpress();
|
||||||
this.setupWebSocket();
|
this.setupWebSocket();
|
||||||
this.setupUdpDiscovery();
|
this.setupGatewayClient();
|
||||||
this.setupPresetManager();
|
this.setupPresetManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +54,7 @@ class LEDLabServer {
|
|||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
this.app.get('/api/nodes', (req, res) => {
|
this.app.get('/api/nodes', (req, res) => {
|
||||||
const nodes = this.udpDiscovery.getNodes();
|
const nodes = this.gatewayClient.getNodes();
|
||||||
res.json({ nodes });
|
res.json({ nodes });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +68,7 @@ class LEDLabServer {
|
|||||||
streaming: this.currentPreset !== null,
|
streaming: this.currentPreset !== null,
|
||||||
currentPreset: this.currentPresetName || null,
|
currentPreset: this.currentPresetName || null,
|
||||||
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
nodeCount: this.udpDiscovery.getNodeCount(),
|
nodeCount: this.gatewayClient.getNodeCount(),
|
||||||
currentTarget: this.currentTarget,
|
currentTarget: this.currentTarget,
|
||||||
fps: this.fps,
|
fps: this.fps,
|
||||||
});
|
});
|
||||||
@@ -122,7 +129,7 @@ class LEDLabServer {
|
|||||||
streaming: this.currentPreset !== null,
|
streaming: this.currentPreset !== null,
|
||||||
currentPreset: this.currentPresetName || null,
|
currentPreset: this.currentPresetName || null,
|
||||||
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
nodes: this.udpDiscovery.getNodes(),
|
nodes: this.gatewayClient.getNodes(),
|
||||||
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
||||||
currentTarget: this.currentTarget,
|
currentTarget: this.currentTarget,
|
||||||
fps: this.fps,
|
fps: this.fps,
|
||||||
@@ -174,26 +181,14 @@ class LEDLabServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupUdpDiscovery() {
|
setupGatewayClient() {
|
||||||
this.udpDiscovery.on('nodeDiscovered', (node) => {
|
// Start gateway client for node discovery
|
||||||
console.log('Node discovered:', node.ip);
|
this.gatewayClient.start();
|
||||||
|
|
||||||
this.broadcastToClients({
|
// Start UDP sender for sending frames
|
||||||
type: 'nodeDiscovered',
|
this.udpSender.start();
|
||||||
node
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.udpDiscovery.on('nodeLost', (node) => {
|
console.log('Using gateway for node discovery and UDP sender for frame streaming');
|
||||||
console.log('Node lost:', node.ip);
|
|
||||||
|
|
||||||
this.broadcastToClients({
|
|
||||||
type: 'nodeLost',
|
|
||||||
node
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.udpDiscovery.start();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupPresetManager() {
|
setupPresetManager() {
|
||||||
@@ -441,7 +436,9 @@ class LEDLabServer {
|
|||||||
if (frameData) {
|
if (frameData) {
|
||||||
// Send to specific target
|
// Send to specific target
|
||||||
if (this.currentTarget) {
|
if (this.currentTarget) {
|
||||||
this.udpDiscovery.sendToNode(this.currentTarget, frameData);
|
this.udpSender.sendToNode(this.currentTarget, frameData).catch(err => {
|
||||||
|
console.error(`Error sending frame to ${this.currentTarget}:`, err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send frame data to WebSocket clients for preview
|
// Send frame data to WebSocket clients for preview
|
||||||
@@ -462,7 +459,10 @@ class LEDLabServer {
|
|||||||
const frameData = stream.preset.generateFrame();
|
const frameData = stream.preset.generateFrame();
|
||||||
if (frameData) {
|
if (frameData) {
|
||||||
// Send to specific node
|
// Send to specific node
|
||||||
this.udpDiscovery.sendToNode(nodeIp, frameData);
|
// frameData format: "RAW:FF0000FF0000..." (RAW prefix + hex pixel data)
|
||||||
|
this.udpSender.sendToNode(nodeIp, frameData).catch(err => {
|
||||||
|
console.error(`Error sending frame to ${nodeIp}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
// Send frame data to WebSocket clients for preview
|
// Send frame data to WebSocket clients for preview
|
||||||
this.broadcastToClients({
|
this.broadcastToClients({
|
||||||
@@ -475,7 +475,7 @@ class LEDLabServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendToSpecificNode(nodeIp, message) {
|
sendToSpecificNode(nodeIp, message) {
|
||||||
return this.udpDiscovery.sendToNode(nodeIp, message);
|
return this.udpSender.sendToNode(nodeIp, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastCurrentState() {
|
broadcastCurrentState() {
|
||||||
@@ -483,7 +483,7 @@ class LEDLabServer {
|
|||||||
streaming: this.currentPreset !== null,
|
streaming: this.currentPreset !== null,
|
||||||
currentPreset: this.currentPresetName || null,
|
currentPreset: this.currentPresetName || null,
|
||||||
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
matrixSize: { width: this.matrixWidth, height: this.matrixHeight },
|
||||||
nodes: this.udpDiscovery.getNodes(),
|
nodes: this.gatewayClient.getNodes(),
|
||||||
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null,
|
||||||
currentTarget: this.currentTarget,
|
currentTarget: this.currentTarget,
|
||||||
fps: this.fps,
|
fps: this.fps,
|
||||||
@@ -603,7 +603,8 @@ class LEDLabServer {
|
|||||||
startServer() {
|
startServer() {
|
||||||
this.server.listen(this.port, () => {
|
this.server.listen(this.port, () => {
|
||||||
console.log(`LEDLab server running on port ${this.port}`);
|
console.log(`LEDLab server running on port ${this.port}`);
|
||||||
console.log(`UDP discovery on port ${this.udpPort}`);
|
console.log(`Gateway client connecting to ${this.gatewayUrl}`);
|
||||||
|
console.log(`UDP sender configured for port ${this.udpPort}`);
|
||||||
console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`);
|
console.log(`Matrix size: ${this.matrixWidth}x${this.matrixHeight}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -614,8 +615,9 @@ class LEDLabServer {
|
|||||||
// Stop streaming first
|
// Stop streaming first
|
||||||
this.stopStreaming();
|
this.stopStreaming();
|
||||||
|
|
||||||
// Stop UDP discovery
|
// Stop gateway client and UDP sender
|
||||||
this.udpDiscovery.stop();
|
this.gatewayClient.stop();
|
||||||
|
this.udpSender.stop();
|
||||||
|
|
||||||
// Close all WebSocket connections immediately
|
// Close all WebSocket connections immediately
|
||||||
this.wss.close();
|
this.wss.close();
|
||||||
@@ -645,6 +647,8 @@ if (require.main === module) {
|
|||||||
const server = new LEDLabServer({
|
const server = new LEDLabServer({
|
||||||
port: process.env.PORT || 8080,
|
port: process.env.PORT || 8080,
|
||||||
udpPort: process.env.UDP_PORT || 4210,
|
udpPort: process.env.UDP_PORT || 4210,
|
||||||
|
gatewayUrl: process.env.GATEWAY_URL || 'http://localhost:3001',
|
||||||
|
filterAppLabel: process.env.FILTER_APP_LABEL || 'pixelstream',
|
||||||
matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16,
|
matrixWidth: parseInt(process.env.MATRIX_WIDTH) || 16,
|
||||||
matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16,
|
matrixHeight: parseInt(process.env.MATRIX_HEIGHT) || 16,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,35 +1,12 @@
|
|||||||
// UDP Discovery service for SPORE nodes
|
// UDP Sender service for sending frames to SPORE nodes
|
||||||
|
|
||||||
const dgram = require('dgram');
|
const dgram = require('dgram');
|
||||||
const EventEmitter = require('events');
|
|
||||||
const os = require('os');
|
|
||||||
|
|
||||||
class UdpDiscovery extends EventEmitter {
|
class UdpSender {
|
||||||
constructor(port = 4210) {
|
constructor(port = 4210) {
|
||||||
super();
|
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.nodes = new Map(); // ip -> { lastSeen, status }
|
|
||||||
this.discoveryInterval = null;
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
// Get local network interfaces to filter out local server
|
|
||||||
this.localInterfaces = this.getLocalInterfaces();
|
|
||||||
}
|
|
||||||
|
|
||||||
getLocalInterfaces() {
|
|
||||||
const interfaces = os.networkInterfaces();
|
|
||||||
const localIPs = new Set();
|
|
||||||
|
|
||||||
Object.values(interfaces).forEach(iface => {
|
|
||||||
iface.forEach(addr => {
|
|
||||||
if (addr.family === 'IPv4' && !addr.internal) {
|
|
||||||
localIPs.add(addr.address);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return localIPs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -40,24 +17,15 @@ class UdpDiscovery extends EventEmitter {
|
|||||||
this.socket = dgram.createSocket('udp4');
|
this.socket = dgram.createSocket('udp4');
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
|
|
||||||
this.socket.on('message', (msg, rinfo) => {
|
|
||||||
this.handleMessage(msg, rinfo);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socket.on('error', (err) => {
|
this.socket.on('error', (err) => {
|
||||||
console.error('UDP Discovery socket error:', err);
|
console.error('UDP Sender socket error:', err);
|
||||||
this.emit('error', err);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.bind(this.port, () => {
|
this.socket.bind(0, () => {
|
||||||
console.log(`UDP Discovery listening on port ${this.port}`);
|
// Bind to any available port
|
||||||
// Enable broadcast after binding
|
|
||||||
this.socket.setBroadcast(true);
|
this.socket.setBroadcast(true);
|
||||||
this.emit('started');
|
console.log(`UDP Sender ready on port ${this.socket.address().port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start periodic discovery broadcast
|
|
||||||
this.startDiscoveryBroadcast();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -67,101 +35,12 @@ class UdpDiscovery extends EventEmitter {
|
|||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
if (this.discoveryInterval) {
|
|
||||||
clearInterval(this.discoveryInterval);
|
|
||||||
this.discoveryInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nodes.clear();
|
console.log('UDP Sender stopped');
|
||||||
console.log('UDP Discovery stopped');
|
|
||||||
this.emit('stopped');
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMessage(msg, rinfo) {
|
|
||||||
const message = msg.toString('utf8');
|
|
||||||
const nodeIp = rinfo.address;
|
|
||||||
|
|
||||||
// Skip local server IPs
|
|
||||||
if (this.localInterfaces.has(nodeIp)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update node last seen time
|
|
||||||
this.nodes.set(nodeIp, {
|
|
||||||
lastSeen: Date.now(),
|
|
||||||
status: 'connected',
|
|
||||||
address: nodeIp,
|
|
||||||
port: rinfo.port
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit node discovered/updated event
|
|
||||||
this.emit('nodeDiscovered', {
|
|
||||||
ip: nodeIp,
|
|
||||||
port: rinfo.port,
|
|
||||||
status: 'connected'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up stale nodes periodically
|
|
||||||
this.cleanupStaleNodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
startDiscoveryBroadcast() {
|
|
||||||
// Broadcast discovery message every 5 seconds
|
|
||||||
this.discoveryInterval = setInterval(() => {
|
|
||||||
this.broadcastDiscovery();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// Send initial broadcast
|
|
||||||
this.broadcastDiscovery();
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastDiscovery() {
|
|
||||||
if (!this.socket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const discoveryMessage = 'SPORE_DISCOVERY';
|
|
||||||
const message = Buffer.from(discoveryMessage, 'utf8');
|
|
||||||
|
|
||||||
// Broadcast to all nodes on the network (broadcast already enabled in bind callback)
|
|
||||||
|
|
||||||
this.socket.send(message, 0, message.length, this.port, '255.255.255.255', (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('Error broadcasting discovery message:', err);
|
|
||||||
} else {
|
|
||||||
console.log('Discovery message broadcasted');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupStaleNodes() {
|
|
||||||
const now = Date.now();
|
|
||||||
const staleThreshold = 10000; // 10 seconds
|
|
||||||
|
|
||||||
for (const [ip, node] of this.nodes.entries()) {
|
|
||||||
if (now - node.lastSeen > staleThreshold) {
|
|
||||||
this.nodes.delete(ip);
|
|
||||||
this.emit('nodeLost', { ip, status: 'disconnected' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodes() {
|
|
||||||
const nodes = Array.from(this.nodes.entries()).map(([ip, node]) => ({
|
|
||||||
ip,
|
|
||||||
...node
|
|
||||||
}));
|
|
||||||
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodeCount() {
|
|
||||||
return this.nodes.size;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToNode(nodeIp, message) {
|
sendToNode(nodeIp, message) {
|
||||||
@@ -170,15 +49,17 @@ class UdpDiscovery extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buffer = Buffer.from(message, 'utf8');
|
const buffer = Buffer.from(message, 'utf8');
|
||||||
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(`Error sending to node ${nodeIp}:`, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
return new Promise((resolve, reject) => {
|
||||||
|
this.socket.send(buffer, 0, buffer.length, this.port, nodeIp, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Error sending to node ${nodeIp}:`, err);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastToAll(message) {
|
broadcastToAll(message) {
|
||||||
@@ -189,16 +70,17 @@ class UdpDiscovery extends EventEmitter {
|
|||||||
const buffer = Buffer.from(message, 'utf8');
|
const buffer = Buffer.from(message, 'utf8');
|
||||||
this.socket.setBroadcast(true);
|
this.socket.setBroadcast(true);
|
||||||
|
|
||||||
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (err) {
|
this.socket.send(buffer, 0, buffer.length, this.port, '255.255.255.255', (err) => {
|
||||||
console.error('Error broadcasting message:', err);
|
if (err) {
|
||||||
return false;
|
console.error('Error broadcasting message:', err);
|
||||||
}
|
reject(err);
|
||||||
return true;
|
return;
|
||||||
|
}
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = UdpDiscovery;
|
module.exports = UdpSender;
|
||||||
|
|||||||
Reference in New Issue
Block a user