feat: add MQTT client for camera configuration and enhance camera management

This commit is contained in:
2026-02-08 11:58:57 +07:00
parent 78162fc0cb
commit d619534a73
14 changed files with 1254 additions and 111 deletions

View File

@@ -9,8 +9,9 @@ import {
getRefreshToken, getRefreshToken,
setAccessToken, setAccessToken,
} from '@/utils/storage'; } from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max'; import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
import axios from 'axios';
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi // Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null; let refreshingTokenPromise: Promise<string | null> | null = null;
@@ -125,7 +126,7 @@ export const handleRequestConfig: RequestConfig = {
const isRefreshRequest = response.config.url?.includes( const isRefreshRequest = response.config.url?.includes(
API_PATH_REFRESH_TOKEN, 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 // Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if ( if (
@@ -161,19 +162,30 @@ export const handleRequestConfig: RequestConfig = {
// Rebuild request options from config // Rebuild request options from config
const originalConfig = response.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 = { const newOptions = {
url: originalConfig.url,
method: originalConfig.method, method: originalConfig.method,
headers: { headers: {
...(originalConfig.headers || {}), ...(originalConfig.headers || {}),
Authorization: `${newToken}`, Authorization: `${newToken}`,
}, },
data: originalConfig.data, data: data,
params: originalConfig.params, params: originalConfig.params,
skipAuthRefresh: true,
}; };
// Gọi lại request gốc với accessToken mới // Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
return request(originalConfig.url, newOptions); return axios(newOptions);
} }
if ( if (

View File

@@ -9,8 +9,9 @@ import {
getRefreshToken, getRefreshToken,
setAccessToken, setAccessToken,
} from '@/utils/storage'; } from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max'; import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
import axios from 'axios';
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi // Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null; let refreshingTokenPromise: Promise<string | null> | null = null;
@@ -52,7 +53,8 @@ export const handleRequestConfig: RequestConfig = {
return ( return (
(status >= 200 && status < 300) || (status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND || status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED status === HTTPSTATUS.HTTP_UNAUTHORIZED ||
status === HTTPSTATUS.HTTP_FORBIDDEN
); );
}, },
headers: { 'X-Requested-With': 'XMLHttpRequest' }, headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -123,15 +125,17 @@ export const handleRequestConfig: RequestConfig = {
// Unwrap data from backend response // Unwrap data from backend response
responseInterceptors: [ responseInterceptors: [
async (response: any, options: any) => { async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN); // const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN); // response.url may be undefined or different in prod
const alreadyRetried = options?.skipAuthRefresh === true; 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 // Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if ( if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED && response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn // Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest && !isRefreshRequest
!alreadyRetried
) { ) {
const newToken = await getValidAccessToken(); const newToken = await getValidAccessToken();
@@ -151,24 +155,35 @@ export const handleRequestConfig: RequestConfig = {
// Rebuild request options from config // Rebuild request options from config
const originalConfig = response.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 = { const newOptions = {
url: originalConfig.url,
method: originalConfig.method, method: originalConfig.method,
headers: { headers: {
...(originalConfig.headers || {}), ...(originalConfig.headers || {}),
Authorization: `${newToken}`, Authorization: `${newToken}`,
}, },
data: originalConfig.data, data: data,
params: originalConfig.params, params: originalConfig.params,
skipAuthRefresh: true,
}; };
// Gọi lại request gốc với accessToken mới // Gọi lại request gốc với accessToken mới bằng axios để tránh double-unwrap
return request(originalConfig.url, newOptions); return axios(newOptions);
} }
if ( if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED && response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried) isRefreshRequest
) { ) {
clearAllData(); clearAllData();
history.push(ROUTE_LOGIN); history.push(ROUTE_LOGIN);

303
docs/mqtt-client.md Normal file
View 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
View File

@@ -14,13 +14,16 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mqtt": "^5.15.0",
"ol": "^10.6.1", "ol": "^10.6.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"reconnecting-websocket": "^4.4.0" "reconnecting-websocket": "^4.4.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.33", "@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/uuid": "^10.0.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"husky": "^9", "husky": "^9",
@@ -4044,6 +4047,15 @@
"redux": ">= 3.7.2" "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": { "node_modules/@types/resolve": {
"version": "1.20.6", "version": "1.20.6",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz",
@@ -4068,6 +4080,22 @@
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==",
"license": "MIT" "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": { "node_modules/@types/yargs": {
"version": "16.0.11", "version": "16.0.11",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.11.tgz",
@@ -7066,6 +7094,18 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true "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": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -7952,6 +7992,58 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/bn.js": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.2.tgz",
@@ -8037,6 +8129,18 @@
"node": ">=8" "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": { "node_modules/brorand": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -8707,6 +8811,12 @@
"node": ">=16" "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": { "node_modules/common-path-prefix": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz",
@@ -8770,6 +8880,21 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT" "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": { "node_modules/connect-history-api-fallback": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", "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" "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": { "node_modules/eventemitter3": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
@@ -10963,6 +11097,19 @@
"node": ">=6" "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": { "node_modules/fast-uri": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -11866,6 +12013,12 @@
"he": "bin/he" "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": { "node_modules/history": {
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
@@ -12370,6 +12523,15 @@
"loose-envify": "^1.0.0" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -14310,6 +14472,15 @@
"node": "*" "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": { "node_modules/minimist-options": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
@@ -14351,6 +14522,95 @@
"node": "*" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -14731,6 +14991,26 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -18029,7 +18309,6 @@
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/rimraf": { "node_modules/rimraf": {
@@ -18789,6 +19068,30 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/sonic-boom": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz",
@@ -21015,6 +21318,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -21358,6 +21667,19 @@
"node": ">= 0.4.0" "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": { "node_modules/v8-compile-cache": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz",
@@ -22198,6 +22520,53 @@
"node": ">=0.10.0" "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": { "node_modules/wrap-ansi": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",

View File

@@ -25,13 +25,16 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"moment": "^2.30.1", "moment": "^2.30.1",
"mqtt": "^5.15.0",
"ol": "^10.6.1", "ol": "^10.6.1",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"reconnecting-websocket": "^4.4.0" "reconnecting-websocket": "^4.4.0",
"uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.33", "@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/uuid": "^10.0.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"baseline-browser-mapping": "^2.9.6", "baseline-browser-mapping": "^2.9.6",
"husky": "^9", "husky": "^9",

View File

@@ -108,6 +108,8 @@ const LoginPage = () => {
setInitialState((s: any) => ({ setInitialState((s: any) => ({
...s, ...s,
currentUserProfile: userInfo, currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light',
})); }));
}); });
} }
@@ -148,6 +150,9 @@ const LoginPage = () => {
setInitialState((s: any) => ({ setInitialState((s: any) => ({
...s, ...s,
currentUserProfile: userInfo, currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
'light',
})); }));
}); });
} }
@@ -175,6 +180,9 @@ const LoginPage = () => {
setInitialState((s: any) => ({ setInitialState((s: any) => ({
...s, ...s,
currentUserProfile: userInfo, currentUserProfile: userInfo,
theme:
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') ||
'light',
})); }));
}); });
} }

View File

@@ -8,6 +8,7 @@ import {
Row, Row,
Select, Select,
} from 'antd'; } from 'antd';
import { useEffect } from 'react';
// Camera types // Camera types
const CAMERA_TYPES = [ const CAMERA_TYPES = [
@@ -31,20 +32,58 @@ interface CameraFormValues {
interface CameraFormModalProps { interface CameraFormModalProps {
open: boolean; open: boolean;
onCancel: () => void; onCancel: () => void;
onSubmit: (values: CameraFormValues) => void; onSubmit: (camera: MasterModel.Camera) => void;
isOnline?: boolean;
editingCamera?: MasterModel.Camera | null;
} }
const CameraFormModal: React.FC<CameraFormModalProps> = ({ const CameraFormModal: React.FC<CameraFormModalProps> = ({
open, open,
onCancel, onCancel,
onSubmit, onSubmit,
isOnline = true,
editingCamera,
}) => { }) => {
const [form] = Form.useForm<CameraFormValues>(); 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 () => { const handleSubmit = async () => {
try { try {
const values = await form.validateFields(); 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(); form.resetFields();
} catch (error) { } catch (error) {
console.error('Validation failed:', error); console.error('Validation failed:', error);
@@ -58,7 +97,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
return ( return (
<Modal <Modal
title="Tạo mới camera" title={isEditMode ? 'Chỉnh sửa camera' : 'Tạo mới camera'}
open={open} open={open}
onCancel={handleCancel} onCancel={handleCancel}
footer={[ footer={[
@@ -66,7 +105,7 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
Hủy Hủy
</Button>, </Button>,
<Button key="submit" type="primary" onClick={handleSubmit}> <Button key="submit" type="primary" onClick={handleSubmit}>
Đng ý {isEditMode ? 'Cập nhật' : 'Đồng ý'}
</Button>, </Button>,
]} ]}
width={500} width={500}

View File

@@ -4,22 +4,30 @@ import {
PlusOutlined, PlusOutlined,
ReloadOutlined, ReloadOutlined,
} from '@ant-design/icons'; } 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 { interface CameraTableProps {
cameraData: MasterModel.Camera[] | null; cameraData: MasterModel.Camera[] | null;
onCreateCamera: () => void; onCreateCamera: () => void;
onEditCamera?: (camera: MasterModel.Camera) => void;
onDeleteCameras?: (cameraIds: string[]) => void;
onReload?: () => void; onReload?: () => void;
loading?: boolean; loading?: boolean;
isOnline?: boolean;
} }
const CameraTable: React.FC<CameraTableProps> = ({ const CameraTable: React.FC<CameraTableProps> = ({
cameraData, cameraData,
onCreateCamera, onCreateCamera,
onEditCamera,
onDeleteCameras,
onReload, onReload,
loading = false, loading = false,
isOnline = false,
}) => { }) => {
const { token } = theme.useToken(); const { token } = theme.useToken();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const handleReload = () => { const handleReload = () => {
console.log('Reload cameras'); console.log('Reload cameras');
@@ -27,22 +35,18 @@ const CameraTable: React.FC<CameraTableProps> = ({
}; };
const handleDelete = () => { const handleDelete = () => {
console.log('Delete selected cameras'); if (selectedRowKeys.length === 0) {
// TODO: Implement delete functionality return;
}
onDeleteCameras?.(selectedRowKeys as string[]);
setSelectedRowKeys([]);
}; };
const handleEdit = (camera: MasterModel.Camera) => { const handleEdit = (camera: MasterModel.Camera) => {
console.log('Edit camera:', camera); onEditCamera?.(camera);
// TODO: Implement edit functionality
}; };
const columns = [ const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{ {
title: 'Tên', title: 'Tên',
dataIndex: 'name', dataIndex: 'name',
@@ -67,11 +71,14 @@ const CameraTable: React.FC<CameraTableProps> = ({
title: 'Thao tác', title: 'Thao tác',
key: 'action', key: 'action',
render: (_: any, record: MasterModel.Camera) => ( render: (_: any, record: MasterModel.Camera) => (
<Button <Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
size="small" <Button
icon={<EditOutlined />} size="small"
onClick={() => handleEdit(record)} icon={<EditOutlined />}
/> onClick={() => handleEdit(record)}
disabled={!isOnline}
/>
</Tooltip>
), ),
}, },
]; ];
@@ -79,11 +86,29 @@ const CameraTable: React.FC<CameraTableProps> = ({
return ( return (
<Card bodyStyle={{ padding: 16 }}> <Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}> <Tooltip title={!isOnline ? 'Thiết bị đang ngoại tuyến' : ''}>
Tạo mới camera <Button
</Button> type="primary"
<Button icon={<ReloadOutlined />} onClick={handleReload} /> icon={<PlusOutlined />}
<Button icon={<DeleteOutlined />} onClick={handleDelete} /> 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> </Space>
<Table <Table
@@ -92,6 +117,12 @@ const CameraTable: React.FC<CameraTableProps> = ({
rowKey="id" rowKey="id"
size="small" size="small"
loading={loading} loading={loading}
rowSelection={{
type: 'checkbox',
selectedRowKeys,
onChange: (newSelectedRowKeys) =>
setSelectedRowKeys(newSelectedRowKeys),
}}
pagination={{ pagination={{
size: 'small', size: 'small',
showTotal: (total: number, range: [number, number]) => showTotal: (total: number, range: [number, number]) =>

View File

@@ -1,6 +1,15 @@
import { apiQueryConfigAlarm } from '@/services/master/MessageController'; import { apiQueryConfigAlarm } from '@/services/master/MessageController';
import { useModel } from '@umijs/max'; 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'; import { useEffect, useState } from 'react';
const { Text } = Typography; const { Text } = Typography;
@@ -15,15 +24,24 @@ const RECORDING_MODES = [
interface CameraV6Props { interface CameraV6Props {
thing: MasterModel.Thing | null; thing: MasterModel.Thing | null;
cameraConfig?: MasterModel.CameraV6 | 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 { token } = theme.useToken();
const { initialState } = useModel('@@initialState'); const { initialState } = useModel('@@initialState');
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]); const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>( const [recordingMode, setRecordingMode] =
'none', useState<MasterModel.CameraV6['record_type']>('none');
);
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>( const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
null, null,
); );
@@ -95,31 +113,39 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
setSelectedAlerts([]); setSelectedAlerts([]);
}; };
const handleSubmitAlerts = () => { const handleSubmitConfig = () => {
console.log('Submit alerts:', { onSubmit?.({
recordingMode, recordingMode,
selectedAlerts, selectedAlerts,
}); });
// TODO: Call API to save alert configuration
}; };
return ( return (
<Card className="p-4"> <Card className="p-4">
{/* Recording Mode */} {/* Recording Mode */}
<div className="mb-6"> <div className="mb-6">
<Text strong className="block mb-2"> <div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
Ghi dữ liệu camera <div className="w-full sm:w-1/3 lg:w-1/4">
</Text> <Text strong className="block mb-2">
<div className="flex gap-8 items-center"> Ghi dữ liệu camera
<Select </Text>
value={recordingMode} <Select
onChange={setRecordingMode} value={recordingMode}
options={RECORDING_MODES} onChange={setRecordingMode}
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4" options={RECORDING_MODES}
/> className="w-full"
<Button type="primary" onClick={handleSubmitAlerts}> popupMatchSelectWidth={false}
Gửi đi />
</Button> </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>
</div> </div>
@@ -155,7 +181,7 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
size="small" size="small"
hoverable hoverable
onClick={() => handleAlertToggle(alarmId)} onClick={() => handleAlertToggle(alarmId)}
className="cursor-pointer h-20 flex items-center justify-center" className="cursor-pointer h-24 flex items-center justify-center"
style={{ style={{
borderColor: isSelected borderColor: isSelected
? token.colorPrimary ? token.colorPrimary
@@ -166,14 +192,21 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
: token.colorBgContainer, : 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 <Text
className="text-xs break-words" className="text-xs"
style={{ style={{
color: isSelected color: isSelected
? token.colorPrimary ? token.colorPrimary
: token.colorText, : token.colorText,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
lineHeight: '1.2em',
}} }}
title={alarm.name}
> >
{alarm.name} {alarm.name}
</Text> </Text>

View File

@@ -1,11 +1,12 @@
import { apiQueryCamera } from '@/services/master/MessageController'; import { apiQueryCamera } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController'; import { apiGetThingDetail } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient'; import { mqttClient } from '@/utils/mqttClient';
import { ArrowLeftOutlined } from '@ant-design/icons'; import { ArrowLeftOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components'; import { PageContainer } from '@ant-design/pro-components';
import { history, useModel, useParams } from '@umijs/max'; 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 { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import CameraFormModal from './components/CameraFormModal'; import CameraFormModal from './components/CameraFormModal';
import CameraTable from './components/CameraTable'; import CameraTable from './components/CameraTable';
import ConfigCameraV5 from './components/ConfigCameraV5'; import ConfigCameraV5 from './components/ConfigCameraV5';
@@ -21,18 +22,53 @@ const CameraConfigPage = () => {
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>( const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
null, null,
); );
const [mqttConnected, setMqttConnected] = useState(false);
const [isOnline, setIsOnline] = useState(false);
const [editingCamera, setEditingCamera] = useState<MasterModel.Camera | null>(
null,
);
const { initialState } = useModel('@@initialState'); const { initialState } = useModel('@@initialState');
// Initialize MQTT connection
useEffect(() => { useEffect(() => {
wsClient.connect('/mqtt', false); const { frontend_thing_id, frontend_thing_key } =
const unsubscribe = wsClient.subscribe((data: any) => { initialState?.currentUserProfile?.metadata || {};
console.log('Received WS data:', data);
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 () => { 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 // Fetch thing info on mount
useEffect(() => { useEffect(() => {
@@ -99,14 +135,120 @@ const CameraConfigPage = () => {
const handleCloseModal = () => { const handleCloseModal = () => {
setIsModalVisible(false); setIsModalVisible(false);
setEditingCamera(null);
}; };
const handleSubmitCamera = (values: any) => { // Core function to send camera config via MQTT
console.log('Camera values:', values); const sendCameraConfig = (configPayload: MasterModel.CameraV6) => {
// TODO: Call API to create camera // 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(); 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 // Helper function to determine which camera component to render
const renderCameraRecordingComponent = () => { const renderCameraRecordingComponent = () => {
const thingType = thing?.metadata?.type; const thingType = thing?.metadata?.type;
@@ -116,10 +258,24 @@ const CameraConfigPage = () => {
} }
if (thingType === 'spole' || thingType === 'gmsv6') { 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 ( return (
@@ -134,6 +290,10 @@ const CameraConfigPage = () => {
onClick={() => history.push('/manager/devices')} onClick={() => history.push('/manager/devices')}
/> />
<span>{thing?.name || 'Loading...'}</span> <span>{thing?.name || 'Loading...'}</span>
<Badge
status={isOnline ? 'success' : 'default'}
text={isOnline ? 'Trực tuyến' : 'Ngoại tuyến'}
/>
</Space> </Space>
), ),
}} }}
@@ -144,8 +304,11 @@ const CameraConfigPage = () => {
<CameraTable <CameraTable
cameraData={cameras} cameraData={cameras}
onCreateCamera={handleOpenModal} onCreateCamera={handleOpenModal}
onEditCamera={handleOpenEditModal}
onDeleteCameras={handleDeleteCameras}
onReload={fetchCameraConfig} onReload={fetchCameraConfig}
loading={cameraLoading} loading={cameraLoading}
isOnline={isOnline}
/> />
</Col> </Col>
@@ -155,11 +318,13 @@ const CameraConfigPage = () => {
</Col> </Col>
</Row> </Row>
{/* Create Camera Modal */} {/* Create/Edit Camera Modal */}
<CameraFormModal <CameraFormModal
open={isModalVisible} open={isModalVisible}
onCancel={handleCloseModal} onCancel={handleCloseModal}
onSubmit={handleSubmitCamera} onSubmit={handleSubmitCamera}
isOnline={isOnline}
editingCamera={editingCamera}
/> />
</PageContainer> </PageContainer>
</Spin> </Spin>

View File

@@ -5,7 +5,10 @@ export async function apiQueryLogs(
params: MasterModel.SearchLogPaginationBody, params: MasterModel.SearchLogPaginationBody,
type: MasterModel.LogTypeRequest, type: MasterModel.LogTypeRequest,
) { ) {
return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, { return request<MasterModel.MesageReaderResponse>(
params: params, `${API_READER}/${type}/messages`,
}); {
params: params,
},
);
} }

36
src/services/master/typings/camera.d.ts vendored Normal file
View 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;
}
}

View File

@@ -29,12 +29,10 @@ declare namespace MasterModel {
} }
// Response types cho từng domain // Response types cho từng domain
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
type AlarmMessageResponse = MesageReaderResponse<Alarm>; type AlarmMessageResponse = MesageReaderResponse<Alarm>;
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>; type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6; type MessageDataType = NodeConfig[];
interface Message<T = MessageDataType> extends MessageBasicInfo { interface Message<T = MessageDataType> extends MessageBasicInfo {
string_value?: string; string_value?: string;
@@ -42,31 +40,8 @@ declare namespace MasterModel {
} }
// Message types cho từng domain // Message types cho từng domain
type CameraMessage = Message<CameraV5>;
type CameraV6Message = Message<CameraV6>;
type NodeConfigMessage = Message<NodeConfig[]>; 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 { interface Alarm {
id: string; id: string;
type: Type; type: Type;

151
src/utils/mqttClient.ts Normal file
View 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();