feat: add MQTT client for camera configuration and enhance camera management
This commit is contained in:
@@ -9,8 +9,9 @@ import {
|
||||
getRefreshToken,
|
||||
setAccessToken,
|
||||
} from '@/utils/storage';
|
||||
import { history, request, RequestConfig } from '@umijs/max';
|
||||
import { history, RequestConfig } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
||||
let refreshingTokenPromise: Promise<string | null> | null = null;
|
||||
@@ -125,7 +126,7 @@ export const handleRequestConfig: RequestConfig = {
|
||||
const isRefreshRequest = response.config.url?.includes(
|
||||
API_PATH_REFRESH_TOKEN,
|
||||
);
|
||||
console.log('Is refresh request:', isRefreshRequest);
|
||||
// console.log('Is refresh request:', isRefreshRequest);
|
||||
|
||||
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
||||
if (
|
||||
@@ -161,19 +162,30 @@ export const handleRequestConfig: RequestConfig = {
|
||||
|
||||
// Rebuild request options from config
|
||||
const originalConfig = response.config;
|
||||
|
||||
// Parse data if it is a JSON string to avoid double serialization when using axios directly
|
||||
let data = originalConfig.data;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Ignore parse error, use original string
|
||||
}
|
||||
}
|
||||
|
||||
const newOptions = {
|
||||
url: originalConfig.url,
|
||||
method: originalConfig.method,
|
||||
headers: {
|
||||
...(originalConfig.headers || {}),
|
||||
Authorization: `${newToken}`,
|
||||
},
|
||||
data: originalConfig.data,
|
||||
data: data,
|
||||
params: originalConfig.params,
|
||||
skipAuthRefresh: true,
|
||||
};
|
||||
|
||||
// Gọi lại request gốc với accessToken mới
|
||||
return request(originalConfig.url, newOptions);
|
||||
// Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
|
||||
return axios(newOptions);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
getRefreshToken,
|
||||
setAccessToken,
|
||||
} from '@/utils/storage';
|
||||
import { history, request, RequestConfig } from '@umijs/max';
|
||||
import { history, RequestConfig } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
||||
let refreshingTokenPromise: Promise<string | null> | null = null;
|
||||
@@ -52,7 +53,8 @@ export const handleRequestConfig: RequestConfig = {
|
||||
return (
|
||||
(status >= 200 && status < 300) ||
|
||||
status === HTTPSTATUS.HTTP_NOTFOUND ||
|
||||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
|
||||
status === HTTPSTATUS.HTTP_UNAUTHORIZED ||
|
||||
status === HTTPSTATUS.HTTP_FORBIDDEN
|
||||
);
|
||||
},
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
@@ -123,15 +125,17 @@ export const handleRequestConfig: RequestConfig = {
|
||||
// Unwrap data from backend response
|
||||
responseInterceptors: [
|
||||
async (response: any, options: any) => {
|
||||
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
|
||||
const alreadyRetried = options?.skipAuthRefresh === true;
|
||||
// const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN); // response.url may be undefined or different in prod
|
||||
const isRefreshRequest = response.config.url?.includes(
|
||||
API_PATH_REFRESH_TOKEN,
|
||||
);
|
||||
// const alreadyRetried = options?.skipAuthRefresh === true;
|
||||
|
||||
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
||||
if (
|
||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
|
||||
!isRefreshRequest &&
|
||||
!alreadyRetried
|
||||
!isRefreshRequest
|
||||
) {
|
||||
const newToken = await getValidAccessToken();
|
||||
|
||||
@@ -151,24 +155,35 @@ export const handleRequestConfig: RequestConfig = {
|
||||
|
||||
// Rebuild request options from config
|
||||
const originalConfig = response.config;
|
||||
|
||||
// Parse data if it is a JSON string to avoid double serialization when using axios directly
|
||||
let data = originalConfig.data;
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch (e) {
|
||||
// Ignore parse error, use original string
|
||||
}
|
||||
}
|
||||
|
||||
const newOptions = {
|
||||
url: originalConfig.url,
|
||||
method: originalConfig.method,
|
||||
headers: {
|
||||
...(originalConfig.headers || {}),
|
||||
Authorization: `${newToken}`,
|
||||
},
|
||||
data: originalConfig.data,
|
||||
data: data,
|
||||
params: originalConfig.params,
|
||||
skipAuthRefresh: true,
|
||||
};
|
||||
|
||||
// Gọi lại request gốc với accessToken mới
|
||||
return request(originalConfig.url, newOptions);
|
||||
// Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
|
||||
return axios(newOptions);
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
|
||||
(isRefreshRequest || alreadyRetried)
|
||||
isRefreshRequest
|
||||
) {
|
||||
clearAllData();
|
||||
history.push(ROUTE_LOGIN);
|
||||
|
||||
303
docs/mqtt-client.md
Normal file
303
docs/mqtt-client.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# MQTT Client - Hướng dẫn sử dụng
|
||||
|
||||
## Tổng quan
|
||||
|
||||
MQTT Client (`mqttClient`) là một utility singleton được thiết kế để quản lý kết nối MQTT trong ứng dụng. File này nằm tại `src/utils/mqttClient.ts`.
|
||||
|
||||
## Cài đặt
|
||||
|
||||
Package `mqtt` được cài đặt qua npm:
|
||||
|
||||
```bash
|
||||
npm install mqtt
|
||||
```
|
||||
|
||||
## Cấu trúc
|
||||
|
||||
```
|
||||
src/utils/
|
||||
├── mqttClient.ts # MQTT Client utility
|
||||
└── wsClient.ts # WebSocket Client utility (legacy)
|
||||
```
|
||||
|
||||
## Cách sử dụng
|
||||
|
||||
### 1. Import
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
```
|
||||
|
||||
### 2. Kết nối
|
||||
|
||||
```typescript
|
||||
// Lấy credentials từ user metadata
|
||||
const { frontend_thing_id, frontend_thing_key } =
|
||||
initialState?.currentUserProfile?.metadata || {};
|
||||
|
||||
// Kết nối
|
||||
mqttClient.connect({
|
||||
username: frontend_thing_id,
|
||||
password: frontend_thing_key,
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Đăng ký Event Handlers
|
||||
|
||||
```typescript
|
||||
// Khi kết nối thành công
|
||||
const unConnect = mqttClient.onConnect(() => {
|
||||
console.log('MQTT Connected!');
|
||||
});
|
||||
|
||||
// Khi có lỗi
|
||||
const unError = mqttClient.onError((error) => {
|
||||
console.error('MQTT Error:', error);
|
||||
});
|
||||
|
||||
// Khi kết nối đóng
|
||||
const unClose = mqttClient.onClose(() => {
|
||||
console.log('MQTT Closed');
|
||||
});
|
||||
|
||||
// Cleanup khi component unmount
|
||||
return () => {
|
||||
unConnect();
|
||||
unError();
|
||||
unClose();
|
||||
mqttClient.disconnect();
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Publish Message
|
||||
|
||||
```typescript
|
||||
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||
const payload = JSON.stringify(senmlData);
|
||||
|
||||
if (mqttClient.isConnected()) {
|
||||
mqttClient.publish(topic, payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Subscribe Topic
|
||||
|
||||
```typescript
|
||||
// Subscribe
|
||||
mqttClient.subscribe('channels/123/messages/#');
|
||||
|
||||
// Nhận message
|
||||
const unMessage = mqttClient.onMessage((topic, message, packet) => {
|
||||
console.log('Received:', topic, message.toString());
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
mqttClient.unsubscribe('channels/123/messages/#');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Mô tả |
|
||||
| ---------------------------- | ---------------------------------- |
|
||||
| `connect(credentials, url?)` | Kết nối MQTT với username/password |
|
||||
| `disconnect()` | Ngắt kết nối |
|
||||
| `subscribe(topic)` | Subscribe vào topic |
|
||||
| `unsubscribe(topic)` | Unsubscribe khỏi topic |
|
||||
| `publish(topic, payload)` | Publish message |
|
||||
| `onConnect(callback)` | Đăng ký callback khi kết nối |
|
||||
| `onClose(callback)` | Đăng ký callback khi đóng |
|
||||
| `onError(callback)` | Đăng ký callback khi lỗi |
|
||||
| `onMessage(callback)` | Đăng ký callback khi nhận message |
|
||||
| `isConnected()` | Kiểm tra trạng thái kết nối |
|
||||
| `getClient()` | Lấy MQTT client gốc |
|
||||
|
||||
## Cấu hình Proxy (Development)
|
||||
|
||||
Trong môi trường development, MQTT được proxy qua config trong `config/proxy.ts`:
|
||||
|
||||
```typescript
|
||||
"/mqtt": {
|
||||
target: "https://gms.smatec.com.vn",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
```
|
||||
|
||||
## Format SenML
|
||||
|
||||
Dữ liệu MQTT được format theo chuẩn SenML:
|
||||
|
||||
```typescript
|
||||
const senml = [
|
||||
{
|
||||
bn: `urn:dev:mac:${mac}:`, // Base name
|
||||
n: 'ack', // Name
|
||||
t: Date.now() / 1000, // Timestamp (seconds)
|
||||
vs: uuidv4(), // Value string (ACK ID)
|
||||
},
|
||||
{
|
||||
n: 'user@email.com', // Email (@ → :)
|
||||
t: Date.now() / 1000,
|
||||
vs: JSON.stringify(config), // Config dạng JSON string
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Topic Structure
|
||||
|
||||
```
|
||||
channels/${channel_id}/messages/${type}/${gw_type}
|
||||
```
|
||||
|
||||
| Phần | Mô tả |
|
||||
| ------------ | ------------------------------------------------------- |
|
||||
| `channel_id` | ID của kênh (từ `thing.metadata.cfg_channel_id`) |
|
||||
| `type` | Loại message: `cameraconfig`, `config`, `log`, `notify` |
|
||||
| `gw_type` | Loại gateway: `gmsv6`, `gmsv5` |
|
||||
|
||||
## Ví dụ hoàn chỉnh
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MyComponent = ({ thing }) => {
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { frontend_thing_id, frontend_thing_key } =
|
||||
initialState?.currentUserProfile?.metadata || {};
|
||||
|
||||
if (!frontend_thing_id || !frontend_thing_key) return;
|
||||
|
||||
mqttClient.connect({
|
||||
username: frontend_thing_id,
|
||||
password: frontend_thing_key,
|
||||
});
|
||||
|
||||
const unConnect = mqttClient.onConnect(() => setConnected(true));
|
||||
const unClose = mqttClient.onClose(() => setConnected(false));
|
||||
|
||||
return () => {
|
||||
unConnect();
|
||||
unClose();
|
||||
mqttClient.disconnect();
|
||||
};
|
||||
}, [initialState]);
|
||||
|
||||
const handlePublish = () => {
|
||||
const { cfg_channel_id, external_id } = thing.metadata;
|
||||
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||
|
||||
const payload = [
|
||||
{
|
||||
bn: `urn:dev:mac:${external_id.replaceAll('-', '')}:`,
|
||||
n: 'ack',
|
||||
t: Date.now() / 1000,
|
||||
vs: uuidv4(),
|
||||
},
|
||||
{
|
||||
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':'),
|
||||
t: Date.now() / 1000,
|
||||
vs: JSON.stringify({ record_type: 'all' }),
|
||||
},
|
||||
];
|
||||
|
||||
if (mqttClient.isConnected()) {
|
||||
mqttClient.publish(topic, JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handlePublish} disabled={!connected}>
|
||||
{connected ? 'Publish' : 'Connecting...'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## So sánh mqttClient vs wsClient
|
||||
|
||||
Dự án có 2 utilities để giao tiếp real-time:
|
||||
|
||||
### Bảng so sánh
|
||||
|
||||
| Tiêu chí | mqttClient | wsClient |
|
||||
| --- | --- | --- |
|
||||
| **Thư viện** | `mqtt` (MQTT.js) | `reconnecting-websocket` |
|
||||
| **Protocol** | MQTT over WebSocket | WebSocket thuần |
|
||||
| **Xác thực** | Username/Password (MQTT credentials) | Token (access_token) hoặc không |
|
||||
| **Topics** | Hỗ trợ MQTT topics, subscribe/unsubscribe | Không có khái niệm topic |
|
||||
| **Publish** | `publish(topic, payload)` | `send(data)` |
|
||||
| **Subscribe** | `subscribe(topic)` + `onMessage()` | `subscribe(callback)` |
|
||||
| **Reconnect** | Tự động (built-in) | Tự động (reconnecting-websocket) |
|
||||
| **Use case** | Giao tiếp với IoT Gateway/Devices | Giao tiếp WebSocket server |
|
||||
|
||||
### Khi nào dùng mqttClient?
|
||||
|
||||
✅ **Dùng mqttClient khi:**
|
||||
|
||||
- Gửi/nhận dữ liệu với IoT Gateways (GMSv5, GMSv6)
|
||||
- Cấu hình thiết bị (camera, nodes, schedules)
|
||||
- Cần subscribe nhiều topics khác nhau
|
||||
- Làm việc với Mainflux platform
|
||||
|
||||
### Khi nào dùng wsClient?
|
||||
|
||||
✅ **Dùng wsClient khi:**
|
||||
|
||||
- Cần WebSocket connection đơn giản
|
||||
- Giao tiếp với WebSocket server không phải MQTT broker
|
||||
- Xác thực bằng access_token
|
||||
|
||||
### Code comparison
|
||||
|
||||
**mqttClient:**
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
|
||||
// Connect với username/password
|
||||
mqttClient.connect({
|
||||
username: 'thing_id',
|
||||
password: 'thing_key',
|
||||
});
|
||||
|
||||
// Subscribe topic
|
||||
mqttClient.subscribe('channels/123/messages/#');
|
||||
|
||||
// Publish với topic
|
||||
mqttClient.publish('channels/123/messages/config', payload);
|
||||
|
||||
// Nhận message
|
||||
mqttClient.onMessage((topic, message) => {
|
||||
console.log(topic, message.toString());
|
||||
});
|
||||
```
|
||||
|
||||
**wsClient:**
|
||||
|
||||
```typescript
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
|
||||
// Connect với hoặc không có token
|
||||
wsClient.connect('/mqtt', false);
|
||||
|
||||
// Không có subscribe topic
|
||||
// Chỉ nhận tất cả messages
|
||||
wsClient.subscribe((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
// Gửi data (không có topic)
|
||||
wsClient.send({ action: 'update', data: payload });
|
||||
```
|
||||
|
||||
## Xem thêm
|
||||
|
||||
- [Mainflux.md](../Mainflux.md) - Tài liệu giao tiếp MQTT chi tiết
|
||||
- [mqttClient.ts](../src/utils/mqttClient.ts) - Source code MQTT Client
|
||||
- [wsClient.ts](../src/utils/wsClient.ts) - Source code WebSocket Client
|
||||
373
package-lock.json
generated
373
package-lock.json
generated
@@ -14,13 +14,16 @@
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"ol": "^10.6.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"reconnecting-websocket": "^4.4.0"
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.33",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"baseline-browser-mapping": "^2.9.6",
|
||||
"husky": "^9",
|
||||
@@ -4044,6 +4047,15 @@
|
||||
"redux": ">= 3.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz",
|
||||
"integrity": "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
|
||||
@@ -4068,6 +4080,22 @@
|
||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "16.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz",
|
||||
@@ -7066,6 +7094,18 @@
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -7952,6 +7992,58 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.6",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz",
|
||||
"integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bl/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
|
||||
@@ -8037,6 +8129,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/broker-factory": {
|
||||
"version": "3.1.13",
|
||||
"resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz",
|
||||
"integrity": "sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.48"
|
||||
}
|
||||
},
|
||||
"node_modules/brorand": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
|
||||
@@ -8707,6 +8811,12 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/commist": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/common-path-prefix": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
|
||||
@@ -8770,6 +8880,21 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/connect-history-api-fallback": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz",
|
||||
@@ -10751,6 +10876,15 @@
|
||||
"es5-ext": "~0.10.14"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
@@ -10963,6 +11097,19 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-unique-numbers": {
|
||||
"version": "9.0.26",
|
||||
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz",
|
||||
"integrity": "sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -11866,6 +12013,12 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/history": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
|
||||
@@ -12370,6 +12523,15 @@
|
||||
"loose-envify": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -14310,6 +14472,15 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist-options": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
|
||||
@@ -14351,6 +14522,95 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt": {
|
||||
"version": "5.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz",
|
||||
"integrity": "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.21",
|
||||
"@types/ws": "^8.18.1",
|
||||
"commist": "^3.2.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"debug": "^4.4.1",
|
||||
"help-me": "^5.0.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"minimist": "^1.2.8",
|
||||
"mqtt-packet": "^9.0.2",
|
||||
"number-allocator": "^1.0.14",
|
||||
"readable-stream": "^4.7.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"socks": "^2.8.6",
|
||||
"split2": "^4.2.0",
|
||||
"worker-timers": "^8.0.23",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"bin": {
|
||||
"mqtt": "build/bin/mqtt.js",
|
||||
"mqtt_pub": "build/bin/pub.js",
|
||||
"mqtt_sub": "build/bin/sub.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
||||
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bl": "^6.0.8",
|
||||
"debug": "^4.3.4",
|
||||
"process-nextick-args": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt/node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/mqtt/node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -14731,6 +14991,26 @@
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"js-sdsl": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator/node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -18029,7 +18309,6 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
@@ -18789,6 +19068,30 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
|
||||
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.0.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz",
|
||||
@@ -21015,6 +21318,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -21358,6 +21667,19 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz",
|
||||
@@ -22198,6 +22520,53 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-factory": {
|
||||
"version": "7.0.48",
|
||||
"resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz",
|
||||
"integrity": "sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers": {
|
||||
"version": "8.0.30",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz",
|
||||
"integrity": "sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-broker": "^8.0.15",
|
||||
"worker-timers-worker": "^9.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-broker": {
|
||||
"version": "8.0.15",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz",
|
||||
"integrity": "sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"broker-factory": "^3.1.13",
|
||||
"fast-unique-numbers": "^9.0.26",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-timers-worker": "^9.0.13"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-worker": {
|
||||
"version": "9.0.13",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz",
|
||||
"integrity": "sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"tslib": "^2.8.1",
|
||||
"worker-factory": "^7.0.48"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
|
||||
@@ -25,13 +25,16 @@
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"ol": "^10.6.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"reconnecting-websocket": "^4.4.0"
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.33",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"baseline-browser-mapping": "^2.9.6",
|
||||
"husky": "^9",
|
||||
|
||||
@@ -108,6 +108,8 @@ const LoginPage = () => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
currentUserProfile: userInfo,
|
||||
theme:
|
||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light',
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -148,6 +150,9 @@ const LoginPage = () => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
currentUserProfile: userInfo,
|
||||
theme:
|
||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
|
||||
'light',
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -175,6 +180,9 @@ const LoginPage = () => {
|
||||
setInitialState((s: any) => ({
|
||||
...s,
|
||||
currentUserProfile: userInfo,
|
||||
theme:
|
||||
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
|
||||
'light',
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Row,
|
||||
Select,
|
||||
} from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Camera types
|
||||
const CAMERA_TYPES = [
|
||||
@@ -31,20 +32,58 @@ interface CameraFormValues {
|
||||
interface CameraFormModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: CameraFormValues) => void;
|
||||
onSubmit: (camera: MasterModel.Camera) => void;
|
||||
isOnline?: boolean;
|
||||
editingCamera?: MasterModel.Camera | null;
|
||||
}
|
||||
|
||||
const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||
open,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
isOnline = true,
|
||||
editingCamera,
|
||||
}) => {
|
||||
const [form] = Form.useForm<CameraFormValues>();
|
||||
const isEditMode = !!editingCamera;
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editingCamera) {
|
||||
form.setFieldsValue({
|
||||
name: editingCamera.name || '',
|
||||
type: editingCamera.cate_id || 'HIKVISION',
|
||||
account: editingCamera.username || '',
|
||||
password: editingCamera.password || '',
|
||||
ipAddress: editingCamera.ip || '',
|
||||
rtspPort: editingCamera.rtsp_port || 554,
|
||||
httpPort: editingCamera.http_port || 80,
|
||||
stream: editingCamera.stream || 0,
|
||||
channel: editingCamera.channel || 0,
|
||||
});
|
||||
} else if (open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, editingCamera, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onSubmit(values);
|
||||
// Convert form values to MasterModel.Camera format
|
||||
const camera: MasterModel.Camera = {
|
||||
// Keep existing ID when editing, generate new ID when creating
|
||||
id: editingCamera?.id || `cam_${Date.now()}`,
|
||||
name: values.name,
|
||||
cate_id: values.type,
|
||||
ip: values.ipAddress,
|
||||
rtsp_port: values.rtspPort,
|
||||
http_port: values.httpPort,
|
||||
stream: values.stream,
|
||||
channel: values.channel,
|
||||
username: values.account,
|
||||
password: values.password,
|
||||
};
|
||||
onSubmit(camera);
|
||||
form.resetFields();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
@@ -58,7 +97,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Tạo mới camera"
|
||||
title={isEditMode ? 'Chỉnh sửa camera' : 'Tạo mới camera'}
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
footer={[
|
||||
@@ -66,7 +105,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
|
||||
Hủy
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmit}>
|
||||
Đồng ý
|
||||
{isEditMode ? 'Cập nhật' : 'Đồng ý'}
|
||||
</Button>,
|
||||
]}
|
||||
width={500}
|
||||
|
||||
@@ -4,22 +4,30 @@ import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
|
||||
import { Button, Card, Space, Table, theme, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CameraTableProps {
|
||||
cameraData: MasterModel.Camera[] | null;
|
||||
onCreateCamera: () => void;
|
||||
onEditCamera?: (camera: MasterModel.Camera) => void;
|
||||
onDeleteCameras?: (cameraIds: string[]) => void;
|
||||
onReload?: () => void;
|
||||
loading?: boolean;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
const CameraTable: React.FC<CameraTableProps> = ({
|
||||
cameraData,
|
||||
onCreateCamera,
|
||||
onEditCamera,
|
||||
onDeleteCameras,
|
||||
onReload,
|
||||
loading = false,
|
||||
isOnline = false,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
const handleReload = () => {
|
||||
console.log('Reload cameras');
|
||||
@@ -27,22 +35,18 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
console.log('Delete selected cameras');
|
||||
// TODO: Implement delete functionality
|
||||
if (selectedRowKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
onDeleteCameras?.(selectedRowKeys as string[]);
|
||||
setSelectedRowKeys([]);
|
||||
};
|
||||
|
||||
const handleEdit = (camera: MasterModel.Camera) => {
|
||||
console.log('Edit camera:', camera);
|
||||
// TODO: Implement edit functionality
|
||||
onEditCamera?.(camera);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'checkbox',
|
||||
width: 50,
|
||||
render: () => <Checkbox />,
|
||||
},
|
||||
{
|
||||
title: 'Tên',
|
||||
dataIndex: 'name',
|
||||
@@ -67,11 +71,14 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
||||
title: 'Thao tác',
|
||||
key: 'action',
|
||||
render: (_: any, record: MasterModel.Camera) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
/>
|
||||
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEdit(record)}
|
||||
disabled={!isOnline}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -79,11 +86,29 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
||||
return (
|
||||
<Card bodyStyle={{ padding: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
|
||||
Tạo mới camera
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReload} />
|
||||
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
|
||||
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onCreateCamera}
|
||||
disabled={!isOnline}
|
||||
>
|
||||
Tạo mới camera
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReload}
|
||||
loading={loading}
|
||||
/>
|
||||
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDelete}
|
||||
disabled={!isOnline || selectedRowKeys.length === 0}
|
||||
danger
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
@@ -92,6 +117,12 @@ const CameraTable: React.FC<CameraTableProps> = ({
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
rowSelection={{
|
||||
type: 'checkbox',
|
||||
selectedRowKeys,
|
||||
onChange: (newSelectedRowKeys) =>
|
||||
setSelectedRowKeys(newSelectedRowKeys),
|
||||
}}
|
||||
pagination={{
|
||||
size: 'small',
|
||||
showTotal: (total: number, range: [number, number]) =>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Select,
|
||||
theme,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -15,15 +24,24 @@ const RECORDING_MODES = [
|
||||
interface CameraV6Props {
|
||||
thing: MasterModel.Thing | null;
|
||||
cameraConfig?: MasterModel.CameraV6 | null;
|
||||
onSubmit?: (config: {
|
||||
recordingMode: MasterModel.CameraV6['record_type'];
|
||||
selectedAlerts: string[];
|
||||
}) => void;
|
||||
isOnline?: boolean;
|
||||
}
|
||||
|
||||
const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||
const CameraV6: React.FC<CameraV6Props> = ({
|
||||
thing,
|
||||
cameraConfig,
|
||||
onSubmit,
|
||||
isOnline = false,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
||||
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
|
||||
'none',
|
||||
);
|
||||
const [recordingMode, setRecordingMode] =
|
||||
useState<MasterModel.CameraV6['record_type']>('none');
|
||||
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
|
||||
null,
|
||||
);
|
||||
@@ -95,31 +113,39 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||
setSelectedAlerts([]);
|
||||
};
|
||||
|
||||
const handleSubmitAlerts = () => {
|
||||
console.log('Submit alerts:', {
|
||||
const handleSubmitConfig = () => {
|
||||
onSubmit?.({
|
||||
recordingMode,
|
||||
selectedAlerts,
|
||||
});
|
||||
// TODO: Call API to save alert configuration
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
{/* Recording Mode */}
|
||||
<div className="mb-6">
|
||||
<Text strong className="block mb-2">
|
||||
Ghi dữ liệu camera
|
||||
</Text>
|
||||
<div className="flex gap-8 items-center">
|
||||
<Select
|
||||
value={recordingMode}
|
||||
onChange={setRecordingMode}
|
||||
options={RECORDING_MODES}
|
||||
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
|
||||
/>
|
||||
<Button type="primary" onClick={handleSubmitAlerts}>
|
||||
Gửi đi
|
||||
</Button>
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
|
||||
<div className="w-full sm:w-1/3 lg:w-1/4">
|
||||
<Text strong className="block mb-2">
|
||||
Ghi dữ liệu camera
|
||||
</Text>
|
||||
<Select
|
||||
value={recordingMode}
|
||||
onChange={setRecordingMode}
|
||||
options={RECORDING_MODES}
|
||||
className="w-full"
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmitConfig}
|
||||
disabled={!isOnline}
|
||||
>
|
||||
Gửi đi
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -155,7 +181,7 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => handleAlertToggle(alarmId)}
|
||||
className="cursor-pointer h-20 flex items-center justify-center"
|
||||
className="cursor-pointer h-24 flex items-center justify-center"
|
||||
style={{
|
||||
borderColor: isSelected
|
||||
? token.colorPrimary
|
||||
@@ -166,14 +192,21 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
|
||||
: token.colorBgContainer,
|
||||
}}
|
||||
>
|
||||
<div className="p-2 text-center w-full">
|
||||
<div className="p-1 text-center w-full flex items-center justify-center h-full">
|
||||
<Text
|
||||
className="text-xs break-words"
|
||||
className="text-xs"
|
||||
style={{
|
||||
color: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorText,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: '1.2em',
|
||||
}}
|
||||
title={alarm.name}
|
||||
>
|
||||
{alarm.name}
|
||||
</Text>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { apiQueryCamera } from '@/services/master/MessageController';
|
||||
import { apiGetThingDetail } from '@/services/master/ThingController';
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useModel, useParams } from '@umijs/max';
|
||||
import { Button, Col, Row, Space, Spin } from 'antd';
|
||||
import { Badge, Button, Col, message, Row, Space, Spin } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import CameraFormModal from './components/CameraFormModal';
|
||||
import CameraTable from './components/CameraTable';
|
||||
import ConfigCameraV5 from './components/ConfigCameraV5';
|
||||
@@ -21,18 +22,53 @@ const CameraConfigPage = () => {
|
||||
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
|
||||
null,
|
||||
);
|
||||
const [mqttConnected, setMqttConnected] = useState(false);
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [editingCamera, setEditingCamera] = useState<MasterModel.Camera | null>(
|
||||
null,
|
||||
);
|
||||
const { initialState } = useModel('@@initialState');
|
||||
|
||||
// Initialize MQTT connection
|
||||
useEffect(() => {
|
||||
wsClient.connect('/mqtt', false);
|
||||
const unsubscribe = wsClient.subscribe((data: any) => {
|
||||
console.log('Received WS data:', data);
|
||||
const { frontend_thing_id, frontend_thing_key } =
|
||||
initialState?.currentUserProfile?.metadata || {};
|
||||
|
||||
if (!frontend_thing_id || !frontend_thing_key) return;
|
||||
|
||||
// Connect using mqttClient utility
|
||||
mqttClient.connect({
|
||||
username: frontend_thing_id,
|
||||
password: frontend_thing_key,
|
||||
});
|
||||
|
||||
const unConnect = mqttClient.onConnect(() => {
|
||||
console.log('MQTT Connected successfully!');
|
||||
setMqttConnected(true);
|
||||
});
|
||||
|
||||
const unError = mqttClient.onError((error) => {
|
||||
console.error('MQTT Error:', error);
|
||||
setMqttConnected(false);
|
||||
});
|
||||
|
||||
const unClose = mqttClient.onClose(() => {
|
||||
console.log('MQTT Connection closed');
|
||||
setMqttConnected(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unConnect();
|
||||
unError();
|
||||
unClose();
|
||||
mqttClient.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [initialState]);
|
||||
|
||||
// Check device online status using connected property
|
||||
useEffect(() => {
|
||||
setIsOnline(thing?.metadata?.connected ?? false);
|
||||
}, [thing]);
|
||||
|
||||
// Fetch thing info on mount
|
||||
useEffect(() => {
|
||||
@@ -99,14 +135,120 @@ const CameraConfigPage = () => {
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalVisible(false);
|
||||
setEditingCamera(null);
|
||||
};
|
||||
|
||||
const handleSubmitCamera = (values: any) => {
|
||||
console.log('Camera values:', values);
|
||||
// TODO: Call API to create camera
|
||||
// Core function to send camera config via MQTT
|
||||
const sendCameraConfig = (configPayload: MasterModel.CameraV6) => {
|
||||
// Check if device is online
|
||||
if (!isOnline) {
|
||||
message.error('Thiết bị đang ngoại tuyến, không thể gửi cấu hình');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!thing?.metadata?.cfg_channel_id || !thing?.metadata?.external_id) {
|
||||
message.error('Thiếu thông tin cấu hình thiết bị');
|
||||
return false;
|
||||
}
|
||||
|
||||
const { cfg_channel_id, external_id } = thing.metadata;
|
||||
const pubTopic = `channels/${cfg_channel_id}/messages/cameraconfig/${thing.metadata?.type}`;
|
||||
const mac = external_id?.replaceAll('-', '');
|
||||
const ack = uuidv4();
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
const senml: MasterModel.CameraV6ConfigRequest[] = [
|
||||
{
|
||||
bn: `urn:dev:mac:${mac?.toLowerCase()}:`,
|
||||
n: 'ack',
|
||||
t: now,
|
||||
vs: ack,
|
||||
},
|
||||
{
|
||||
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':') || '',
|
||||
vs: JSON.stringify(configPayload),
|
||||
},
|
||||
];
|
||||
|
||||
const payload = JSON.stringify(senml);
|
||||
|
||||
if (mqttClient.isConnected()) {
|
||||
mqttClient.publish(pubTopic, payload);
|
||||
message.success('Đã gửi cấu hình thành công');
|
||||
return true;
|
||||
} else {
|
||||
message.error('MQTT chưa kết nối');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle camera list config submission (add/edit/delete cameras)
|
||||
const handleSubmitCameraConfig = (updatedCams: MasterModel.Camera[]) => {
|
||||
const configPayload: MasterModel.CameraV6 = {
|
||||
cams: updatedCams,
|
||||
record_type: cameraConfig?.record_type || 'all',
|
||||
...(cameraConfig?.record_type === 'alarm' && {
|
||||
record_alarm_list: cameraConfig?.record_alarm_list || [],
|
||||
}),
|
||||
};
|
||||
|
||||
if (sendCameraConfig(configPayload)) {
|
||||
// Update local state
|
||||
setCameraConfig(configPayload);
|
||||
setCameras(updatedCams);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle open edit modal - open modal with camera data for editing
|
||||
const handleOpenEditModal = (camera: MasterModel.Camera) => {
|
||||
setEditingCamera(camera);
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle submit camera (add or edit)
|
||||
const handleSubmitCamera = (camera: MasterModel.Camera) => {
|
||||
if (editingCamera) {
|
||||
// Edit mode: update existing camera
|
||||
const updatedCams = (cameraConfig?.cams || []).map((cam) =>
|
||||
cam.id === camera.id ? camera : cam,
|
||||
);
|
||||
handleSubmitCameraConfig(updatedCams);
|
||||
} else {
|
||||
// Add mode: add new camera
|
||||
const updatedCams = [...(cameraConfig?.cams || []), camera];
|
||||
handleSubmitCameraConfig(updatedCams);
|
||||
}
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
// Handle delete cameras - submit config with cameras removed
|
||||
const handleDeleteCameras = (cameraIds: string[]) => {
|
||||
const updatedCams = (cameraConfig?.cams || []).filter(
|
||||
(cam) => !cameraIds.includes(cam.id || ''),
|
||||
);
|
||||
handleSubmitCameraConfig(updatedCams);
|
||||
};
|
||||
|
||||
// Handle recording config submission from ConfigCameraV6
|
||||
const handleSubmitConfig = (configPayload: {
|
||||
recordingMode: MasterModel.CameraV6['record_type'];
|
||||
selectedAlerts: string[];
|
||||
}) => {
|
||||
// Chỉ gửi record_alarm_list khi record_type = alarm
|
||||
const fullConfig: MasterModel.CameraV6 = {
|
||||
cams: cameraConfig?.cams || [],
|
||||
record_type: configPayload.recordingMode,
|
||||
...(configPayload.recordingMode === 'alarm' && {
|
||||
record_alarm_list: configPayload.selectedAlerts,
|
||||
}),
|
||||
};
|
||||
|
||||
if (sendCameraConfig(fullConfig)) {
|
||||
// Update local state
|
||||
setCameraConfig(fullConfig);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to determine which camera component to render
|
||||
const renderCameraRecordingComponent = () => {
|
||||
const thingType = thing?.metadata?.type;
|
||||
@@ -116,10 +258,24 @@ const CameraConfigPage = () => {
|
||||
}
|
||||
|
||||
if (thingType === 'spole' || thingType === 'gmsv6') {
|
||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||
return (
|
||||
<ConfigCameraV6
|
||||
thing={thing}
|
||||
cameraConfig={cameraConfig}
|
||||
onSubmit={handleSubmitConfig}
|
||||
isOnline={isOnline}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
|
||||
return (
|
||||
<ConfigCameraV6
|
||||
thing={thing}
|
||||
cameraConfig={cameraConfig}
|
||||
onSubmit={handleSubmitConfig}
|
||||
isOnline={isOnline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -134,6 +290,10 @@ const CameraConfigPage = () => {
|
||||
onClick={() => history.push('/manager/devices')}
|
||||
/>
|
||||
<span>{thing?.name || 'Loading...'}</span>
|
||||
<Badge
|
||||
status={isOnline ? 'success' : 'default'}
|
||||
text={isOnline ? 'Trực tuyến' : 'Ngoại tuyến'}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
@@ -144,8 +304,11 @@ const CameraConfigPage = () => {
|
||||
<CameraTable
|
||||
cameraData={cameras}
|
||||
onCreateCamera={handleOpenModal}
|
||||
onEditCamera={handleOpenEditModal}
|
||||
onDeleteCameras={handleDeleteCameras}
|
||||
onReload={fetchCameraConfig}
|
||||
loading={cameraLoading}
|
||||
isOnline={isOnline}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -155,11 +318,13 @@ const CameraConfigPage = () => {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create Camera Modal */}
|
||||
{/* Create/Edit Camera Modal */}
|
||||
<CameraFormModal
|
||||
open={isModalVisible}
|
||||
onCancel={handleCloseModal}
|
||||
onSubmit={handleSubmitCamera}
|
||||
isOnline={isOnline}
|
||||
editingCamera={editingCamera}
|
||||
/>
|
||||
</PageContainer>
|
||||
</Spin>
|
||||
|
||||
@@ -5,7 +5,10 @@ export async function apiQueryLogs(
|
||||
params: MasterModel.SearchLogPaginationBody,
|
||||
type: MasterModel.LogTypeRequest,
|
||||
) {
|
||||
return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
|
||||
params: params,
|
||||
});
|
||||
return request<MasterModel.MesageReaderResponse>(
|
||||
`${API_READER}/${type}/messages`,
|
||||
{
|
||||
params: params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
36
src/services/master/typings/camera.d.ts
vendored
Normal file
36
src/services/master/typings/camera.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
declare namespace MasterModel {
|
||||
interface Camera {
|
||||
id?: string;
|
||||
name?: string;
|
||||
cate_id?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rtsp_port?: number;
|
||||
http_port?: number;
|
||||
channel?: number;
|
||||
ip?: string;
|
||||
stream?: number;
|
||||
}
|
||||
|
||||
interface CameraV5 {
|
||||
cams?: Camera[];
|
||||
}
|
||||
|
||||
interface CameraV6 extends CameraV5 {
|
||||
record_type?: 'none' | 'alarm' | 'all';
|
||||
record_alarm_list?: string[];
|
||||
}
|
||||
|
||||
type CameraMessage = Message<CameraV5>;
|
||||
type CameraV6Message = Message<CameraV6>;
|
||||
|
||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||
|
||||
interface CameraV6ConfigRequest {
|
||||
bn?: string;
|
||||
n?: string;
|
||||
t?: number;
|
||||
vs?: string | CameraV6;
|
||||
}
|
||||
}
|
||||
27
src/services/master/typings/log.d.ts
vendored
27
src/services/master/typings/log.d.ts
vendored
@@ -29,12 +29,10 @@ declare namespace MasterModel {
|
||||
}
|
||||
|
||||
// Response types cho từng domain
|
||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
||||
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
||||
|
||||
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
||||
type MessageDataType = NodeConfig[];
|
||||
|
||||
interface Message<T = MessageDataType> extends MessageBasicInfo {
|
||||
string_value?: string;
|
||||
@@ -42,31 +40,8 @@ declare namespace MasterModel {
|
||||
}
|
||||
|
||||
// Message types cho từng domain
|
||||
type CameraMessage = Message<CameraV5>;
|
||||
type CameraV6Message = Message<CameraV6>;
|
||||
type NodeConfigMessage = Message<NodeConfig[]>;
|
||||
|
||||
interface CameraV5 {
|
||||
cams?: Camera[];
|
||||
}
|
||||
interface CameraV6 extends CameraV5 {
|
||||
record_type?: 'none' | 'alarm' | 'all';
|
||||
record_alarm_list?: string[];
|
||||
}
|
||||
|
||||
interface Camera {
|
||||
id?: string;
|
||||
name?: string;
|
||||
cate_id?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rtsp_port?: number;
|
||||
http_port?: number;
|
||||
channel?: number;
|
||||
ip?: string;
|
||||
stream?: number;
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
id: string;
|
||||
type: Type;
|
||||
|
||||
151
src/utils/mqttClient.ts
Normal file
151
src/utils/mqttClient.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import mqtt, { IClientOptions, MqttClient, Packet } from 'mqtt';
|
||||
|
||||
type MessageHandler = (topic: string, message: Buffer, packet: Packet) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
type ErrorHandler = (error: Error) => void;
|
||||
|
||||
interface MqttCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class MQTTClientManager {
|
||||
private client: MqttClient | null = null;
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private connectHandlers = new Set<ConnectionHandler>();
|
||||
private closeHandlers = new Set<ConnectionHandler>();
|
||||
private errorHandlers = new Set<ErrorHandler>();
|
||||
|
||||
/**
|
||||
* Kết nối tới MQTT broker.
|
||||
* @param credentials Thông tin xác thực (username, password)
|
||||
* @param url Địa chỉ MQTT broker (mặc định là /mqtt)
|
||||
*/
|
||||
connect(credentials: MqttCredentials, url: string = '/mqtt') {
|
||||
if (this.client?.connected) return;
|
||||
|
||||
// Build WebSocket URL
|
||||
let mqttUrl = url;
|
||||
if (url.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
mqttUrl = `${protocol}//${window.location.host}${url}`;
|
||||
}
|
||||
|
||||
const opts: IClientOptions = {
|
||||
clean: true,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 30 * 1000,
|
||||
};
|
||||
|
||||
this.client = mqtt.connect(mqttUrl, opts);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('MQTT Connected successfully!');
|
||||
this.connectHandlers.forEach((fn) => fn());
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
console.log('MQTT Connection closed');
|
||||
this.closeHandlers.forEach((fn) => fn());
|
||||
});
|
||||
|
||||
this.client.on('error', (error: Error) => {
|
||||
console.error('MQTT Error:', error);
|
||||
this.errorHandlers.forEach((fn) => fn(error));
|
||||
});
|
||||
|
||||
this.client.on('message', (topic, message, packet) => {
|
||||
this.messageHandlers.forEach((fn) => fn(topic, message, packet));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ngắt kết nối MQTT và giải phóng tài nguyên.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.client) {
|
||||
this.client.end();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe vào một topic.
|
||||
* @param topic Topic cần subscribe
|
||||
*/
|
||||
subscribe(topic: string | string[]) {
|
||||
this.client?.subscribe(topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe khỏi một topic.
|
||||
* @param topic Topic cần unsubscribe
|
||||
*/
|
||||
unsubscribe(topic: string | string[]) {
|
||||
this.client?.unsubscribe(topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish message tới một topic.
|
||||
* @param topic Topic để publish
|
||||
* @param payload Payload (string hoặc object sẽ được stringify)
|
||||
*/
|
||||
publish(topic: string, payload: string | object) {
|
||||
const payloadStr =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
this.client?.publish(topic, payloadStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi nhận được message.
|
||||
* @param cb Hàm callback xử lý message
|
||||
* @returns Hàm hủy đăng ký callback
|
||||
*/
|
||||
onMessage(cb: MessageHandler) {
|
||||
this.messageHandlers.add(cb);
|
||||
return () => this.messageHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi kết nối thành công.
|
||||
*/
|
||||
onConnect(cb: ConnectionHandler) {
|
||||
this.connectHandlers.add(cb);
|
||||
return () => this.connectHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi kết nối bị đóng.
|
||||
*/
|
||||
onClose(cb: ConnectionHandler) {
|
||||
this.closeHandlers.add(cb);
|
||||
return () => this.closeHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi có lỗi.
|
||||
*/
|
||||
onError(cb: ErrorHandler) {
|
||||
this.errorHandlers.add(cb);
|
||||
return () => this.errorHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra trạng thái kết nối MQTT.
|
||||
* @returns true nếu đã kết nối, ngược lại là false
|
||||
*/
|
||||
isConnected() {
|
||||
return this.client?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy instance client MQTT gốc (nếu cần thao tác nâng cao).
|
||||
*/
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
export const mqttClient = new MQTTClientManager();
|
||||
Reference in New Issue
Block a user