Compare commits

...

4 Commits

Author SHA1 Message Date
a0c49a29bc feat: multiarch build 2025-10-27 21:38:01 +01:00
06fc5e6747 feat: add Docker build 2025-10-27 15:19:40 +01:00
be6d9bb98a Merge pull request 'feature/gateway' (#2) from feature/gateway into main
Reviewed-on: #2
2025-10-27 13:10:47 +01:00
7784365361 feat: subscribe to websocket for cluster updates 2025-10-27 13:05:35 +01:00
5 changed files with 233 additions and 88 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.git
.gitignore
.cursor
*.md
node_modules
README.md
docs

41
Dockerfile Normal file
View 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
View 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 \
.

View File

@@ -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

View File

@@ -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,47 +33,87 @@ 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);
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(); const newNodes = new Map();
let totalNodes = 0;
let filteredNodes = 0;
if (data.nodes && Array.isArray(data.nodes)) { if (members && Array.isArray(members)) {
totalNodes = data.nodes.length; members.forEach(node => {
data.nodes.forEach(node => {
// Filter for nodes with specified app label (if filtering is enabled) // Filter for nodes with specified app label (if filtering is enabled)
if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) { if (this.filterAppLabel && !this.hasAppLabel(node, this.filterAppLabel)) {
filteredNodes++;
return; return;
} }
const nodeIp = node.ip; const nodeIp = node.ip || node.IP;
newNodes.set(nodeIp, { newNodes.set(nodeIp, {
lastSeen: Date.now(), lastSeen: Date.now(),
status: node.status || 'active', status: node.status || node.Status || 'active',
hostname: node.hostname || nodeIp, hostname: node.hostname || node.Hostname || nodeIp,
port: node.port || 4210, port: node.port || node.Port || 4210,
isPrimary: node.isPrimary || false isPrimary: (primaryNode === nodeIp),
labels: node.labels || node.Labels
}); });
}); });
//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 // Check for newly discovered nodes
@@ -85,10 +122,14 @@ class GatewayClient {
if (!existingNode) { if (!existingNode) {
console.log(`Node discovered via gateway: ${ip} (${nodeInfo.hostname})`); console.log(`Node discovered via gateway: ${ip} (${nodeInfo.hostname})`);
this.nodes.set(ip, nodeInfo); this.nodes.set(ip, nodeInfo);
// Could emit an event here if needed: this.emit('nodeDiscovered', nodeInfo);
} else if (existingNode.hostname !== nodeInfo.hostname) { } else if (existingNode.hostname !== nodeInfo.hostname) {
console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`); console.log(`Node hostname updated: ${ip} -> ${nodeInfo.hostname}`);
this.nodes.set(ip, nodeInfo); this.nodes.set(ip, nodeInfo);
} else {
// Update status and last seen
existingNode.status = nodeInfo.status;
existingNode.lastSeen = nodeInfo.lastSeen;
existingNode.isPrimary = nodeInfo.isPrimary;
} }
} }
@@ -97,39 +138,38 @@ class GatewayClient {
if (!newNodes.has(ip)) { if (!newNodes.has(ip)) {
console.log(`Node lost via gateway: ${ip}`); console.log(`Node lost via gateway: ${ip}`);
this.nodes.delete(ip); this.nodes.delete(ip);
// Could emit an event here if needed: this.emit('nodeLost', { ip }); }
} }
} }
} catch (error) { handleNodeDiscovery(action, nodeIp) {
console.error('Error fetching nodes from gateway:', error.message); 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);
} }
} }
httpGet(url) { scheduleReconnect() {
return new Promise((resolve, reject) => { if (!this.isRunning) {
http.get(url, (res) => { return;
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
resolve(data);
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
} }
});
res.on('error', (err) => { if (this.reconnectAttempts >= this.maxReconnectAttempts) {
reject(err); console.error('Max WebSocket reconnection attempts reached');
}); return;
}).on('error', (err) => { }
reject(err);
}); 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;