Replace browser MQTT bridge with backend device-events service
The frontend previously connected directly to the MQTT broker via a CDN-loaded Paho client and a Paho-specific ws proxy. Move that responsibility into the backend: DeviceDataService persists MQTT messages to Postgres, and DeviceEventsController exposes them over a REST endpoint plus a WebSocket gateway that the frontend now consumes directly. Also adds a pgadmin service for inspecting the database. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
4822
backend/package-lock.json
generated
Normal file
4822
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,14 @@
|
|||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
"reflect-metadata": "^0.1.13"
|
"@nestjs/platform-socket.io": "^10.0.0",
|
||||||
|
"@nestjs/websockets": "^10.0.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"mqtt": "^5.15.2",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.0.0",
|
||||||
|
"socket.io": "^4.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { StatusController } from './status.controller';
|
import { StatusController } from './status.controller';
|
||||||
|
import { DeviceEventsController } from './device-events.controller';
|
||||||
|
import { DeviceDataService } from './device-data.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
controllers: [AppController, StatusController],
|
controllers: [AppController, StatusController, DeviceEventsController],
|
||||||
providers: [AppService],
|
providers: [AppService, DeviceDataService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
151
backend/src/device-data.service.ts
Normal file
151
backend/src/device-data.service.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { connect as connectMqtt } from 'mqtt';
|
||||||
|
import { Client as PgClient } from 'pg';
|
||||||
|
|
||||||
|
export interface DeviceRecord {
|
||||||
|
id: number;
|
||||||
|
topic: string;
|
||||||
|
payload: string;
|
||||||
|
receivedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DeviceDataService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DeviceDataService.name);
|
||||||
|
private readonly listeners = new Set<(record: DeviceRecord) => void>();
|
||||||
|
private pgClient: PgClient | null = null;
|
||||||
|
private mqttClient: any = null;
|
||||||
|
private initialized = false;
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.connectDatabase();
|
||||||
|
await this.connectMqtt();
|
||||||
|
this.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecords(limit = 20): Promise<DeviceRecord[]> {
|
||||||
|
if (!this.pgClient) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pgClient!.query(
|
||||||
|
`SELECT id, topic, payload, received_at as "receivedAt"
|
||||||
|
FROM device_events
|
||||||
|
ORDER BY received_at DESC, id DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
[limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
topic: row.topic,
|
||||||
|
payload: row.payload,
|
||||||
|
receivedAt: row.receivedAt.toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (record: DeviceRecord) => void): () => void {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => this.listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectDatabase() {
|
||||||
|
try {
|
||||||
|
const client = new PgClient({
|
||||||
|
host: process.env.DATABASE_HOST || 'postgres',
|
||||||
|
port: Number(process.env.DATABASE_PORT || 5432),
|
||||||
|
user: process.env.DATABASE_USER || 'ulhub',
|
||||||
|
password: process.env.DATABASE_PASSWORD || 'development',
|
||||||
|
database: process.env.DATABASE_NAME || 'ulhub',
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS device_events (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
topic TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.pgClient = client;
|
||||||
|
this.logger.log('Connected to PostgreSQL and ensured device_events table exists');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to connect to PostgreSQL', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async connectMqtt() {
|
||||||
|
try {
|
||||||
|
const host = process.env.MQTT_HOST || 'mosquitto';
|
||||||
|
const port = Number(process.env.MQTT_PORT || 1883);
|
||||||
|
const username = process.env.MQTT_USERNAME || 'testuser';
|
||||||
|
const password = process.env.MQTT_PASSWORD || 'testpass';
|
||||||
|
this.mqttClient = connectMqtt(`mqtt://${host}:${port}`, {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clientId: `ulhub-backend-${Math.random().toString(16).slice(2)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mqttClient.on('connect', () => {
|
||||||
|
this.logger.log(`Connected to MQTT broker at ${host}:${port}`);
|
||||||
|
this.mqttClient!.subscribe('devices/#', (err) => {
|
||||||
|
if (err) {
|
||||||
|
this.logger.error('Failed to subscribe to devices/#', err);
|
||||||
|
} else {
|
||||||
|
this.logger.log('Subscribed to devices/#');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mqttClient.on('message', async (topic, payload) => {
|
||||||
|
await this.persistRecord(topic, payload.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mqttClient.on('error', (error) => {
|
||||||
|
this.logger.error('MQTT client error', error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to connect to MQTT broker', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistRecord(topic: string, payload: string): Promise<DeviceRecord> {
|
||||||
|
if (!this.pgClient) {
|
||||||
|
await this.initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pgClient!.query(
|
||||||
|
`INSERT INTO device_events (topic, payload)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
RETURNING id, topic, payload, received_at as "receivedAt"`,
|
||||||
|
[topic, payload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const record: DeviceRecord = {
|
||||||
|
id: result.rows[0].id,
|
||||||
|
topic: result.rows[0].topic,
|
||||||
|
payload: result.rows[0].payload,
|
||||||
|
receivedAt: result.rows[0].receivedAt.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifyListeners(record);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners(record: DeviceRecord) {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
listener(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/device-events.controller.ts
Normal file
30
backend/src/device-events.controller.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { OnGatewayConnection, OnGatewayDisconnect, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
||||||
|
import { Server, Socket } from 'socket.io';
|
||||||
|
import { DeviceDataService, DeviceRecord } from './device-data.service';
|
||||||
|
|
||||||
|
@WebSocketGateway({ path: '/api/device-events/ws' })
|
||||||
|
@Controller('device-events')
|
||||||
|
export class DeviceEventsController implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
server: Server;
|
||||||
|
|
||||||
|
constructor(private readonly deviceDataService: DeviceDataService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
return this.deviceDataService.getRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnection(client: Socket) {
|
||||||
|
client.emit('message', JSON.stringify({ type: 'connected' }));
|
||||||
|
this.deviceDataService.subscribe((record: DeviceRecord) => {
|
||||||
|
client.emit('message', JSON.stringify(record));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnect(client: Socket) {
|
||||||
|
// no-op; subscriptions are handled by the service subscription set
|
||||||
|
void client;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,5 +55,15 @@ services:
|
|||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:8
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: admin@example.com
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
|
ports:
|
||||||
|
- "5050:80"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
@@ -1,33 +1,26 @@
|
|||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const WS_PATH = '/api/device-events/ws';
|
||||||
|
|
||||||
interface ApiStatus {
|
interface ApiStatus {
|
||||||
message: string;
|
message: string;
|
||||||
docs: string;
|
docs: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MqttMessage {
|
interface DeviceRecord {
|
||||||
|
id: number;
|
||||||
topic: string;
|
topic: string;
|
||||||
payload: string;
|
payload: string;
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
Paho?: {
|
|
||||||
Client: new (...args: any[]) => any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [status, setStatus] = useState<ApiStatus | null>(null);
|
const [status, setStatus] = useState<ApiStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<MqttMessage[]>([]);
|
const [messages, setMessages] = useState<DeviceRecord[]>([]);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
|
|
||||||
const clientId = useMemo(() => `ulhub-web-${Math.random().toString(16).slice(2)}`, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/status')
|
fetch('/api/status')
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -36,90 +29,48 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const script = document.createElement('script');
|
fetch('/api/device-events')
|
||||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.min.js';
|
.then((res) => res.json())
|
||||||
script.async = true;
|
.then((payload) => {
|
||||||
console.log('Appending Paho script to page:', script.src);
|
const records = Array.isArray(payload) ? payload : payload?.records ?? [];
|
||||||
script.onerror = () => {
|
setMessages(Array.isArray(records) ? records : []);
|
||||||
console.error('Failed to load Paho MQTT script from CDN');
|
setConnected(true);
|
||||||
setError('Failed to load MQTT client script');
|
})
|
||||||
};
|
.catch((err) => {
|
||||||
script.onload = () => {
|
setError(err.message || 'Unable to load device events');
|
||||||
console.log('Paho script loaded', !!window.Paho);
|
});
|
||||||
console.log('Paho global:', window.Paho);
|
|
||||||
// connect to same-origin via nginx proxy at /mqtt so browsers use wss on HTTPS
|
|
||||||
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
||||||
const uri = `${scheme}://${window.location.host}/mqtt`;
|
|
||||||
console.log('Connecting Paho client to', uri);
|
|
||||||
|
|
||||||
let client: any;
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const socket = new WebSocket(`${wsProtocol}://${window.location.host}${WS_PATH}`);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
if (window.Paho && typeof (window.Paho as any).Client === 'function') {
|
const payload = JSON.parse(event.data);
|
||||||
client = new (window.Paho as any).Client(uri, clientId);
|
if (payload.type === 'connected') {
|
||||||
} else if (window.Paho && (window.Paho as any).MQTT && typeof (window.Paho as any).MQTT.Client === 'function') {
|
setConnected(true);
|
||||||
// Paho.MQTT.Client(host, port, path, clientId)
|
|
||||||
const url = new URL(uri);
|
|
||||||
const host = url.hostname;
|
|
||||||
const port = url.port ? Number(url.port) : (url.protocol === 'wss:' ? 443 : 80);
|
|
||||||
const path = url.pathname + (url.search || '');
|
|
||||||
client = new (window.Paho as any).MQTT.Client(host, port, path, clientId);
|
|
||||||
} else {
|
|
||||||
console.error('Paho client constructor not found', window.Paho);
|
|
||||||
setError('MQTT client not available in browser');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to instantiate Paho client', e);
|
setMessages((prev) => [payload, ...prev].slice(0, 20));
|
||||||
setError(String(e));
|
} catch (err) {
|
||||||
return;
|
console.error('Failed to parse device-event payload', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
client.onConnectionLost = (responseObject: { errorCode: number; errorMessage: string }) => {
|
|
||||||
console.warn('Paho connection lost', responseObject);
|
|
||||||
setConnected(false);
|
|
||||||
setError(responseObject.errorMessage || 'MQTT connection lost');
|
|
||||||
};
|
|
||||||
|
|
||||||
client.onMessageArrived = (message: { topic: string; payloadString: string }) => {
|
|
||||||
setMessages((prev) => [
|
|
||||||
{
|
|
||||||
topic: message.topic,
|
|
||||||
payload: message.payloadString,
|
|
||||||
receivedAt: new Date().toLocaleTimeString(),
|
|
||||||
},
|
|
||||||
...prev,
|
|
||||||
].slice(0, 10));
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectOptions: any = {
|
|
||||||
onSuccess: () => {
|
|
||||||
console.log('Paho connected');
|
|
||||||
setConnected(true);
|
|
||||||
setError(null);
|
|
||||||
client.subscribe('devices/#');
|
|
||||||
},
|
|
||||||
onFailure: (err: { errorMessage: string }) => {
|
|
||||||
console.error('Paho connect failure', err);
|
|
||||||
setConnected(false);
|
|
||||||
setError(err.errorMessage || 'Unable to connect to MQTT broker');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
// force secure websocket when page is served over HTTPS
|
|
||||||
connectOptions.useSSL = window.location.protocol === 'https:';
|
|
||||||
console.log('Paho connect options:', connectOptions);
|
|
||||||
client.connect(connectOptions);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
client.disconnect();
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.body.appendChild(script);
|
socket.onerror = () => {
|
||||||
|
setConnected(false);
|
||||||
|
setError('Realtime stream disconnected');
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.body.removeChild(script);
|
socket.close();
|
||||||
};
|
};
|
||||||
}, [clientId]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -145,10 +96,10 @@ export default function Home() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section style={{ marginTop: '1.5rem' }}>
|
<section style={{ marginTop: '1.5rem' }}>
|
||||||
<h2>MQTT WebSocket</h2>
|
<h2>Device Events</h2>
|
||||||
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
|
<p>Status: {connected ? 'Connected' : 'Connecting...'}</p>
|
||||||
<ul>
|
<ul>
|
||||||
{messages.map((msg, index) => (
|
{(Array.isArray(messages) ? messages : []).map((msg, index) => (
|
||||||
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
|
<li key={`${msg.topic}-${index}`} style={{ marginBottom: '0.75rem' }}>
|
||||||
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
|
<strong>{msg.topic}</strong> <span style={{ color: '#666' }}>{msg.receivedAt}</span>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
Reference in New Issue
Block a user