Compare commits
4 Commits
858be416eb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a0c49a29bc | |||
| 06fc5e6747 | |||
| be6d9bb98a | |||
| 7784365361 |
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 \
|
||||||
|
.
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ 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:
|
||||||
|
|
||||||
- **Gateway Integration**: Queries spore-gateway for node discovery (requires spore-gateway to be running)
|
- **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
|
- **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
|
||||||
@@ -51,7 +51,7 @@ Web UI (Browser) <--WebSocket--> Server <--UDP--> SPORE Nodes
|
|||||||
Preset Engine
|
Preset Engine
|
||||||
|
|
|
|
||||||
Frame Generation (60fps)
|
Frame Generation (60fps)
|
||||||
Gateway API (Discovery)
|
Gateway WebSocket (Real-time cluster updates)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// Gateway Client - Communicates with spore-gateway for node discovery
|
// Gateway Client - Communicates with spore-gateway for node discovery
|
||||||
|
|
||||||
const http = require('http');
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
class GatewayClient {
|
class GatewayClient {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
|
this.gatewayUrl = options.gatewayUrl || 'http://localhost:3001';
|
||||||
this.pollInterval = options.pollInterval || 2000; // Poll every 2 seconds
|
|
||||||
this.filterAppLabel = options.filterAppLabel || 'pixelstream'; // Filter nodes by app label, set to null to disable
|
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.nodes = new Map(); // ip -> { lastSeen, status, hostname, port }
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
this.pollTimer = null;
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = 10;
|
||||||
|
this.reconnectDelay = 2000;
|
||||||
|
this.reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
@@ -20,13 +23,7 @@ class GatewayClient {
|
|||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
|
console.log(`Starting Gateway client, connecting to ${this.gatewayUrl}`);
|
||||||
|
|
||||||
// Initial fetch
|
this.connectWebSocket();
|
||||||
this.fetchNodes();
|
|
||||||
|
|
||||||
// Start polling
|
|
||||||
this.pollTimer = setInterval(() => {
|
|
||||||
this.fetchNodes();
|
|
||||||
}, this.pollInterval);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
@@ -36,100 +33,143 @@ class GatewayClient {
|
|||||||
|
|
||||||
this.isRunning = false;
|
this.isRunning = false;
|
||||||
|
|
||||||
if (this.pollTimer) {
|
if (this.reconnectTimer) {
|
||||||
clearInterval(this.pollTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
this.pollTimer = null;
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.nodes.clear();
|
this.nodes.clear();
|
||||||
console.log('Gateway client stopped');
|
console.log('Gateway client stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchNodes() {
|
connectWebSocket() {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpGet(`${this.gatewayUrl}/api/discovery/nodes`);
|
// Convert http:// to ws:// for WebSocket
|
||||||
const data = JSON.parse(response);
|
const wsUrl = this.gatewayUrl.replace('http://', 'ws://').replace('https://', 'wss://') + '/ws';
|
||||||
|
console.log(`Connecting to WebSocket: ${wsUrl}`);
|
||||||
|
|
||||||
// Update nodes from gateway response
|
this.ws = new WebSocket(wsUrl);
|
||||||
const newNodes = new Map();
|
|
||||||
let totalNodes = 0;
|
|
||||||
let filteredNodes = 0;
|
|
||||||
|
|
||||||
if (data.nodes && Array.isArray(data.nodes)) {
|
this.ws.on('open', () => {
|
||||||
totalNodes = data.nodes.length;
|
console.log('WebSocket connected to gateway');
|
||||||
data.nodes.forEach(node => {
|
this.reconnectAttempts = 0;
|
||||||
// Filter for nodes with specified app label (if filtering is enabled)
|
});
|
||||||
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
|
|
||||||
filteredNodes++;
|
this.ws.on('message', (data) => {
|
||||||
return;
|
try {
|
||||||
}
|
const message = JSON.parse(data.toString());
|
||||||
|
this.handleWebSocketMessage(message);
|
||||||
const nodeIp = node.ip;
|
} catch (error) {
|
||||||
newNodes.set(nodeIp, {
|
console.error('Error parsing WebSocket message:', error);
|
||||||
lastSeen: Date.now(),
|
|
||||||
status: node.status || 'active',
|
|
||||||
hostname: node.hostname || nodeIp,
|
|
||||||
port: node.port || 4210,
|
|
||||||
isPrimary: node.isPrimary || false
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
//if (totalNodes > 0 && filteredNodes > 0 && this.filterAppLabel) {
|
|
||||||
// console.loh(`Filtered ${filteredNodes} nodes without app: ${this.filterAppLabel} label (${newNodes.size} ${this.filterAppLabel} nodes active)`);
|
|
||||||
//}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
// Could emit an event here if needed: this.emit('nodeDiscovered', nodeInfo);
|
|
||||||
} else if (existingNode.hostname !== nodeInfo.hostname) {
|
|
||||||
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
|
|
||||||
this.nodes.set(ip, nodeInfo);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Check for lost nodes
|
this.ws.on('close', (code, reason) => {
|
||||||
for (const ip of this.nodes.keys()) {
|
console.log(`WebSocket connection closed: ${code} ${reason}`);
|
||||||
if (!newNodes.has(ip)) {
|
if (this.isRunning) {
|
||||||
console.log(`Node lost via gateway: ${ip}`);
|
this.scheduleReconnect();
|
||||||
this.nodes.delete(ip);
|
|
||||||
// Could emit an event here if needed: this.emit('nodeLost', { ip });
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
this.ws.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error.message);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching nodes from gateway:', error.message);
|
console.error('Failed to create WebSocket connection:', error);
|
||||||
|
this.scheduleReconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpGet(url) {
|
handleWebSocketMessage(message) {
|
||||||
return new Promise((resolve, reject) => {
|
if (message.topic === 'cluster/update') {
|
||||||
http.get(url, (res) => {
|
this.handleClusterUpdate(message.members, message.primaryNode, message.totalNodes);
|
||||||
let data = '';
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
const nodeIp = node.ip || node.IP;
|
||||||
data += chunk;
|
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
|
||||||
});
|
});
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
if (res.statusCode === 200) {
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
}).on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 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() {
|
getNodes() {
|
||||||
@@ -138,6 +178,7 @@ class GatewayClient {
|
|||||||
hostname: node.hostname || ip,
|
hostname: node.hostname || ip,
|
||||||
port: node.port,
|
port: node.port,
|
||||||
status: node.status,
|
status: node.status,
|
||||||
|
isPrimary: node.isPrimary,
|
||||||
...node
|
...node
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -158,3 +199,4 @@ class GatewayClient {
|
|||||||
|
|
||||||
module.exports = GatewayClient;
|
module.exports = GatewayClient;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user