feat: Pulse sensor integration with real-time WebSocket alerts
This commit is contained in:
parent
2ca6fb01f4
commit
5c86b98628
12 changed files with 997 additions and 9 deletions
5
.agent/rules/dev.md
Normal file
5
.agent/rules/dev.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
---
|
||||||
|
|
||||||
|
workspace is ~/DEV
|
||||||
117
backend/package-lock.json
generated
117
backend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/jwt": "^7.2.4",
|
"@fastify/jwt": "^7.2.4",
|
||||||
"@fastify/multipart": "^8.0.0",
|
"@fastify/multipart": "^8.0.0",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -799,6 +800,43 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/websocket": {
|
||||||
|
"version": "11.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||||
|
"integrity": "sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"duplexify": "^4.1.3",
|
||||||
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"ws": "^8.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fastify/websocket/node_modules/fastify-plugin": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/config-array": {
|
"node_modules/@humanwhocodes/config-array": {
|
||||||
"version": "0.13.0",
|
"version": "0.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||||
|
|
@ -2896,6 +2934,18 @@
|
||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexify": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"end-of-stream": "^1.4.1",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.1.1",
|
||||||
|
"stream-shift": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dynamic-dedupe": {
|
"node_modules/dynamic-dedupe": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz",
|
||||||
|
|
@ -2942,6 +2992,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/end-of-stream": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"once": "^1.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
|
|
@ -5026,7 +5085,6 @@
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
|
|
@ -5481,6 +5539,20 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -5903,6 +5975,12 @@
|
||||||
"reusify": "^1.0.0"
|
"reusify": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-shift": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stream-wormhole": {
|
"node_modules/stream-wormhole": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz",
|
||||||
|
|
@ -5912,6 +5990,15 @@
|
||||||
"node": ">=4.0.0"
|
"node": ">=4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-length": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
|
|
@ -6393,6 +6480,12 @@
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/util-deprecate": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/v8-compile-cache-lib": {
|
"node_modules/v8-compile-cache-lib": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||||
|
|
@ -6480,7 +6573,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/write-file-atomic": {
|
"node_modules/write-file-atomic": {
|
||||||
|
|
@ -6497,6 +6589,27 @@
|
||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/jwt": "^7.2.4",
|
"@fastify/jwt": "^7.2.4",
|
||||||
"@fastify/multipart": "^8.0.0",
|
"@fastify/multipart": "^8.0.0",
|
||||||
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@prisma/client": "^5.7.0",
|
"@prisma/client": "^5.7.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
|
@ -40,4 +41,4 @@
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
backend/src/plugins/websocket.ts
Normal file
115
backend/src/plugins/websocket.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* WebSocket Plugin for Real-time Alerts
|
||||||
|
*
|
||||||
|
* Broadcasts environment alerts to connected clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import websocket from '@fastify/websocket';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
interface AlertMessage {
|
||||||
|
type: 'ALERT' | 'READING' | 'HEARTBEAT';
|
||||||
|
data: any;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected clients
|
||||||
|
const clients: Map<string, WebSocket> = new Map();
|
||||||
|
|
||||||
|
export async function websocketPlugin(fastify: FastifyInstance) {
|
||||||
|
await fastify.register(websocket);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket endpoint for real-time alerts
|
||||||
|
*/
|
||||||
|
fastify.get('/ws/alerts', { websocket: true }, (socket, request) => {
|
||||||
|
const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
clients.set(clientId, socket);
|
||||||
|
fastify.log.info(`WebSocket client connected: ${clientId}`);
|
||||||
|
|
||||||
|
// Send welcome message
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'CONNECTED',
|
||||||
|
clientId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
|
||||||
|
socket.on('message', (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
// Handle ping/pong for keepalive
|
||||||
|
if (data.type === 'PING') {
|
||||||
|
socket.send(JSON.stringify({ type: 'PONG', timestamp: new Date().toISOString() }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid messages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
clients.delete(clientId);
|
||||||
|
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
fastify.log.error(`WebSocket error for ${clientId}:`, error);
|
||||||
|
clients.delete(clientId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an alert to all connected clients
|
||||||
|
*/
|
||||||
|
export function broadcastAlert(alert: any): void {
|
||||||
|
const message: AlertMessage = {
|
||||||
|
type: 'ALERT',
|
||||||
|
data: alert,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
|
||||||
|
clients.forEach((socket, clientId) => {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === 1) { // OPEN
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to broadcast to ${clientId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast a sensor reading update
|
||||||
|
*/
|
||||||
|
export function broadcastReading(reading: any): void {
|
||||||
|
const message: AlertMessage = {
|
||||||
|
type: 'READING',
|
||||||
|
data: reading,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = JSON.stringify(message);
|
||||||
|
|
||||||
|
clients.forEach((socket, clientId) => {
|
||||||
|
try {
|
||||||
|
if (socket.readyState === 1) {
|
||||||
|
socket.send(payload);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to broadcast reading to ${clientId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of connected clients
|
||||||
|
*/
|
||||||
|
export function getConnectedClientCount(): number {
|
||||||
|
return clients.size;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { broadcastAlert } from '../plugins/websocket';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
|
@ -586,8 +587,16 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
||||||
...data
|
...data
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Fan out to notification channels (in-app notifications)
|
// Broadcast to WebSocket clients for real-time notifications
|
||||||
// This will integrate with the notification service for mobile push
|
broadcastAlert({
|
||||||
|
id: alert.id,
|
||||||
|
type: data.alertType,
|
||||||
|
sensorName: data.sensorName,
|
||||||
|
value: data.currentValue,
|
||||||
|
threshold: data.threshold,
|
||||||
|
message: alert.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
178
backend/src/routes/pulse.routes.ts
Normal file
178
backend/src/routes/pulse.routes.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
/**
|
||||||
|
* Pulse Integration Routes
|
||||||
|
*
|
||||||
|
* Exposes Pulse sensor data through Veridian's API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { getPulseService, initPulseService } from '../services/pulse.service';
|
||||||
|
|
||||||
|
export async function pulseRoutes(fastify: FastifyInstance) {
|
||||||
|
// Auth middleware
|
||||||
|
fastify.addHook('onRequest', async (request) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify();
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/status
|
||||||
|
* Check Pulse API connection status
|
||||||
|
*/
|
||||||
|
fastify.get('/status', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
error: 'Pulse API key not configured',
|
||||||
|
hint: 'Set PULSE_API_KEY environment variable'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pulse.testConnection();
|
||||||
|
return {
|
||||||
|
connected: result.success,
|
||||||
|
deviceCount: result.deviceCount,
|
||||||
|
error: result.error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /pulse/configure
|
||||||
|
* Configure Pulse API key (admin only)
|
||||||
|
*/
|
||||||
|
fastify.post('/configure', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { apiKey } = request.body as { apiKey: string };
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return reply.status(400).send({ error: 'API key required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the key before saving
|
||||||
|
const testService = initPulseService(apiKey);
|
||||||
|
const result = await testService.testConnection();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Invalid API key',
|
||||||
|
details: result.error
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Store in database/env for persistence
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deviceCount: result.deviceCount,
|
||||||
|
message: 'Pulse API configured successfully'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices
|
||||||
|
* List all Pulse devices
|
||||||
|
*/
|
||||||
|
fastify.get('/devices', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await pulse.getDevices();
|
||||||
|
return { devices };
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/readings
|
||||||
|
* Get current readings from all devices
|
||||||
|
*/
|
||||||
|
fastify.get('/readings', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readings = await pulse.getCurrentReadings();
|
||||||
|
return {
|
||||||
|
readings,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices/:id/readings
|
||||||
|
* Get current reading for a specific device
|
||||||
|
*/
|
||||||
|
fastify.get('/devices/:id/readings', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reading = await pulse.getDeviceReading(id);
|
||||||
|
if (!reading) {
|
||||||
|
return reply.status(404).send({ error: 'Device not found' });
|
||||||
|
}
|
||||||
|
return { deviceId: id, ...reading };
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /pulse/devices/:id/history
|
||||||
|
* Get historical readings for a device
|
||||||
|
*/
|
||||||
|
fastify.get('/devices/:id/history', {
|
||||||
|
handler: async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const { hours = 24 } = request.query as { hours?: number };
|
||||||
|
const pulse = getPulseService();
|
||||||
|
|
||||||
|
if (!pulse) {
|
||||||
|
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readings = await pulse.getHistory(id, hours);
|
||||||
|
return {
|
||||||
|
deviceId: id,
|
||||||
|
hours,
|
||||||
|
count: readings.length,
|
||||||
|
readings
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
fastify.log.error(error);
|
||||||
|
return reply.status(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
124
backend/src/scripts/demo-alerts.ts
Normal file
124
backend/src/scripts/demo-alerts.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
/**
|
||||||
|
* Demo Alert Generator
|
||||||
|
*
|
||||||
|
* Generates fake sensor alerts for demonstration purposes.
|
||||||
|
* Run with: npx ts-node src/scripts/demo-alerts.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const DEMO_SENSORS = [
|
||||||
|
{ id: 'demo-pulse-1', name: 'Flower Room - Pulse Pro', roomId: 'flower-room' },
|
||||||
|
{ id: 'demo-pulse-2', name: 'Veg Room - Pulse', roomId: 'veg-room' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALERT_TYPES = [
|
||||||
|
{ type: 'TEMPERATURE_HIGH', metric: 'temperature', min: 82, max: 88 },
|
||||||
|
{ type: 'TEMPERATURE_LOW', metric: 'temperature', min: 58, max: 64 },
|
||||||
|
{ type: 'HUMIDITY_HIGH', metric: 'humidity', min: 72, max: 78 },
|
||||||
|
{ type: 'HUMIDITY_LOW', metric: 'humidity', min: 35, max: 42 },
|
||||||
|
{ type: 'VPD_HIGH', metric: 'vpd', min: 1.5, max: 1.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generateDemoAlert() {
|
||||||
|
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||||
|
const alertConfig = ALERT_TYPES[Math.floor(Math.random() * ALERT_TYPES.length)];
|
||||||
|
|
||||||
|
const value = alertConfig.min + Math.random() * (alertConfig.max - alertConfig.min);
|
||||||
|
const threshold = alertConfig.type.includes('HIGH')
|
||||||
|
? value - 5
|
||||||
|
: value + 5;
|
||||||
|
|
||||||
|
const alert = await prisma.environmentAlert.create({
|
||||||
|
data: {
|
||||||
|
type: alertConfig.type,
|
||||||
|
severity: 'WARNING',
|
||||||
|
message: `${sensor.name}: ${alertConfig.metric} ${alertConfig.type.includes('HIGH') ? 'above' : 'below'} threshold (${value.toFixed(1)} vs ${threshold.toFixed(1)})`,
|
||||||
|
value: value,
|
||||||
|
threshold: threshold,
|
||||||
|
metadata: {
|
||||||
|
sensorId: sensor.id,
|
||||||
|
sensorName: sensor.name,
|
||||||
|
roomId: sensor.roomId,
|
||||||
|
isDemo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🚨 Generated alert: ${alert.message}`);
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateDemoReading() {
|
||||||
|
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||||
|
|
||||||
|
// Generate realistic readings
|
||||||
|
const temperature = 72 + (Math.random() - 0.5) * 10; // 67-77°F
|
||||||
|
const humidity = 55 + (Math.random() - 0.5) * 15; // 47-62%
|
||||||
|
const vpd = 1.0 + (Math.random() - 0.5) * 0.4; // 0.8-1.2 kPa
|
||||||
|
|
||||||
|
const reading = await prisma.sensorReading.create({
|
||||||
|
data: {
|
||||||
|
sensorId: sensor.id,
|
||||||
|
value: temperature,
|
||||||
|
unit: '°F',
|
||||||
|
timestamp: new Date(),
|
||||||
|
metadata: {
|
||||||
|
humidity,
|
||||||
|
vpd,
|
||||||
|
sensorName: sensor.name,
|
||||||
|
isDemo: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📊 Generated reading: ${sensor.name} - ${temperature.toFixed(1)}°F, ${humidity.toFixed(1)}% RH`);
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runDemoMode(intervalMs: number = 5000, alertChance: number = 0.2) {
|
||||||
|
console.log('🎭 Demo mode started');
|
||||||
|
console.log(` Interval: ${intervalMs}ms`);
|
||||||
|
console.log(` Alert chance: ${alertChance * 100}%`);
|
||||||
|
console.log(' Press Ctrl+C to stop\n');
|
||||||
|
|
||||||
|
const loop = async () => {
|
||||||
|
await generateDemoReading();
|
||||||
|
|
||||||
|
if (Math.random() < alertChance) {
|
||||||
|
await generateDemoAlert();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial run
|
||||||
|
await loop();
|
||||||
|
|
||||||
|
// Repeat
|
||||||
|
setInterval(loop, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI interface
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const command = args[0] || 'run';
|
||||||
|
|
||||||
|
if (command === 'alert') {
|
||||||
|
generateDemoAlert().then(() => process.exit(0));
|
||||||
|
} else if (command === 'reading') {
|
||||||
|
generateDemoReading().then(() => process.exit(0));
|
||||||
|
} else if (command === 'run') {
|
||||||
|
const interval = parseInt(args[1] || '5000');
|
||||||
|
const alertChance = parseFloat(args[2] || '0.2');
|
||||||
|
runDemoMode(interval, alertChance);
|
||||||
|
} else {
|
||||||
|
console.log(`
|
||||||
|
Usage:
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts alert - Generate single alert
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts reading - Generate single reading
|
||||||
|
npx ts-node src/scripts/demo-alerts.ts run [interval] [alertChance]
|
||||||
|
- Run continuous demo mode
|
||||||
|
- interval: ms between readings (default: 5000)
|
||||||
|
- alertChance: 0-1 probability (default: 0.2)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,8 @@ import { metrcRoutes } from './routes/metrc.routes';
|
||||||
import { visitorRoutes } from './routes/visitors.routes';
|
import { visitorRoutes } from './routes/visitors.routes';
|
||||||
import { accessZoneRoutes } from './routes/access-zones.routes';
|
import { accessZoneRoutes } from './routes/access-zones.routes';
|
||||||
import { messagingRoutes } from './routes/messaging.routes';
|
import { messagingRoutes } from './routes/messaging.routes';
|
||||||
|
import { pulseRoutes } from './routes/pulse.routes';
|
||||||
|
import { websocketPlugin } from './plugins/websocket';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -77,6 +79,12 @@ server.register(financialRoutes, { prefix: '/api/financial' });
|
||||||
server.register(insightsRoutes, { prefix: '/api/insights' });
|
server.register(insightsRoutes, { prefix: '/api/insights' });
|
||||||
server.register(uploadRoutes, { prefix: '/api/upload' });
|
server.register(uploadRoutes, { prefix: '/api/upload' });
|
||||||
|
|
||||||
|
// Pulse sensor integration
|
||||||
|
server.register(pulseRoutes, { prefix: '/api/pulse' });
|
||||||
|
|
||||||
|
// WebSocket for real-time alerts
|
||||||
|
server.register(websocketPlugin);
|
||||||
|
|
||||||
// Admin routes (demo/testing)
|
// Admin routes (demo/testing)
|
||||||
import { adminRoutes } from './routes/admin.routes';
|
import { adminRoutes } from './routes/admin.routes';
|
||||||
server.register(adminRoutes, { prefix: '/api/admin' });
|
server.register(adminRoutes, { prefix: '/api/admin' });
|
||||||
|
|
|
||||||
157
backend/src/services/pulse.service.ts
Normal file
157
backend/src/services/pulse.service.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
/**
|
||||||
|
* Pulse Grow API Service
|
||||||
|
*
|
||||||
|
* Server-side integration with Pulse Grow sensor platform.
|
||||||
|
* API Docs: https://api.pulsegrow.com/docs
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PULSE_API_BASE = 'https://api.pulsegrow.com';
|
||||||
|
|
||||||
|
export interface PulseDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PulseReading {
|
||||||
|
deviceId: string;
|
||||||
|
deviceName: string;
|
||||||
|
temperature: number; // Fahrenheit
|
||||||
|
humidity: number; // %
|
||||||
|
vpd: number; // kPa
|
||||||
|
dewpoint: number; // Fahrenheit
|
||||||
|
light?: number; // PPFD (for Pro devices)
|
||||||
|
co2?: number; // ppm (for Pro devices)
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PulseService {
|
||||||
|
private apiKey: string;
|
||||||
|
|
||||||
|
constructor(apiKey: string) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(path: string): Promise<any> {
|
||||||
|
const res = await fetch(`${PULSE_API_BASE}${path}`, {
|
||||||
|
headers: {
|
||||||
|
'x-api-key': this.apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Pulse API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all devices for this grow
|
||||||
|
*/
|
||||||
|
async getDevices(): Promise<PulseDevice[]> {
|
||||||
|
const data = await this.fetch('/devices');
|
||||||
|
return (data.devices || data || []).map((d: any) => ({
|
||||||
|
id: d.id || d.deviceId,
|
||||||
|
name: d.name || d.deviceName || 'Unknown',
|
||||||
|
type: d.type || 'pulse',
|
||||||
|
isOnline: d.isOnline ?? d.online ?? true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current readings for all devices
|
||||||
|
*/
|
||||||
|
async getCurrentReadings(): Promise<PulseReading[]> {
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
const readings: PulseReading[] = [];
|
||||||
|
|
||||||
|
for (const device of devices) {
|
||||||
|
try {
|
||||||
|
const reading = await this.getDeviceReading(device.id);
|
||||||
|
if (reading) {
|
||||||
|
readings.push({
|
||||||
|
deviceId: device.id,
|
||||||
|
deviceName: device.name,
|
||||||
|
...reading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to get reading for ${device.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return readings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current reading for a specific device
|
||||||
|
*/
|
||||||
|
async getDeviceReading(deviceId: string): Promise<Omit<PulseReading, 'deviceId' | 'deviceName'> | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetch(`/devices/${deviceId}/sensors/current`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
temperature: data.temperature?.value ?? data.temp ?? 0,
|
||||||
|
humidity: data.humidity?.value ?? data.rh ?? 0,
|
||||||
|
vpd: data.vpd?.value ?? data.vpd ?? 0,
|
||||||
|
dewpoint: data.dewpoint?.value ?? data.dew ?? 0,
|
||||||
|
light: data.ppfd?.value ?? data.light,
|
||||||
|
co2: data.co2?.value ?? data.co2,
|
||||||
|
timestamp: new Date(data.timestamp || Date.now()),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical data for a device
|
||||||
|
*/
|
||||||
|
async getHistory(deviceId: string, hours: number = 24): Promise<PulseReading[]> {
|
||||||
|
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||||
|
const data = await this.fetch(`/devices/${deviceId}/sensors/data?start=${start}`);
|
||||||
|
|
||||||
|
return (data.readings || data || []).map((r: any) => ({
|
||||||
|
deviceId,
|
||||||
|
deviceName: '',
|
||||||
|
temperature: r.temperature ?? r.temp ?? 0,
|
||||||
|
humidity: r.humidity ?? r.rh ?? 0,
|
||||||
|
vpd: r.vpd ?? 0,
|
||||||
|
dewpoint: r.dewpoint ?? r.dew ?? 0,
|
||||||
|
light: r.ppfd ?? r.light,
|
||||||
|
co2: r.co2,
|
||||||
|
timestamp: new Date(r.timestamp),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Pulse API
|
||||||
|
*/
|
||||||
|
async testConnection(): Promise<{ success: boolean; deviceCount: number; error?: string }> {
|
||||||
|
try {
|
||||||
|
const devices = await this.getDevices();
|
||||||
|
return { success: true, deviceCount: devices.length };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { success: false, deviceCount: 0, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance (initialized with API key from env)
|
||||||
|
let pulseServiceInstance: PulseService | null = null;
|
||||||
|
|
||||||
|
export function getPulseService(): PulseService | null {
|
||||||
|
if (!pulseServiceInstance && process.env.PULSE_API_KEY) {
|
||||||
|
pulseServiceInstance = new PulseService(process.env.PULSE_API_KEY);
|
||||||
|
}
|
||||||
|
return pulseServiceInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initPulseService(apiKey: string): PulseService {
|
||||||
|
pulseServiceInstance = new PulseService(apiKey);
|
||||||
|
return pulseServiceInstance;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import { pageVariants } from '../lib/animations';
|
||||||
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
import { UserMenu } from './layout/UserMenu';
|
import { UserMenu } from './layout/UserMenu';
|
||||||
|
import { NotificationBell } from './notifications/NotificationBell';
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
@ -91,10 +92,7 @@ export default function Layout() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button className="relative p-2 text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] transition-colors rounded-lg hover:bg-[var(--color-bg-tertiary)]">
|
<NotificationBell />
|
||||||
<Bell size={20} />
|
|
||||||
<span className="absolute top-2 right-2 w-2 h-2 bg-[var(--color-error)] rounded-full ring-2 ring-[var(--color-bg-primary)]" />
|
|
||||||
</button>
|
|
||||||
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>
|
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>
|
||||||
<Filter size={20} />
|
<Filter size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
129
frontend/src/components/notifications/NotificationBell.tsx
Normal file
129
frontend/src/components/notifications/NotificationBell.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
/**
|
||||||
|
* Notification Bell Component
|
||||||
|
*
|
||||||
|
* Shows in the header with badge count and dropdown of recent alerts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Bell, X, AlertTriangle, Thermometer, Droplets } from 'lucide-react';
|
||||||
|
import { useNotifications } from '../../hooks/useNotifications';
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const { connected, alerts, unreadCount, clearUnread, clearAll } = useNotifications();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const toggleOpen = () => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
if (!isOpen && unreadCount > 0) {
|
||||||
|
clearUnread();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlertIcon = (type: string) => {
|
||||||
|
if (type.includes('TEMPERATURE')) return <Thermometer className="w-4 h-4" />;
|
||||||
|
if (type.includes('HUMIDITY')) return <Droplets className="w-4 h-4" />;
|
||||||
|
return <AlertTriangle className="w-4 h-4" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlertColor = (type: string) => {
|
||||||
|
if (type.includes('HIGH')) return 'text-red-500';
|
||||||
|
if (type.includes('LOW')) return 'text-blue-500';
|
||||||
|
return 'text-yellow-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Bell Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleOpen}
|
||||||
|
className="relative p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<Bell className={`w-5 h-5 ${connected ? 'text-gray-600 dark:text-gray-400' : 'text-gray-400'}`} />
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full animate-pulse">
|
||||||
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connection indicator */}
|
||||||
|
<span className={`absolute bottom-1 right-1 w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="absolute right-0 top-12 z-50 w-80 max-h-96 overflow-hidden rounded-xl bg-white dark:bg-gray-900 shadow-2xl border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearAll}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts List */}
|
||||||
|
<div className="max-h-72 overflow-y-auto">
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-gray-500">
|
||||||
|
<Bell className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No notifications</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
alerts.map((alert, index) => (
|
||||||
|
<div
|
||||||
|
key={alert.id || index}
|
||||||
|
className="px-4 py-3 border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`mt-0.5 ${getAlertColor(alert.type)}`}>
|
||||||
|
{getAlertIcon(alert.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-gray-900 dark:text-white truncate">
|
||||||
|
{alert.sensorName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{alert.value.toFixed(1)} (threshold: {alert.threshold.toFixed(1)})
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{new Date(alert.timestamp).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{alerts.length > 0 && (
|
||||||
|
<div className="px-4 py-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<a
|
||||||
|
href="/environment"
|
||||||
|
className="block text-center text-sm text-emerald-600 hover:text-emerald-700 dark:text-emerald-500"
|
||||||
|
>
|
||||||
|
View all alerts →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
frontend/src/hooks/useNotifications.ts
Normal file
151
frontend/src/hooks/useNotifications.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/**
|
||||||
|
* WebSocket hook for real-time alerts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AlertMessage {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
sensorName: string;
|
||||||
|
value: number;
|
||||||
|
threshold: number;
|
||||||
|
message: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseNotificationsOptions {
|
||||||
|
showToasts?: boolean;
|
||||||
|
playSound?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications(options: UseNotificationsOptions = {}) {
|
||||||
|
const { showToasts = true, playSound = true } = options;
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [alerts, setAlerts] = useState<AlertMessage[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
// Determine WebSocket URL based on current location
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
const wsUrl = `${protocol}//${host}/ws/alerts`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('🔔 Notification WebSocket connected');
|
||||||
|
setConnected(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (message.type === 'ALERT') {
|
||||||
|
const alert = message.data as AlertMessage;
|
||||||
|
|
||||||
|
// Add to alerts list
|
||||||
|
setAlerts(prev => [alert, ...prev].slice(0, 50));
|
||||||
|
setUnreadCount(prev => prev + 1);
|
||||||
|
|
||||||
|
// Show toast notification
|
||||||
|
if (showToasts) {
|
||||||
|
const isHigh = alert.type.includes('HIGH');
|
||||||
|
toast.error(alert.message, {
|
||||||
|
icon: isHigh ? '🔥' : '❄️',
|
||||||
|
duration: 10000,
|
||||||
|
action: {
|
||||||
|
label: 'View',
|
||||||
|
onClick: () => {
|
||||||
|
// Could navigate to environment dashboard
|
||||||
|
window.location.href = '/environment';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play sound
|
||||||
|
if (playSound) {
|
||||||
|
playAlertSound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse WebSocket message:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('🔔 Notification WebSocket disconnected');
|
||||||
|
setConnected(false);
|
||||||
|
|
||||||
|
// Reconnect after delay
|
||||||
|
setTimeout(connect, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
ws.close();
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to connect WebSocket:', error);
|
||||||
|
}
|
||||||
|
}, [showToasts, playSound]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearUnread = useCallback(() => {
|
||||||
|
setUnreadCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(() => {
|
||||||
|
setAlerts([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
return () => disconnect();
|
||||||
|
}, [connect, disconnect]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected,
|
||||||
|
alerts,
|
||||||
|
unreadCount,
|
||||||
|
clearUnread,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function playAlertSound() {
|
||||||
|
try {
|
||||||
|
// Create a simple beep sound
|
||||||
|
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!AudioContext) return;
|
||||||
|
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const oscillator = ctx.createOscillator();
|
||||||
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||||
|
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||||||
|
|
||||||
|
oscillator.start(ctx.currentTime);
|
||||||
|
oscillator.stop(ctx.currentTime + 0.3);
|
||||||
|
} catch {
|
||||||
|
// Ignore audio errors
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue