diff --git a/config/request_dev.ts b/config/request_dev.ts index 7357ff2..19057be 100644 --- a/config/request_dev.ts +++ b/config/request_dev.ts @@ -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 | 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 ( diff --git a/config/request_prod.ts b/config/request_prod.ts index 74b2270..fb617b2 100644 --- a/config/request_prod.ts +++ b/config/request_prod.ts @@ -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 | 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); diff --git a/docs/mqtt-client.md b/docs/mqtt-client.md new file mode 100644 index 0000000..26c3dcd --- /dev/null +++ b/docs/mqtt-client.md @@ -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 ( + + ); +}; +``` + +## 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 diff --git a/package-lock.json b/package-lock.json index 1288b60..e3d7ef1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6b54aab..d0a99e1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index 3586cc6..75f2704 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -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', })); }); } diff --git a/src/pages/Manager/Device/Camera/components/CameraFormModal.tsx b/src/pages/Manager/Device/Camera/components/CameraFormModal.tsx index 80cd2b1..3863024 100644 --- a/src/pages/Manager/Device/Camera/components/CameraFormModal.tsx +++ b/src/pages/Manager/Device/Camera/components/CameraFormModal.tsx @@ -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 = ({ open, onCancel, onSubmit, + isOnline = true, + editingCamera, }) => { const [form] = Form.useForm(); + 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 = ({ return ( = ({ Hủy , , ]} width={500} diff --git a/src/pages/Manager/Device/Camera/components/CameraTable.tsx b/src/pages/Manager/Device/Camera/components/CameraTable.tsx index f67682e..3771fb8 100644 --- a/src/pages/Manager/Device/Camera/components/CameraTable.tsx +++ b/src/pages/Manager/Device/Camera/components/CameraTable.tsx @@ -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 = ({ cameraData, onCreateCamera, + onEditCamera, + onDeleteCameras, onReload, loading = false, + isOnline = false, }) => { const { token } = theme.useToken(); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); const handleReload = () => { console.log('Reload cameras'); @@ -27,22 +35,18 @@ const CameraTable: React.FC = ({ }; 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: () => , - }, { title: 'Tên', dataIndex: 'name', @@ -67,11 +71,14 @@ const CameraTable: React.FC = ({ title: 'Thao tác', key: 'action', render: (_: any, record: MasterModel.Camera) => ( - - + +