Compare commits

13 Commits

Author SHA1 Message Date
057c7885cf feat: implement "Remember Me" functionality with AES-256 encryption for secure credential storage 2026-02-14 18:31:14 +07:00
ea5fc0a617 feat: Refactor theme management and localization for camera and terminal components 2026-02-10 15:07:46 +07:00
9d211ed43c feat: add camera configuration translations and enhance Camera components with internationalization support 2026-02-09 21:55:56 +07:00
674d53bcc5 feat: add Manager Dashboard and Device Terminal features 2026-02-09 16:38:28 +07:00
4af34eab3e refactor: streamline camera form handling and update types for better consistency 2026-02-08 14:58:33 +07:00
d619534a73 feat: add MQTT client for camera configuration and enhance camera management 2026-02-08 11:58:57 +07:00
78162fc0cb chore(release): Remove commented-out disabled property from two-factor authentication menu item configuration." 2026-02-07 09:14:54 +07:00
155101491b feat: Implement Two-Factor Authentication (2FA) with new OTP and login forms, updated API endpoints, and manager functionality to disable 2FA. 2026-02-07 08:24:13 +07:00
a011405d92 chore: update iconfont url 2026-02-06 23:27:36 +07:00
afe50dbd07 chore: update iconfont url 2026-02-06 22:29:01 +07:00
Tran Anh Tuan
8af31a0435 feat(device/detail): update BinarySensors component, add Chart component for sensor data visualization and add update-iconfont.sh 2026-02-05 10:09:59 +07:00
256ce06ea2 chore: update iconfont url 2026-02-05 10:00:46 +07:00
acb453d98d chore: update iconfont url 2026-02-05 09:57:40 +07:00
58 changed files with 5802 additions and 429 deletions

View File

@@ -5,6 +5,8 @@ import {
forgotPasswordRoute,
loginRoute,
managerCameraRoute,
managerDashboardRoute,
managerDeviceTerminalRoute,
managerRouteBase,
notFoundRoute,
profileRoute,
@@ -28,7 +30,12 @@ export default defineConfig({
},
{
...managerRouteBase,
routes: [...commonManagerRoutes, managerCameraRoute],
routes: [
managerDashboardRoute,
...commonManagerRoutes,
managerCameraRoute,
managerDeviceTerminalRoute,
],
},
{
path: '/',

View File

@@ -9,8 +9,9 @@ import {
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd';
import axios from 'axios';
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
@@ -52,7 +53,8 @@ export const handleRequestConfig: RequestConfig = {
return (
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
status === HTTPSTATUS.HTTP_UNAUTHORIZED ||
status === HTTPSTATUS.HTTP_FORBIDDEN
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -121,15 +123,16 @@ 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.config.url?.includes(
API_PATH_REFRESH_TOKEN,
);
// 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 (
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();
console.log('Access Token hết hạn, đang refresh...');
@@ -157,22 +160,37 @@ 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 = {
...options,
url: originalConfig.url,
method: originalConfig.method,
headers: {
...(options.headers || {}),
...(originalConfig.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
data: data,
params: originalConfig.params,
};
// Gọi lại request gốc với accessToken mới
return request(response.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);

View File

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

View File

@@ -97,11 +97,22 @@ export const managerCameraRoute = {
component: './Manager/Device/Camera',
};
export const managerDeviceTerminalRoute = {
path: '/manager/devices/:thingId/terminal',
component: './Manager/Device/Terminal',
};
export const managerRouteBase = {
name: 'manager',
icon: 'icon-setting',
path: '/manager',
access: 'canAdmin_SysAdmin',
hideChildrenInMenu: true,
};
export const managerDashboardRoute = {
path: '/manager',
component: './Manager/Dashboard',
};
export const notFoundRoute = {

595
docs/encryption-flow.md Normal file
View File

@@ -0,0 +1,595 @@
# 🔐 Luồng Hoạt Động - Hệ Thống Lưu Mật Khẩu AES-256
**Version:** 2.0 (AES-256 Encryption)
**File:** `src/utils/rememberMe.ts`
**Loại Bảo Mật:** AES-256 (Advanced Encryption Standard)
---
## 📋 Mục Lục
1. [Tổng Quan](#tổng-quan)
2. [Kiến Thức Nền Tảng](#kiến-thức-nền-tảng)
3. [Luồng Lưu Thông Tin](#luồng-lưu-thông-tin)
4. [Luồng Tải Thông Tin](#luồng-tải-thông-tin)
5. [Chi Tiết Mã Hóa AES-256](#chi-tiết-mã-hóa-aes-256)
6. [Ví Dụ Cụ Thể](#ví-dụ-cụ-thể)
7. [Xử Lý Lỗi](#xử-lý-lỗi)
8. [So Sánh Bảo Mật](#so-sánh-bảo-mật)
---
## 🎯 Tổng Quan
Hệ thống lưu mật khẩu "Remember Me" sử dụng **AES-256 encryption** để bảo vệ thông tin đăng nhập người dùng khi lưu vào `localStorage`.
### Các Bước Chính:
```
┌─────────────────────────────────────────────────────────┐
│ 1. Nhập Email + Password từ form đăng nhập │
├─────────────────────────────────────────────────────────┤
│ 2. Tạo Object: { email, password } │
├─────────────────────────────────────────────────────────┤
│ 3. Chuyển thành JSON string │
├─────────────────────────────────────────────────────────┤
│ 4. Mã hóa AES-256 (với SECRET_KEY) │
├─────────────────────────────────────────────────────────┤
│ 5. Lưu ciphertext vào localStorage │
├─────────────────────────────────────────────────────────┤
│ 6. Khi cần: Giải mã và lấy lại thông tin │
└─────────────────────────────────────────────────────────┘
```
---
## 🏫 Kiến Thức Nền Tảng
### AES-256 là gì?
**AES** = Advanced Encryption Standard (chuẩn mã hóa quốc tế)
**256** = Khóa bí mật dài 256 bit (32 bytes)
### Tại sao dùng AES-256?
| Tiêu Chí | Chi Tiết |
| --- | --- |
| **Độ An Toàn** | ✅ Hầu như không thể bẻ khóa (2^256 khả năng) |
| **Tốc Độ** | ✅ Nhanh (xử lý được tỷ tấn dữ liệu/giây) |
| **Tiêu Chuẩn** | ✅ Được chính phủ Mỹ & thế giới sử dụng |
| **IV (Initialization Vector)** | ✅ Tự động tạo ngẫu nhiên mỗi lần |
| **Pattern Hiding** | ✅ Cùng plaintext → khác ciphertext |
### IV (Initialization Vector) là gì?
- **Định nghĩa:** Một chuỗi bit ngẫu nhiên 128-bit (16 bytes)
- **Chức năng:** Đảm bảo cùng plaintext lại được mã hóa khác
- **Ví dụ:**
```
Plaintext: "password"
Lần 1: IV = random_1 → Ciphertext = "aB3dE7..."
Lần 2: IV = random_2 → Ciphertext = "xY9pQ2..." (khác!)
Lần 3: IV = random_3 → Ciphertext = "kL5mR8..." (khác!)
Cả 3 lần đếu giải mã lại thành "password"
```
---
## 💾 Luồng Lưu Thông Tin
### Hàm: `saveCredentials(email, password)`
```
Đầu Vào:
email = "user@gmail.com"
password = "MyPassword123"
┌────────────────────────────────┐
│ Bước 1: Tạo Object Credentials │
└────────────────────────────────┘
credentials = {
email: "user@gmail.com",
password: "MyPassword123"
}
┌─────────────────────────────────┐
│ Bước 2: Chuyển Object → JSON │
└─────────────────────────────────┘
json = '{"email":"user@gmail.com","password":"MyPassword123"}'
┌───────────────────────────────────────────┐
│ Bước 3: Mã Hóa (AES-256) │
│ │
│ Hàm: encrypt(json) │
│ CryptoJS.AES.encrypt(json, SECRET_KEY) │
└───────────────────────────────────────────┘
encrypted = "U2FsdGVkX1+ZzO2jNxNKbvH..."
(output = Salt + IV + Ciphertext, tất cả dạng Base64)
┌─────────────────────────────────────────┐
│ Bước 4: Lưu vào localStorage │
│ localStorage.setItem(REMEMBER_ME_KEY, │
│ encrypted) │
└─────────────────────────────────────────┘
localStorage['smatec_remember_login'] = "U2FsdGVkX1+ZzO2jNxNKbvH..."
Đầu Ra: ✅ Mật khẩu được mã hóa & lưu an toàn
```
### Code Chi Tiết:
```typescript
export function saveCredentials(email: string, password: string): void {
try {
// Bước 1: Tạo object
const credentials: RememberedCredentials = { email, password };
// Bước 2: JSON stringify
const json = JSON.stringify(credentials);
// Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}'
// Bước 3: Mã hóa AES-256
const encrypted = encrypt(json);
// Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..."
// Bước 4: Lưu vào localStorage
localStorage.setItem(REMEMBER_ME_KEY, encrypted);
// Lưu thành công ✅
} catch (error) {
console.error('Error saving credentials:', error);
}
}
```
---
## 📂 Luồng Tải Thông Tin
### Hàm: `loadCredentials()`
```
Đầu Vào: (Không có tham số - tự động từ localStorage)
┌────────────────────────────────────────┐
│ Bước 1: Lấy Dữ Liệu Từ localStorage │
└────────────────────────────────────────┘
encrypted = localStorage.getItem(REMEMBER_ME_KEY)
// Kết quả: "U2FsdGVkX1+ZzO2jNxNKbvH..." hoặc null
┌────────────────────────────────┐
│ Bước 2: Kiểm Tra Null │
└────────────────────────────────┘
if (!encrypted) {
return null ❌ (Không có dữ liệu lưu)
}
┌───────────────────────────────────────────┐
│ Bước 3: Giải Mã (AES-256) │
│ │
│ Hàm: decrypt(encrypted) │
│ CryptoJS.AES.decrypt(encrypted, │
│ SECRET_KEY) │
└───────────────────────────────────────────┘
decrypted_text = '{"email":"user@gmail.com","password":"MyPassword123"}'
┌──────────────────────────────────┐
│ Bước 4: Kiểm Tra Decode Hợp Lệ │
└──────────────────────────────────┘
if (!decrypted_text) {
clearCredentials() // Xóa dữ liệu lỗi
return null ❌
}
┌──────────────────────────────┐
│ Bước 5: Parse JSON │
└──────────────────────────────┘
credentials = JSON.parse(decrypted_text)
// Kết quả:
// {
// email: "user@gmail.com",
// password: "MyPassword123"
// }
return credentials ✅
Đầu Ra: RememberedCredentials object hoặc null
```
### Code Chi Tiết:
```typescript
export function loadCredentials(): RememberedCredentials | null {
try {
// Bước 1: Lấy từ localStorage
const encrypted = localStorage.getItem(REMEMBER_ME_KEY);
// Bước 2: Kiểm tra null
if (!encrypted) {
return null;
}
// Bước 3: Giải mã AES-256
const decrypted = decrypt(encrypted);
// Kết quả: '{"email":"user@gmail.com","password":"MyPassword123"}'
// Bước 4: Kiểm tra giải mã hợp lệ
if (!decrypted) {
clearCredentials();
return null;
}
// Bước 5: Parse JSON
const credentials: RememberedCredentials = JSON.parse(decrypted);
// Trả về object credentials ✅
return credentials;
} catch (error) {
console.error('Error loading credentials:', error);
clearCredentials();
return null;
}
}
```
---
## 🔐 Chi Tiết Mã Hóa AES-256
### Hàm Encrypt
```typescript
function encrypt(data: string): string {
try {
// CryptoJS.AES.encrypt() tự động:
// 1. Tạo salt ngẫu nhiên (8 bytes)
// 2. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2)
// 3. Mã hóa data bằng AES-256-CBC
// 4. Trả về: Salt + IV + Ciphertext (tất cả Base64)
const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
return encrypted;
} catch (error) {
console.error('Encryption error:', error);
return '';
}
}
```
**Cấu Trúc Output:**
```
Ciphertext cuối cùng = "U2FsdGVkX1+ZzO2jNxNKbvH..."
Được chia thành 3 phần:
│ Part 1 │ Part 2 │ Part 3 │
│ Magic String │ Salt (8) │ IV + Ciphertext │
│ "Salted__" │ random │ │
└──────────────┴───────────┴─────────────────┘
Tất cả encode Base64
```
### Hàm Decrypt
```typescript
function decrypt(encrypted: string): string {
try {
// CryptoJS.AES.decrypt() tự động:
// 1. Decode Base64 ciphertext
// 2. Trích xuất Salt từ đầu
// 3. Từ salt + SECRET_KEY → tạo key & IV (PBKDF2)
// 4. Giải mã dữ liệu bằng AES-256-CBC
// 5. Trả về plaintext
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
// Chuyển từ WordArray sang UTF8 string
const result = decrypted.toString(CryptoJS.enc.Utf8);
return result;
} catch (error) {
console.error('Decryption error:', error);
return '';
}
}
```
### Biểu Đồ Quy Trình AES-256
```
ENCRYPTION SIDE (Lưu Mật Khẩu)
═══════════════════════════════════════════════════════
Plaintext: "password"
├─→ [PBKDF2 + Salt] ──→ Key (256-bit) + IV (128-bit)
│ │
│ ↓
└──────────────────→ [AES-256-CBC Engine]
Ciphertext
[Base64 Encode]
"U2FsdGVkX1+ZzO2jNxNKbvH..."
DECRYPTION SIDE (Tải Mật Khẩu)
═══════════════════════════════════════════════════════
Ciphertext: "U2FsdGVkX1+ZzO2jNxNKbvH..."
├─→ [Base64 Decode]
├─→ Extract Salt (8 bytes)
│ │
│ └──→ [PBKDF2] ──→ Key (256-bit) + IV (128-bit)
├─→ Extract Ciphertext
│ │
│ └──→ [AES-256-CBC Engine]
│ │
│ ↓
└──────────→ Plaintext: "password"
```
---
## 💡 Ví Dụ Cụ Thể
### Ví Dụ 1: Lưu Email & Mật Khẩu
**Input:**
```javascript
saveCredentials('john@example.com', 'SecurePass2024!');
```
**Bước Bước:**
| Bước | Dữ Liệu | Mô Tả |
| --- | --- | --- |
| 1 | `{ email: "john@example.com", password: "SecurePass2024!" }` | Object credentials |
| 2 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | JSON stringify |
| 3 | `U2FsdGVkX1...abc123...` | AES-256 encrypt + Base64 |
| 4 | localStorage | Lưu thành công ✅ |
**Trong localStorage:**
```javascript
localStorage = {
smatec_remember_login: 'U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p...',
};
```
### Ví Dụ 2: Tải Email & Mật Khẩu
**Input:**
```javascript
loadCredentials();
```
**Bước Bước:**
| Bước | Dữ Liệu | Mô Tả |
| --- | --- | --- |
| 1 | `"U2FsdGVkX1+ZzO2jNxNKbvHJmVaFptBWc0p..."` | Lấy từ localStorage |
| 2 | ✅ Tồn tại | Kiểm tra có dữ liệu |
| 3 | `'{"email":"john@example.com","password":"SecurePass2024!"}'` | AES-256 decrypt |
| 4 | ✅ Valid JSON | Kiểm tra hợp lệ |
| 5 | `{ email: "john@example.com", password: "SecurePass2024!" }` | JSON parse |
| 6 | Trả về object | ✅ |
**Output:**
```javascript
{
email: "john@example.com",
password: "SecurePass2024!"
}
```
---
## ⚠️ Xử Lý Lỗi
### Trường Hợp 1: Không Có Dữ Liệu Lưu
```
loadCredentials()
Kiểm tra localStorage
encrypted === null
return null ← Không có gì để tải
```
### Trường Hợp 2: Dữ Liệu Bị Hỏng
```
loadCredentials()
Lấy được dữ liệu
Giải mã thất bại (decrypt() trả về '')
clearCredentials() ← Xóa dữ liệu lỗi
return null ← Không thể tải
```
### Trường Hợp 3: JSON Parse Lỗi
```
loadCredentials()
Giải mã thành công
JSON.parse() thất bại (dữ liệu không phải JSON)
catch block → clearCredentials()
return null ← Lỗi JSON
```
### Trường Hợp 4: LocalStorage Không Khả Dụng
```
Các hàm try-catch sẽ bắt lỗi
Trả về false / null / log error
Ứng dụng vẫn hoạt động bình thường (graceful degradation)
```
---
## 📊 So Sánh Bảo Mật
### XOR Cipher (Cũ) vs AES-256 (Mới)
```
╔════════════════════════════════════════════════════════════════╗
║ XOR CIPHER (CŨ) │ AES-256 (MỚI) ║
╠════════════════════════════════════════════════════════════════╣
║ Mã Hóa Kiểu │ XOR từng ký tự │ Mã hóa khối ║
║ Độ An Toàn │ ⚠️ Rất Thấp │ ✅ Cực Cao ║
║ Key Length │ Xoay vòng bằng khóa │ 256-bit (32 bytes) ║
║ IV │ ❌ Không có │ ✅ Ngẫu nhiên ║
║ Brute Force │ ⚠️ Dễ (2^key_length) │ ✅ Không Thể ║
║ Pattern │ ⚠️ Có pattern │ ✅ Ẩn pattern ║
║ │ (cùng text → lại) │ (random IV) ║
║ Block Mode │ N/A │ ✅ CBC Mode ║
║ Salt │ ❌ Không │ ✅ Có PBKDF2 ║
║ Performance │ ✅ Cực Nhanh │ ✅ Nhanh (HW opt) ║
║ Standard │ ❌ Custom/Weak │ ✅ NIST/Military ║
╚════════════════════════════════════════════════════════════════╝
```
### Ví Dụ Tấn Công:
**XOR Cipher:**
```
Nếu biết:
- Ciphertext = "abc123"
- Plaintext đối với ciphertext khác = "password"
Có thể dễ dàng suy ra SECRET_KEY bằng XOR:
plaintext XOR ciphertext = key
```
**AES-256:**
```
Ngay cả biết:
- Ciphertext = "U2FsdGVkX1+ZzO2jNxNKbvH..."
- Plaintext = "password"
- IV được sử dụng
Vẫn không thể suy ra SECRET_KEY (cần brute force 2^256 khả năng)
≈ 10^77 năm với máy tính hiện đại
```
---
## 🔧 Các Hàm Hỗ Trợ
### 1. `clearCredentials()`
```typescript
export function clearCredentials(): void {
localStorage.removeItem(REMEMBER_ME_KEY);
// Xóa hoàn toàn dữ liệu khỏi localStorage
// Dùng khi: Người dùng logout hoặc "Forget Me"
}
```
### 2. `hasSavedCredentials()`
```typescript
export function hasSavedCredentials(): boolean {
try {
const encrypted = localStorage.getItem(REMEMBER_ME_KEY);
return encrypted !== null;
// Kiểm tra xem có dữ liệu lưu hay không
// Dùng khi: Hiển thị checkbox "Remember Me"
} catch (error) {
return false;
}
}
```
---
## 🎯 Luồng Sử Dụng Trong Ứng Dụng
### Sơ Đồ Hoàn Chỉnh:
```
LOGIN PAGE
├─→ User nhập email + password
├─→ Tick checkbox "Remember Me"?
│ │
│ ├─→ YES: saveCredentials(email, pwd)
│ │ └─→ Mã hóa + Lưu localStorage ✅
│ │
│ └─→ NO: Không lưu gì
├─→ Gửi login request lên server
└─→ Server xác thực OK ✅
└─→ Redirect tới Dashboard
SUBSEQUENT LOGIN (Lần Đăng Nhập Tiếp Theo)
├─→ Check: hasSavedCredentials()?
│ │
│ ├─→ YES: loadCredentials()
│ │ └─→ Giải mã + Parse ✅
│ │ └─→ Auto-fill form
│ │
│ └─→ NO: Form trống (User nhập thủ công)
└─→ User xác nhận + submit
└─→ Login
```
---
## 📝 Tóm Tắt
| Chức Năng | Hàm | Mô Tả |
| --- | --- | --- |
| Lưu | `saveCredentials(email, pwd)` | Mã hóa AES-256 + lưu localStorage |
| Tải | `loadCredentials()` | Lấy từ localStorage + giải mã |
| Xóa | `clearCredentials()` | Xóa hoàn toàn từ localStorage |
| Kiểm Tra | `hasSavedCredentials()` | Kiểm tra có dữ liệu hay không |
## 🛡️ Kết Luận
**AES-256** được sử dụng để mã hóa thông tin sensitive
**Random IV** đảm bảo mã hóa khác lần nữa
**PBKDF2** làm việc với SECRET_KEY để tạo key mạnh
**Try-Catch** xử lý tất cả lỗi gracefully
**localStorage** là vị trí lưu phù hợp (with encrypted data)
**Bảo mật hiện tại:** ⭐⭐⭐⭐⭐ (Military Grade)

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

424
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{
"name": "smatec-frontend",
"name": "SMATEC-FRONTEND",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -10,15 +10,22 @@
"@ant-design/pro-components": "^2.8.10",
"@umijs/max": "^4.6.23",
"antd": "^5.4.0",
"chart.js": "^4.5.1",
"classnames": "^2.5.1",
"dayjs": "^1.11.19",
"moment": "^2.30.1",
"mqtt": "^5.15.0",
"ol": "^10.6.1",
"reconnecting-websocket": "^4.4.0"
"react-chartjs-2": "^5.3.1",
"reconnecting-websocket": "^4.4.0",
"uuid": "^13.0.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.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",
@@ -2842,6 +2849,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@loadable/component": {
"version": "5.15.2",
"resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz",
@@ -4036,6 +4049,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",
@@ -4060,6 +4082,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",
@@ -7058,6 +7096,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",
@@ -7944,6 +7994,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",
@@ -8029,6 +8131,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",
@@ -8422,6 +8536,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -8687,6 +8813,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",
@@ -8750,6 +8882,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",
@@ -10731,6 +10878,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",
@@ -10943,6 +11099,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",
@@ -11846,6 +12015,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",
@@ -12350,6 +12525,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",
@@ -14290,6 +14474,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",
@@ -14331,6 +14524,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",
@@ -14711,6 +14993,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",
@@ -17295,6 +17597,16 @@
"node": ">=0.10.0"
}
},
"node_modules/react-chartjs-2": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz",
"integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -17999,7 +18311,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": {
@@ -18759,6 +19070,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",
@@ -20985,6 +21320,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",
@@ -21328,6 +21669,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",
@@ -22168,6 +22522,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",
@@ -22326,6 +22727,23 @@
"node": ">=0.4"
}
},
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
"license": "MIT"
},
"node_modules/xterm-addon-fit": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
"license": "MIT",
"peerDependencies": {
"xterm": "^5.0.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -21,15 +21,25 @@
"@ant-design/pro-components": "^2.8.10",
"@umijs/max": "^4.6.23",
"antd": "^5.4.0",
"chart.js": "^4.5.1",
"classnames": "^2.5.1",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.19",
"moment": "^2.30.1",
"mqtt": "^5.15.0",
"ol": "^10.6.1",
"reconnecting-websocket": "^4.4.0"
"react-chartjs-2": "^5.3.1",
"react-countup": "^6.5.3",
"reconnecting-websocket": "^4.4.0",
"uuid": "^13.0.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
"@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",

330
pnpm-lock.yaml generated
View File

@@ -20,28 +20,58 @@ importers:
antd:
specifier: ^5.4.0
version: 5.29.3(date-fns@2.30.0)(moment@2.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
chart.js:
specifier: ^4.5.1
version: 4.5.1
classnames:
specifier: ^2.5.1
version: 2.5.1
crypto-js:
specifier: ^4.2.0
version: 4.2.0
dayjs:
specifier: ^1.11.19
version: 1.11.19
moment:
specifier: ^2.30.1
version: 2.30.1
mqtt:
specifier: ^5.15.0
version: 5.15.0
ol:
specifier: ^10.6.1
version: 10.7.0
react-chartjs-2:
specifier: ^5.3.1
version: 5.3.1(chart.js@4.5.1)(react@18.3.1)
react-countup:
specifier: ^6.5.3
version: 6.5.3(react@18.3.1)
reconnecting-websocket:
specifier: ^4.4.0
version: 4.4.0
uuid:
specifier: ^13.0.0
version: 13.0.0
xterm:
specifier: ^5.3.0
version: 5.3.0
xterm-addon-fit:
specifier: ^0.8.0
version: 0.8.0(xterm@5.3.0)
devDependencies:
'@types/crypto-js':
specifier: ^4.2.2
version: 4.2.2
'@types/react':
specifier: ^18.0.33
version: 18.3.27
'@types/react-dom':
specifier: ^18.0.11
version: 18.3.7(@types/react@18.3.27)
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
babel-plugin-transform-remove-console:
specifier: ^6.9.4
version: 6.9.4
@@ -958,6 +988,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, tarball: https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==, tarball: https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz}
'@loadable/component@5.15.2':
resolution: {integrity: sha512-ryFAZOX5P2vFkUdzaAtTG88IGnr9qxSdvLRvJySXcUA4B4xVWurUNADu3AnKPksxOZajljqTrDEDcYjeL4lvLw==, tarball: https://registry.npmjs.org/@loadable/component/-/component-5.15.2.tgz}
engines: {node: '>=8'}
@@ -1302,6 +1335,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, tarball: https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==, tarball: https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==, tarball: https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz}
@@ -1388,6 +1424,9 @@ packages:
'@types/react@18.3.27':
resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==, tarball: https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz}
'@types/readable-stream@4.0.23':
resolution: {integrity: sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==, tarball: https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.23.tgz}
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==, tarball: https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz}
@@ -1400,6 +1439,12 @@ packages:
'@types/use-sync-external-store@0.0.3':
resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==, tarball: https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==, tarball: https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, tarball: https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, tarball: https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz}
@@ -1815,6 +1860,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==, tarball: https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==, tarball: https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz}
engines: {node: '>=6.5'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==, tarball: https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz}
engines: {node: '>= 0.6'}
@@ -2081,6 +2130,9 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==, tarball: https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz}
engines: {node: '>=8'}
bl@6.1.6:
resolution: {integrity: sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==, tarball: https://registry.npmjs.org/bl/-/bl-6.1.6.tgz}
bn.js@4.12.2:
resolution: {integrity: sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==, tarball: https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz}
@@ -2108,6 +2160,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, tarball: https://registry.npmjs.org/braces/-/braces-3.0.3.tgz}
engines: {node: '>=8'}
broker-factory@3.1.13:
resolution: {integrity: sha512-H2VALe31mEtO/SRcNp4cUU5BAm1biwhc/JaF77AigUuni/1YT0FLCJfbUxwIEs9y6Kssjk2fmXgf+Y9ALvmKlw==, tarball: https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.13.tgz}
brorand@1.1.0:
resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==, tarball: https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz}
@@ -2151,6 +2206,9 @@ packages:
buffer@4.9.2:
resolution: {integrity: sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==, tarball: https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==, tarball: https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz}
builtin-status-codes@3.0.0:
resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==, tarball: https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz}
@@ -2215,6 +2273,10 @@ packages:
resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, tarball: https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==, tarball: https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz}
engines: {pnpm: '>=8'}
chokidar@3.5.3:
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==, tarball: https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz}
engines: {node: '>= 8.10.0'}
@@ -2305,6 +2367,9 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==, tarball: https://registry.npmjs.org/commander/-/commander-8.3.0.tgz}
engines: {node: '>= 12'}
commist@3.2.0:
resolution: {integrity: sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==, tarball: https://registry.npmjs.org/commist/-/commist-3.2.0.tgz}
common-path-prefix@3.0.0:
resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==, tarball: https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz}
@@ -2325,6 +2390,10 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, tarball: https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==, tarball: https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz}
engines: {'0': node >= 6.0}
connect-history-api-fallback@2.0.0:
resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==, tarball: https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz}
engines: {node: '>=0.8'}
@@ -2392,6 +2461,9 @@ packages:
typescript:
optional: true
countup.js@2.9.0:
resolution: {integrity: sha512-llqrvyXztRFPp6+i8jx25phHWcVWhrHO4Nlt0uAOSKHB8778zzQswa4MU3qKBvkXfJKftRYFJuVHez67lyKdHg==, tarball: https://registry.npmjs.org/countup.js/-/countup.js-2.9.0.tgz}
create-ecdh@4.0.4:
resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==, tarball: https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz}
@@ -2409,6 +2481,9 @@ packages:
resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==, tarball: https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.1.tgz}
engines: {node: '>= 0.10'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==, tarball: https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz}
css-blank-pseudo@3.0.3:
resolution: {integrity: sha512-VS90XWtsHGqoM0t4KpH053c4ehxZ2E6HtGI7x68YFV0pTo/QmkV/YFA+NnlvK8guxZVNWGQhVNJGC39Q8XF4OQ==, tarball: https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-3.0.3.tgz}
engines: {node: ^12 || ^14 || >=16}
@@ -3002,6 +3077,10 @@ packages:
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==, tarball: https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==, tarball: https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz}
engines: {node: '>=6'}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==, tarball: https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz}
@@ -3060,6 +3139,10 @@ packages:
resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==, tarball: https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz}
engines: {node: '>=6'}
fast-unique-numbers@9.0.26:
resolution: {integrity: sha512-3Mtq8p1zQinjGyWfKeuBunbuFoixG72AUkk4VvzbX4ykCW9Q4FzRaNyIlfQhUjnKw2ARVP+/CKnoyr6wfHftig==, tarball: https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.26.tgz}
engines: {node: '>=18.2.0'}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz}
@@ -3359,6 +3442,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==, tarball: https://registry.npmjs.org/he/-/he-1.2.0.tgz}
hasBin: true
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==, tarball: https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz}
history@4.10.1:
resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==, tarball: https://registry.npmjs.org/history/-/history-4.10.1.tgz}
@@ -3526,6 +3612,10 @@ packages:
invariant@2.2.4:
resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==, tarball: https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==, tarball: https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, tarball: https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz}
engines: {node: '>= 0.10'}
@@ -3795,6 +3885,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==, tarball: https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz}
hasBin: true
js-sdsl@4.3.0:
resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==, tarball: https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz}
js-sdsl@4.4.2:
resolution: {integrity: sha512-dwXFwByc/ajSV6m5bcKAPwe4yDDF6D614pxmIi5odytzxRlwqF6nwoiCek80Ixc7Cvma5awClxrzFtxCQvcM8w==, tarball: https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.4.2.tgz}
@@ -4156,6 +4249,9 @@ packages:
resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==, tarball: https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz}
engines: {node: '>= 6'}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, tarball: https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==, tarball: https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz}
engines: {node: '>=16 || 14 >=14.17'}
@@ -4163,6 +4259,14 @@ packages:
moment@2.30.1:
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==, tarball: https://registry.npmjs.org/moment/-/moment-2.30.1.tgz}
mqtt-packet@9.0.2:
resolution: {integrity: sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==, tarball: https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz}
mqtt@5.15.0:
resolution: {integrity: sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw==, tarball: https://registry.npmjs.org/mqtt/-/mqtt-5.15.0.tgz}
engines: {node: '>=16.0.0'}
hasBin: true
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==, tarball: https://registry.npmjs.org/ms/-/ms-2.0.0.tgz}
@@ -4263,6 +4367,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, tarball: https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz}
number-allocator@1.0.14:
resolution: {integrity: sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==, tarball: https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, tarball: https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz}
engines: {node: '>=0.10.0'}
@@ -5382,6 +5489,17 @@ packages:
react: '>=16.9.0'
react-dom: '>=16.9.0'
react-chartjs-2@5.3.1:
resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==, tarball: https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz}
peerDependencies:
chart.js: ^4.1.1
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-countup@6.5.3:
resolution: {integrity: sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==, tarball: https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz}
peerDependencies:
react: '>= 16.3.0'
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==, tarball: https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz}
peerDependencies:
@@ -5505,6 +5623,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==, tarball: https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==, tarball: https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz}
engines: {node: '>=8.10.0'}
@@ -5840,6 +5962,14 @@ packages:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==, tarball: https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz}
engines: {node: '>=12'}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==, tarball: https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
socks@2.8.7:
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==, tarball: https://registry.npmjs.org/socks/-/socks-2.8.7.tgz}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonic-boom@2.8.0:
resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==, tarball: https://registry.npmjs.org/sonic-boom/-/sonic-boom-2.8.0.tgz}
@@ -6269,6 +6399,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==, tarball: https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz}
engines: {node: '>= 0.4'}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==, tarball: https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz}
engines: {node: '>=14.17'}
@@ -6358,6 +6491,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==, tarball: https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz}
engines: {node: '>= 0.4.0'}
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==, tarball: https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz}
hasBin: true
v8-compile-cache@2.4.0:
resolution: {integrity: sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw==, tarball: https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz}
@@ -6487,6 +6624,18 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, tarball: https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz}
engines: {node: '>=0.10.0'}
worker-factory@7.0.48:
resolution: {integrity: sha512-CGmBy3tJvpBPjUvb0t4PrpKubUsfkI1Ohg0/GGFU2RvA9j/tiVYwKU8O7yu7gH06YtzbeJLzdUR29lmZKn5pag==, tarball: https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.48.tgz}
worker-timers-broker@8.0.15:
resolution: {integrity: sha512-Te+EiVUMzG5TtHdmaBZvBrZSFNauym6ImDaCAnzQUxvjnw+oGjMT2idmAOgDy30vOZMLejd0bcsc90Axu6XPWA==, tarball: https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.15.tgz}
worker-timers-worker@9.0.13:
resolution: {integrity: sha512-qjn18szGb1kjcmh2traAdki1eiIS5ikFo+L90nfMOvSRpuDw1hAcR1nzkP2+Hkdqz5thIRnfuWx7QSpsEUsA6Q==, tarball: https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.13.tgz}
worker-timers@8.0.30:
resolution: {integrity: sha512-8P7YoMHWN0Tz7mg+9oEhuZdjBIn2z6gfjlJqFcHiDd9no/oLnMGCARCDkV1LR3ccQus62ZdtIp7t3aTKrMLHOg==, tarball: https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.30.tgz}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, tarball: https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz}
engines: {node: '>=10'}
@@ -6521,6 +6670,16 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, tarball: https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz}
engines: {node: '>=0.4'}
xterm-addon-fit@0.8.0:
resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==, tarball: https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz}
deprecated: This package is now deprecated. Move to @xterm/addon-fit instead.
peerDependencies:
xterm: ^5.0.0
xterm@5.3.0:
resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==, tarball: https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz}
deprecated: This package is now deprecated. Move to @xterm/xterm instead.
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, tarball: https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz}
engines: {node: '>=10'}
@@ -7759,6 +7918,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@loadable/component@5.15.2(react@18.3.1)':
dependencies:
'@babel/runtime': 7.23.6
@@ -8099,6 +8260,8 @@ snapshots:
dependencies:
'@babel/types': 7.28.6
'@types/crypto-js@4.2.2': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -8192,6 +8355,10 @@ snapshots:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/readable-stream@4.0.23':
dependencies:
'@types/node': 25.0.9
'@types/resolve@1.20.6': {}
'@types/semver@7.7.1': {}
@@ -8200,6 +8367,12 @@ snapshots:
'@types/use-sync-external-store@0.0.3': {}
'@types/uuid@10.0.0': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.0.9
'@types/yargs-parser@21.0.3': {}
'@types/yargs@13.0.12':
@@ -8497,7 +8670,7 @@ snapshots:
'@umijs/history@5.3.1':
dependencies:
'@babel/runtime': 7.23.6
'@babel/runtime': 7.28.6
query-string: 6.14.1
'@umijs/lint@4.6.23(eslint@8.35.0)(stylelint@14.8.2)(typescript@5.9.3)':
@@ -8991,6 +9164,10 @@ snapshots:
'@xtuc/long@4.2.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -9398,6 +9575,13 @@ snapshots:
binary-extensions@2.3.0: {}
bl@6.1.6:
dependencies:
'@types/readable-stream': 4.0.23
buffer: 6.0.3
inherits: 2.0.4
readable-stream: 4.7.0
bn.js@4.12.2: {}
bn.js@5.2.2: {}
@@ -9438,6 +9622,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
broker-factory@3.1.13:
dependencies:
'@babel/runtime': 7.28.6
fast-unique-numbers: 9.0.26
tslib: 2.8.1
worker-factory: 7.0.48
brorand@1.1.0: {}
browserify-aes@1.2.0:
@@ -9512,6 +9703,11 @@ snapshots:
ieee754: 1.2.1
isarray: 1.0.0
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
builtin-status-codes@3.0.0: {}
bundle-name@3.0.0:
@@ -9573,6 +9769,10 @@ snapshots:
chalk@5.3.0: {}
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
chokidar@3.5.3:
dependencies:
anymatch: 3.1.3
@@ -9672,6 +9872,8 @@ snapshots:
commander@8.3.0: {}
commist@3.2.0: {}
common-path-prefix@3.0.0: {}
compressible@2.0.18:
@@ -9696,6 +9898,13 @@ snapshots:
concat-map@0.0.1: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
connect-history-api-fallback@2.0.0: {}
console-browserify@1.2.0: {}
@@ -9756,6 +9965,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
countup.js@2.9.0: {}
create-ecdh@4.0.4:
dependencies:
bn.js: 4.12.2
@@ -9799,6 +10010,8 @@ snapshots:
randombytes: 2.1.0
randomfill: 1.0.4
crypto-js@4.2.0: {}
css-blank-pseudo@3.0.3(postcss@8.5.6):
dependencies:
postcss: 8.5.6
@@ -10526,6 +10739,8 @@ snapshots:
d: 1.0.2
es5-ext: 0.10.64
event-target-shim@5.0.1: {}
eventemitter3@5.0.1: {}
events-okam@3.3.0: {}
@@ -10637,6 +10852,11 @@ snapshots:
fast-redact@3.5.0: {}
fast-unique-numbers@9.0.26:
dependencies:
'@babel/runtime': 7.28.6
tslib: 2.8.1
fast-uri@3.1.0: {}
fastest-levenshtein@1.0.16: {}
@@ -10959,6 +11179,8 @@ snapshots:
he@1.2.0: {}
help-me@5.0.0: {}
history@4.10.1:
dependencies:
'@babel/runtime': 7.28.6
@@ -11133,6 +11355,8 @@ snapshots:
dependencies:
loose-envify: 1.4.0
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
is-arguments@1.2.0:
@@ -11425,6 +11649,8 @@ snapshots:
jiti@2.6.1: {}
js-sdsl@4.3.0: {}
js-sdsl@4.4.2: {}
js-tokens@4.0.0: {}
@@ -11764,10 +11990,43 @@ snapshots:
is-plain-obj: 1.1.0
kind-of: 6.0.3
minimist@1.2.8: {}
minipass@7.1.2: {}
moment@2.30.1: {}
mqtt-packet@9.0.2:
dependencies:
bl: 6.1.6
debug: 4.4.3
process-nextick-args: 2.0.1
transitivePeerDependencies:
- supports-color
mqtt@5.15.0:
dependencies:
'@types/readable-stream': 4.0.23
'@types/ws': 8.18.1
commist: 3.2.0
concat-stream: 2.0.0
debug: 4.4.3
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.7
split2: 4.2.0
worker-timers: 8.0.30
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
ms@2.0.0: {}
ms@2.1.1: {}
@@ -11908,6 +12167,13 @@ snapshots:
dependencies:
boolbase: 1.0.0
number-allocator@1.0.14:
dependencies:
debug: 4.4.3
js-sdsl: 4.3.0
transitivePeerDependencies:
- supports-color
object-assign@4.1.1: {}
object-hash@3.0.0: {}
@@ -13208,6 +13474,16 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-chartjs-2@5.3.1(chart.js@4.5.1)(react@18.3.1):
dependencies:
chart.js: 4.5.1
react: 18.3.1
react-countup@6.5.3(react@18.3.1):
dependencies:
countup.js: 2.9.0
react: 18.3.1
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -13367,6 +13643,14 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@@ -13738,6 +14022,13 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 4.0.0
smart-buffer@4.2.0: {}
socks@2.8.7:
dependencies:
ip-address: 10.1.0
smart-buffer: 4.2.0
sonic-boom@2.8.0:
dependencies:
atomic-sleep: 1.0.0
@@ -14263,6 +14554,8 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
typedarray@0.0.6: {}
typescript@5.9.3: {}
umi@4.6.23(@babel/core@7.28.6)(@types/node@25.0.9)(@types/react@18.3.27)(eslint@8.35.0)(less-loader@12.3.0(less@4.5.1)(webpack@5.104.1))(less@4.5.1)(lightningcss@1.22.1)(prettier@2.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(resolve-url-loader@5.0.0)(rollup@3.29.5)(sass-loader@16.0.6(sass@1.54.0)(webpack@5.104.1))(sass@1.54.0)(stylelint@14.8.2)(terser@5.46.0)(type-fest@1.4.0)(typescript@5.9.3)(webpack@5.104.1):
@@ -14394,6 +14687,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@13.0.0: {}
v8-compile-cache@2.4.0: {}
validate-npm-package-license@3.0.4:
@@ -14545,6 +14840,33 @@ snapshots:
word-wrap@1.2.5: {}
worker-factory@7.0.48:
dependencies:
'@babel/runtime': 7.28.6
fast-unique-numbers: 9.0.26
tslib: 2.8.1
worker-timers-broker@8.0.15:
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
worker-timers-worker@9.0.13:
dependencies:
'@babel/runtime': 7.28.6
tslib: 2.8.1
worker-factory: 7.0.48
worker-timers@8.0.30:
dependencies:
'@babel/runtime': 7.28.6
tslib: 2.8.1
worker-timers-broker: 8.0.15
worker-timers-worker: 9.0.13
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -14570,6 +14892,12 @@ snapshots:
xtend@4.0.2: {}
xterm-addon-fit@0.8.0(xterm@5.3.0):
dependencies:
xterm: 5.3.0
xterm@5.3.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

View File

@@ -1,5 +1,6 @@
// 运行时配置
import { getTheme } from '@/utils/storage';
import { getLocale, history, Link, RunTimeLayoutConfig } from '@umijs/max';
import { ConfigProvider } from 'antd';
import dayjs from 'dayjs';
@@ -10,7 +11,6 @@ import IconFont from './components/IconFont';
import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider';
import ThemeSwitcher from './components/Theme/ThemeSwitcher';
import { THEME_KEY } from './constants';
import { ROUTE_FORGOT_PASSWORD, ROUTE_LOGIN } from './constants/routes';
import NotFoundPage from './pages/Exception/NotFound';
import UnAccessPage from './pages/Exception/UnAccess';
@@ -52,8 +52,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
// Public routes that don't require authentication
if (publicRoutes.includes(pathname)) {
const currentTheme =
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
const currentTheme = (getTheme() as 'light' | 'dark') || 'light';
return {
theme: currentTheme,
};
@@ -101,8 +100,7 @@ export async function getInitialState(): Promise<InitialStateResponse> {
}
};
const resp = await getUserProfile();
const currentTheme =
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
const currentTheme = (getTheme() as 'light' | 'dark') || 'light';
return {
getUserProfile: getUserProfile!,
currentUserProfile: resp,
@@ -121,7 +119,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light',
splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_rzr2z3xtksr.js',
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_3sthrd9e6y4.js',
contentStyle: {
padding: 0,
margin: 0,
@@ -159,6 +157,12 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
},
layout: 'top',
menuHeaderRender: undefined,
subMenuItemRender: (item, dom) => {
if (item.path) {
return <Link to={item.path}>{dom}</Link>;
}
return dom;
},
menuItemRender: (item, dom) => {
if (item.path) {
// Coerce values to string to satisfy TypeScript expectations
@@ -176,11 +180,11 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
},
token: {
header: {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorBgMenuItemSelected: isDark ? '#111b26' : '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
},
pageContainer: {
paddingInlinePageContainerContent: 8,
// paddingInlinePageContainerContent: 0,
paddingBlockPageContainerContent: 8,
},
},

View File

@@ -1,7 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_rzr2z3xtksr.js',
scriptUrl: '//at.alicdn.com/t/c/font_5096559_3sthrd9e6y4.js',
});
export default IconFont;

View File

@@ -1,27 +1,14 @@
import { useModel } from '@umijs/max';
import { ConfigProvider, theme } from 'antd';
import React, { useEffect, useState } from 'react';
import { getTheme } from './ThemeSwitcher';
import React from 'react';
interface ThemeProviderProps {
children: React.ReactNode;
}
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [isDark, setIsDark] = useState(getTheme() === 'dark');
useEffect(() => {
const handleThemeChange = (e: CustomEvent) => {
setIsDark(e.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
const { initialState } = useModel('@@initialState');
const isDark = (initialState?.theme as 'light' | 'dark') === 'dark';
return (
<ConfigProvider

View File

@@ -1,4 +1,4 @@
import { THEME_KEY } from '@/constants';
import { setTheme } from '@/utils/storage';
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
import { useModel } from '@umijs/max';
import { Segmented } from 'antd';
@@ -34,7 +34,7 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
if (!supportsViewTransition) {
// Fallback: just change theme without animation
localStorage.setItem(THEME_KEY, newTheme);
setTheme(newTheme);
setIsDark(newTheme === 'dark');
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
@@ -58,7 +58,7 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
// Start the view transition
const transition = (document as any).startViewTransition(() => {
localStorage.setItem(THEME_KEY, newTheme);
setTheme(newTheme);
setIsDark(newTheme === 'dark');
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
@@ -107,8 +107,3 @@ const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
};
export default ThemeSwitcher;
// Helper function để get theme từ localStorage
export const getTheme = (): 'light' | 'dark' => {
return (localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
};

View File

@@ -1,6 +1,5 @@
import { THEME_KEY } from '@/constants';
import { getTheme, setTheme } from '@/utils/storage';
import React, { useEffect, useState } from 'react';
import { getTheme } from './ThemeSwitcher';
import './style.less';
const ThemeSwitcherAuth = () => {
@@ -21,7 +20,7 @@ const ThemeSwitcherAuth = () => {
const handleSwitch = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTheme = e.target.checked ? 'dark' : 'light';
localStorage.setItem(THEME_KEY, newTheme);
setTheme(newTheme);
setIsDark(newTheme === 'dark');
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),

View File

@@ -1,9 +1,16 @@
// Auth API Paths
export const API_PATH_LOGIN = '/api/tokens';
export const API_PATH_LOGIN = '/api/login';
export const API_PATH_LOGIN_2FA = '/api/login/2fa';
export const API_PATH_LOGOUT = '/api/logout';
export const API_PATH_REFRESH_TOKEN = '/api/keys/refresh';
export const API_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password';
export const API_FORGOT_PASSWORD = '/api/password/reset-request';
export const API_ENABLE_2FA = '/api/users/2fa/enable';
export const API_ENABLE_2FA_VERIFY = '/api/users/2fa/verify';
export const API_DISABLE_2FA = '/api/users/2fa/disable';
export const API_ADMIN_DISABLE_2FA = '/api/2fa/disable/';
// Alarm API Constants
export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';

View File

@@ -11,6 +11,7 @@ export const DURATION_POLLING_PRESENTATIONS = 120000; //milliseconds
export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2;
export const STATUS_SOS = 3;
export const COLOR_DISCONNECT = '#d9d9d9';
@@ -22,6 +23,8 @@ export const COLOR_SOS = '#ff0000';
export const ACCESS_TOKEN = 'access_token';
export const REFRESH_TOKEN = 'refresh_token';
export const THEME_KEY = 'theme';
export const TERMINAL_THEME_KEY = 'terminal_theme_key';
export const REMEMBER_ME_KEY = 'smatec_remember_login';
// Global Constants
export const LIMIT_TREE_LEVEL = 5;
export const DEFAULT_PAGE_SIZE = 5;

View File

@@ -5,10 +5,6 @@ export default {
'master.auth.validation.email': 'Email is required',
'master.auth.password': 'Password',
'master.auth.validation.password': 'Password is required',
'master.auth.login.subtitle': 'Ship Monitoring System',
'master.auth.login.description': 'Login to continue monitoring vessels',
'master.auth.login.invalid': 'Invalid username or password',
'master.auth.login.success': 'Login successful',
'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful',
@@ -18,6 +14,11 @@ export default {
'master.auth.forgot.message.success':
'Request sent successfully, please check your email!',
'master.auth.forgot.message.fail': 'Request failed, please try again later!',
'master.auth.otp.error': 'Invalid OTP code. Please try again.',
'master.auth.otp.button.title': 'Verify OTP',
'master.auth.otp.placeholder': 'Enter OTP code',
'master.auth.otp.required': 'OTP code is required',
'master.auth.otp.length': 'OTP must be 6 digits',
'master.auth.reset.success': 'Password reset successful',
'master.auth.reset.error': 'An error occurred, please try again later!',
'master.auth.reset.invalid':

View File

@@ -17,4 +17,28 @@ export default {
'master.profile.change-password.fail': 'Change password failed',
'master.profile.change-password.password.strong':
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
'master.profile.2fa.status': '2FA Status',
'master.profile.2fa.description':
'Enable two-step verification to enhance the security of your account',
'master.profile.2fa.enabled': 'Enabled',
'master.profile.2fa.disabled': 'Disabled',
'master.profile.2fa.setup.title': 'Set up two-step verification',
'master.profile.2fa.verify': 'Confirm',
'master.profile.2fa.cancel': 'Cancel',
'master.profile.2fa.scan.instruction':
'Scan the QR code with an authentication app (Google Authenticator, Authy, ...)',
'master.profile.2fa.otp.instruction':
'Enter the 6-digit code from the authentication app:',
'master.profile.2fa.enable.error': 'Unable to enable 2FA. Please try again.',
'master.profile.2fa.otp.invalid': 'Please enter a 6-digit OTP code',
'master.profile.2fa.enable.success': '2FA enabled successfully!',
'master.profile.2fa.verify.error': 'Invalid OTP code. Please try again.',
'master.profile.2fa.disable.confirm.title': 'Confirm disable 2FA',
'master.profile.2fa.disable.confirm.content':
'Are you sure you want to disable two-step verification? This will reduce the security of your account.',
'master.profile.2fa.disable.confirm.ok': 'Disable 2FA',
'master.profile.2fa.disable.confirm.cancel': 'Cancel',
'master.profile.2fa.disable.success': '2FA disabled successfully!',
'master.profile.2fa.disable.error':
'Unable to disable 2FA. Please try again.',
};

View File

@@ -44,4 +44,81 @@ export default {
'master.devices.location.placeholder': 'Enter data',
'master.devices.location.update.success': 'Location updated successfully',
'master.devices.location.update.error': 'Location update failed',
// Camera translations
'master.devices.camera.loading': 'Loading...',
'master.devices.camera.config.success': 'Configuration sent successfully',
'master.devices.camera.config.error.deviceOffline':
'Device is offline, cannot send configuration',
'master.devices.camera.config.error.missingConfig':
'Missing device configuration information',
'master.devices.camera.config.error.mqttNotConnected': 'MQTT not connected',
// Camera Form Modal
'master.devices.camera.form.title.add': 'Add New Camera',
'master.devices.camera.form.title.edit': 'Edit Camera',
'master.devices.camera.form.name': 'Name',
'master.devices.camera.form.name.placeholder': 'Enter name',
'master.devices.camera.form.name.required': 'Please enter name',
'master.devices.camera.form.type': 'Type',
'master.devices.camera.form.type.required': 'Please select type',
'master.devices.camera.form.username': 'Username',
'master.devices.camera.form.username.placeholder': 'Enter username',
'master.devices.camera.form.username.required': 'Please enter username',
'master.devices.camera.form.password': 'Password',
'master.devices.camera.form.password.placeholder': 'Enter password',
'master.devices.camera.form.password.required': 'Please enter password',
'master.devices.camera.form.ip': 'IP Address',
'master.devices.camera.form.ip.placeholder': '192.168.1.10',
'master.devices.camera.form.ip.required': 'Please enter IP address',
'master.devices.camera.form.rtspPort': 'RTSP Port',
'master.devices.camera.form.rtspPort.required': 'Please enter RTSP port',
'master.devices.camera.form.httpPort': 'HTTP Port',
'master.devices.camera.form.httpPort.required': 'Please enter HTTP port',
'master.devices.camera.form.stream': 'Stream',
'master.devices.camera.form.stream.required': 'Please enter stream',
'master.devices.camera.form.channel': 'Channel',
'master.devices.camera.form.channel.required': 'Please enter channel',
'master.devices.camera.form.cancel': 'Cancel',
'master.devices.camera.form.submit': 'OK',
'master.devices.camera.form.update': 'Update',
// Camera Table
'master.devices.camera.table.add': 'Add New Camera',
'master.devices.camera.table.column.name': 'Name',
'master.devices.camera.table.column.type': 'Type',
'master.devices.camera.table.column.ip': 'IP Address',
'master.devices.camera.table.column.action': 'Actions',
'master.devices.camera.table.offline.tooltip': 'Device is offline',
'master.devices.camera.table.pagination': 'Showing {0}-{1} of {2} cameras',
// Camera Config V6
'master.devices.camera.config.recording': 'Camera Recording',
'master.devices.camera.config.send': 'Send',
'master.devices.camera.config.alarmList': 'Alarm List',
'master.devices.camera.config.selected': '{0} items selected',
'master.devices.camera.config.clear': 'Clear',
'master.devices.camera.config.recordingMode.none': 'No Recording',
'master.devices.camera.config.recordingMode.alarm': 'On Alarm',
'master.devices.camera.config.recordingMode.all': '24/7',
// Terminal translations
'master.devices.terminal.pageTitle': 'Terminal',
'master.devices.terminal.loadDeviceError': 'Cannot load device information.',
'master.devices.terminal.mqttError': 'Cannot connect to MQTT.',
'master.devices.terminal.genericError': 'An error occurred',
'master.devices.terminal.unsupported.title':
'Device does not support terminal',
'master.devices.terminal.unsupported.desc':
'GMSv5 devices are not supported. Please use a different device.',
'master.devices.terminal.missingChannel.title':
'Missing control channel information',
'master.devices.terminal.missingChannel.desc':
'Device has not been configured with ctrl_channel_id, cannot open terminal.',
'master.devices.terminal.missingCredential.title':
'Missing authentication information',
'master.devices.terminal.missingCredential.desc':
'Current account has not been granted frontend_thing_id/frontend_thing_key.',
'master.devices.terminal.offline':
'Device is offline. Terminal is in view-only mode.',
'master.devices.terminal.connecting': 'Preparing terminal session...',
'master.devices.terminal.action.clear': 'Clear screen',
'master.devices.terminal.action.theme': 'Theme',
};

View File

@@ -74,4 +74,12 @@ export default {
'master.users.resetPassword.modal.title': 'Reset Password For User',
'master.users.resetPassword.success': 'Password reset successful',
'master.users.resetPassword.error': 'Password reset failed',
'master.users.disable2fa.title': 'Disable 2FA',
'master.users.disable2fa.success': '2FA has been disabled successfully',
'master.users.disable2fa.error': 'Failed to disable 2FA',
'master.users.disable2fa.modal.title': 'Disable Two-Factor Authentication',
'master.users.disable2fa.modal.warning':
'Are you sure you want to disable 2FA for this user?',
'master.users.disable2fa.modal.caution':
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
};

View File

@@ -3,9 +3,6 @@ export default {
'master.auth.login.email': 'Email',
'master.auth.login.title': 'Đăng nhập',
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
'master.auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
'master.auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
'master.auth.login.success': 'Đăng nhập thành công',
'master.auth.logout.title': 'Đăng xuất',
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
'master.auth.logout.success': 'Đăng xuất thành công',
@@ -19,6 +16,11 @@ export default {
'Gửi yêu cầu thành công, vui lòng kiểm tra email của bạn!',
'master.auth.forgot.message.fail':
'Gửi yêu cầu thất bại, vui lòng thử lại sau!',
'master.auth.otp.error': 'Mã OTP không hợp lệ. Vui lòng thử lại.',
'master.auth.otp.button.title': 'Xác thực OTP',
'master.auth.otp.placeholder': 'Nhập mã OTP',
'master.auth.otp.required': 'Vui lòng nhập mã OTP',
'master.auth.otp.length': 'Mã OTP phải có 6 chữ số',
'master.auth.reset.success': 'Đặt lại mật khẩu thành công',
'master.auth.reset.error': 'Có lỗi xảy ra, vui lòng thử lại sau!',
'master.auth.reset.invalid':

View File

@@ -17,4 +17,26 @@ export default {
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
'master.profile.change-password.success': 'Đổi mật khẩu thành công',
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
'master.profile.2fa.status': 'Trạng thái 2FA',
'master.profile.2fa.description':
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
'master.profile.2fa.enabled': 'Bật',
'master.profile.2fa.disabled': 'Tắt',
'master.profile.2fa.setup.title': 'Thiết lập xác thực 2 bước',
'master.profile.2fa.verify': 'Xác nhận',
'master.profile.2fa.cancel': 'Hủy',
'master.profile.2fa.scan.instruction':
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
'master.profile.2fa.otp.instruction': 'Nhập mã 6 số từ ứng dụng xác thực:',
'master.profile.2fa.enable.error': 'Không thể bật 2FA. Vui lòng thử lại.',
'master.profile.2fa.otp.invalid': 'Vui lòng nhập mã OTP 6 số',
'master.profile.2fa.enable.success': 'Bật 2FA thành công!',
'master.profile.2fa.verify.error': 'Mã OTP không đúng. Vui lòng thử lại.',
'master.profile.2fa.disable.confirm.title': 'Xác nhận tắt 2FA',
'master.profile.2fa.disable.confirm.content':
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
'master.profile.2fa.disable.confirm.ok': 'Tắt 2FA',
'master.profile.2fa.disable.confirm.cancel': 'Hủy',
'master.profile.2fa.disable.success': 'Đã tắt 2FA thành công!',
'master.profile.2fa.disable.error': 'Không thể tắt 2FA. Vui lòng thử lại.',
};

View File

@@ -44,4 +44,80 @@ export default {
'master.devices.location.placeholder': 'Nhập dữ liệu',
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
// Camera translations
'master.devices.camera.loading': 'Đang tải...',
'master.devices.camera.config.success': 'Đã gửi cấu hình thành công',
'master.devices.camera.config.error.deviceOffline':
'Thiết bị đang ngoại tuyến, không thể gửi cấu hình',
'master.devices.camera.config.error.missingConfig':
'Thiếu thông tin cấu hình thiết bị',
'master.devices.camera.config.error.mqttNotConnected': 'MQTT chưa kết nối',
// Camera Form Modal
'master.devices.camera.form.title.add': 'Tạo mới camera',
'master.devices.camera.form.title.edit': 'Chỉnh sửa camera',
'master.devices.camera.form.name': 'Tên',
'master.devices.camera.form.name.placeholder': 'Nhập tên',
'master.devices.camera.form.name.required': 'Vui lòng nhập tên',
'master.devices.camera.form.type': 'Loại',
'master.devices.camera.form.type.required': 'Vui lòng chọn loại',
'master.devices.camera.form.username': 'Tài khoản',
'master.devices.camera.form.username.placeholder': 'Nhập tài khoản',
'master.devices.camera.form.username.required': 'Vui lòng nhập tài khoản',
'master.devices.camera.form.password': 'Mật khẩu',
'master.devices.camera.form.password.placeholder': 'Nhập mật khẩu',
'master.devices.camera.form.password.required': 'Vui lòng nhập mật khẩu',
'master.devices.camera.form.ip': 'Địa chỉ IP',
'master.devices.camera.form.ip.placeholder': '192.168.1.10',
'master.devices.camera.form.ip.required': 'Vui lòng nhập địa chỉ IP',
'master.devices.camera.form.rtspPort': 'Cổng RTSP',
'master.devices.camera.form.rtspPort.required': 'Vui lòng nhập cổng RTSP',
'master.devices.camera.form.httpPort': 'Cổng HTTP',
'master.devices.camera.form.httpPort.required': 'Vui lòng nhập cổng HTTP',
'master.devices.camera.form.stream': 'Luồng',
'master.devices.camera.form.stream.required': 'Vui lòng nhập luồng',
'master.devices.camera.form.channel': 'Kênh',
'master.devices.camera.form.channel.required': 'Vui lòng nhập kênh',
'master.devices.camera.form.cancel': 'Hủy',
'master.devices.camera.form.submit': 'Đồng ý',
'master.devices.camera.form.update': 'Cập nhật',
// Camera Table
'master.devices.camera.table.add': 'Tạo mới camera',
'master.devices.camera.table.column.name': 'Tên',
'master.devices.camera.table.column.type': 'Loại',
'master.devices.camera.table.column.ip': 'Địa chỉ IP',
'master.devices.camera.table.column.action': 'Thao tác',
'master.devices.camera.table.offline.tooltip': 'Thiết bị đang ngoại tuyến',
'master.devices.camera.table.pagination': 'Hiển thị {0}-{1} của {2} camera',
// Camera Config V6
'master.devices.camera.config.recording': 'Ghi dữ liệu camera',
'master.devices.camera.config.send': 'Gửi đi',
'master.devices.camera.config.alarmList': 'Danh sách cảnh báo',
'master.devices.camera.config.selected': 'đã chọn {0} mục',
'master.devices.camera.config.clear': 'Xóa',
'master.devices.camera.config.recordingMode.none': 'Không ghi',
'master.devices.camera.config.recordingMode.alarm': 'Theo cảnh báo',
'master.devices.camera.config.recordingMode.all': '24/24',
// Terminal translations
'master.devices.terminal.pageTitle': 'Terminal',
'master.devices.terminal.loadDeviceError':
'Không thể tải thông tin thiết bị.',
'master.devices.terminal.mqttError': 'Không thể kết nối MQTT.',
'master.devices.terminal.genericError': 'Đã có lỗi xảy ra',
'master.devices.terminal.unsupported.title': 'Thiết bị không hỗ trợ terminal',
'master.devices.terminal.unsupported.desc':
'Thiết bị GMSv5 không được hỗ trợ. Vui lòng sử dụng thiết bị khác.',
'master.devices.terminal.missingChannel.title':
'Thiếu thông tin kênh điều khiển',
'master.devices.terminal.missingChannel.desc':
'Thiết bị chưa được cấu hình ctrl_channel_id nên không thể mở terminal.',
'master.devices.terminal.missingCredential.title': 'Thiếu thông tin xác thực',
'master.devices.terminal.missingCredential.desc':
'Tài khoản hiện tại chưa được cấp frontend_thing_id/frontend_thing_key.',
'master.devices.terminal.offline':
'Thiết bị đang ngoại tuyến. Terminal chuyển sang chế độ chỉ xem.',
'master.devices.terminal.connecting': 'Đang chuẩn bị phiên terminal...',
'master.devices.terminal.action.clear': 'Xóa màn hình',
'master.devices.terminal.action.theme': 'Giao diện',
};

View File

@@ -73,4 +73,12 @@ export default {
'master.users.resetPassword.modal.title': 'Đặt lại mật khẩu cho người dùng',
'master.users.resetPassword.success': 'Đặt lại mật khẩu thành công',
'master.users.resetPassword.error': 'Đặt lại mật khẩu thất bại',
'master.users.disable2fa.title': 'Tắt 2FA',
'master.users.disable2fa.success': 'Đã tắt 2FA thành công',
'master.users.disable2fa.error': 'Tắt 2FA thất bại',
'master.users.disable2fa.modal.title': 'Tắt xác thực hai yếu tố',
'master.users.disable2fa.modal.warning':
'Bạn có chắc chắn muốn tắt 2FA cho người dùng này không?',
'master.users.disable2fa.modal.caution':
'Cảnh báo: Việc tắt 2FA sẽ làm giảm bảo mật tài khoản. Người dùng sẽ cần thiết lập lại 2FA từ cài đặt hồ sơ của họ.',
};

View File

@@ -1,11 +1,11 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTE_LOGIN } from '@/constants/routes';
import { apiUserResetPassword } from '@/services/master/UserController';
import { parseAccessToken } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import { getTheme } from '@/utils/storage';
import { ProForm, ProFormText } from '@ant-design/pro-components';
import {
FormattedMessage,
@@ -28,9 +28,7 @@ const ResetPassword = () => {
const [tokenValid, setTokenValid] = useState(false);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const [isDark, setIsDark] = useState(getTheme() === 'dark');
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const intl = useIntl();

View File

@@ -0,0 +1,47 @@
import { UserOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const ForgotPasswordForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.validation.email"
defaultMessage="Email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
</>
);
};
export default ForgotPasswordForm;

View File

@@ -0,0 +1,68 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const LoginForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.validation.email"
defaultMessage="Email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
);
};
export default LoginForm;

View File

@@ -0,0 +1,48 @@
import { LockOutlined } from '@ant-design/icons';
import { ProFormText } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
const OtpForm = () => {
const intl = useIntl();
return (
<>
<ProFormText
name="otp"
fieldProps={{
autoComplete: 'one-time-code',
autoFocus: true,
size: 'large',
maxLength: 6,
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.otp.placeholder',
defaultMessage: 'Enter OTP code',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.auth.otp.required"
defaultMessage="OTP code is required"
/>
),
},
{
len: 6,
message: (
<FormattedMessage
id="master.auth.otp.length"
defaultMessage="OTP must be 6 digits"
/>
),
},
]}
/>
</>
);
};
export default OtpForm;

View File

@@ -1,46 +1,65 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { THEME_KEY } from '@/constants';
import { ROUTER_HOME } from '@/constants/routes';
import {
apiForgotPassword,
apiLogin,
apiLogin2FA,
apiQueryProfile,
} from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import { getDomainTitle, getLogoImage } from '@/utils/logo';
import {
clearCredentials,
hasSavedCredentials,
loadCredentials,
saveCredentials,
} from '@/utils/rememberMe';
import {
getBrowserId,
getRefreshToken,
getTheme,
removeAccessToken,
removeRefreshToken,
setAccessToken,
setRefreshToken,
} from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { LoginFormPage } from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
import { Button, ConfigProvider, Flex, Image, message, theme } from 'antd';
import { CSSProperties, useEffect, useState } from 'react';
import {
Button,
Checkbox,
ConfigProvider,
Flex,
Form,
Image,
message,
theme,
} from 'antd';
import { CSSProperties, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png';
type LoginType = 'login' | 'forgot';
import ForgotPasswordForm from './components/ForgotPasswordForm';
import LoginForm from './components/LoginForm';
import OtpForm from './components/OtpForm';
type LoginType = 'login' | 'forgot' | 'otp';
// Form wrapper with animation
const FormWrapper = ({
children,
key,
animationKey,
}: {
children: React.ReactNode;
key: string;
animationKey: string;
}) => {
const style: CSSProperties = {
animation: 'fadeInSlide 0.4s ease-out forwards',
};
return (
<div key={key} style={style}>
<div key={animationKey} style={style}>
<style>{`
@keyframes fadeInSlide {
from {
@@ -59,9 +78,7 @@ const FormWrapper = ({
};
const LoginPage = () => {
const [isDark, setIsDark] = useState(
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') === 'dark',
);
const [isDark, setIsDark] = useState(getTheme() === 'dark');
const { token } = theme.useToken();
const [messageApi, contextHolder] = message.useMessage();
const urlParams = new URL(window.location.href).searchParams;
@@ -69,6 +86,13 @@ const LoginPage = () => {
const intl = useIntl();
const { setInitialState } = useModel('@@initialState');
const [loginType, setLoginType] = useState<LoginType>('login');
const [pending2faToken, setPending2faToken] = useState<string>('');
const [pendingCredentials, setPendingCredentials] = useState<{
email: string;
password: string;
rememberMe: boolean;
} | null>(null);
const formRef = useRef<any>(null);
// Listen for theme changes from ThemeSwitcherAuth
useEffect(() => {
@@ -85,6 +109,7 @@ const LoginPage = () => {
);
};
}, []);
const checkLogin = async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) {
@@ -102,6 +127,7 @@ const LoginPage = () => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme: getTheme() as 'light' | 'dark',
}));
});
}
@@ -117,8 +143,32 @@ const LoginPage = () => {
checkLogin();
}, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
const { email, password } = values;
// Load saved credentials on mount
useEffect(() => {
try {
if (hasSavedCredentials()) {
const saved = loadCredentials();
if (saved && formRef.current) {
// Use setTimeout to ensure form is mounted before setting values
setTimeout(() => {
if (formRef.current) {
formRef.current.setFieldsValue({
email: saved.email,
password: saved.password,
rememberMe: true,
});
}
}, 100);
}
}
} catch (e) {
console.error('Error loading saved credentials:', e);
clearCredentials();
}
}, []);
const handleLogin = async (values: any) => {
const { email, password, rememberMe } = values;
if (loginType === 'login') {
try {
const resp = await apiLogin({
@@ -126,15 +176,31 @@ const LoginPage = () => {
email,
password,
});
// Check if 2FA is enabled
if (resp?.enabled2fa && resp?.token) {
// Save pending credentials and 2FA token and switch to OTP form
setPendingCredentials({ email, password, rememberMe });
setPending2faToken(resp.token);
setLoginType('otp');
return;
}
if (resp?.token) {
// Handle remember me - save or clear credentials
if (rememberMe) {
saveCredentials(email, password);
} else {
clearCredentials();
}
setAccessToken(resp.token);
setRefreshToken(resp.refresh_token);
setRefreshToken(resp.refresh_token || '');
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme: getTheme() as 'light' | 'dark',
}));
});
}
@@ -147,6 +213,52 @@ const LoginPage = () => {
} catch (error) {
console.error('Login error:', error);
}
} else if (loginType === 'otp') {
// Handle OTP verification
try {
const resp = await apiLogin2FA(pending2faToken, {
otp: values.otp || '',
});
if (resp?.token) {
// Handle remember me - save or clear credentials
if (pendingCredentials) {
if (pendingCredentials.rememberMe) {
saveCredentials(
pendingCredentials.email,
pendingCredentials.password,
);
} else {
clearCredentials();
}
}
setAccessToken(resp.token);
setRefreshToken(resp.refresh_token || '');
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
theme: getTheme() as 'light' | 'dark',
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
}
}
} catch (error) {
console.error('2FA verification error:', error);
messageApi.error(
intl.formatMessage({
id: 'master.auth.otp.error',
defaultMessage: 'Invalid OTP code. Please try again.',
}),
);
}
} else {
try {
const host = window.location.origin;
@@ -176,6 +288,31 @@ const LoginPage = () => {
}
};
const getSubmitText = () => {
if (loginType === 'login') {
return intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
});
}
if (loginType === 'otp') {
return intl.formatMessage({
id: 'master.auth.otp.button.title',
defaultMessage: 'Verify OTP',
});
}
return intl.formatMessage({
id: 'master.auth.forgot.button.title',
defaultMessage: 'Send Reset Link',
});
};
const handleBackToLogin = () => {
setPending2faToken('');
setPendingCredentials(null);
setLoginType('login');
};
return (
<ConfigProvider
theme={{
@@ -190,6 +327,7 @@ const LoginPage = () => {
>
{contextHolder}
<LoginFormPage
formRef={formRef}
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
@@ -208,131 +346,36 @@ const LoginPage = () => {
subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{
searchConfig: {
submitText:
loginType === 'login'
? intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
})
: intl.formatMessage({
id: 'master.auth.forgot.button.title',
defaultMessage: 'Đăng nhập',
}),
submitText: getSubmitText(),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
handleLogin(values)
}
onFinish={async (values: any) => handleLogin(values)}
>
<FormWrapper key={loginType}>
{loginType === 'login' && (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: (
<UserOutlined
// style={{
// color: token.colorText,
// }}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: <LockOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
)}
{loginType === 'forgot' && (
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: <UserOutlined className={'prefixIcon'} />,
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
]}
/>
</>
)}
<FormWrapper animationKey={loginType}>
{loginType === 'login' && <LoginForm />}
{loginType === 'forgot' && <ForgotPasswordForm />}
{loginType === 'otp' && <OtpForm />}
</FormWrapper>
<Flex
justify="flex-end"
align="flex-start"
justify="space-between"
align="center"
style={{ marginBlockEnd: 16 }}
>
{loginType === 'login' && (
<Form.Item
name="rememberMe"
valuePropName="checked"
initialValue={false}
style={{ marginBottom: 0 }}
>
<Checkbox>
<FormattedMessage
id="master.auth.rememberMe"
defaultMessage="Ghi nhớ mật khẩu"
/>
</Checkbox>
</Form.Item>
)}
<Button
type="link"
size="small"
@@ -340,7 +383,7 @@ const LoginPage = () => {
if (loginType === 'login') {
setLoginType('forgot');
} else {
setLoginType('login');
handleBackToLogin();
}
}}
>
@@ -377,4 +420,5 @@ const LoginPage = () => {
</ConfigProvider>
);
};
export default LoginPage;

View File

@@ -0,0 +1,259 @@
import IconFont from '@/components/IconFont';
import { apiQueryGroups } from '@/services/master/GroupController';
import { apiQueryLogs } from '@/services/master/LogController';
import { apiSearchThings } from '@/services/master/ThingController';
import { apiQueryUsers } from '@/services/master/UserController';
import { RightOutlined } from '@ant-design/icons';
import { PageContainer, ProCard } from '@ant-design/pro-components';
import { history, useIntl } from '@umijs/max';
import { Button, Col, Divider, Row, Statistic, Typography } from 'antd';
import { useEffect, useState } from 'react';
import CountUp from 'react-countup';
const { Text } = Typography;
const ManagerDashboard = () => {
const intl = useIntl();
const [counts, setCounts] = useState({
devices: 0,
groups: 0,
users: 0,
logs: 0,
});
const [deviceMetadata, setDeviceMetadata] =
useState<MasterModel.ThingsResponseMetadata | null>(null);
const formatter = (value: number | string) => (
<CountUp end={Number(value)} separator="," duration={2} />
);
useEffect(() => {
const fetchData = async () => {
try {
const [devicesRes, groupsRes, usersRes, logsRes] = await Promise.all([
apiSearchThings({ limit: 1 }),
apiQueryGroups({}),
apiQueryUsers({ limit: 1 }),
apiQueryLogs({ limit: 1 }, 'user_logs'),
]);
const devicesTotal = devicesRes?.total || 0;
const metadata = (devicesRes as any)?.metadata || null;
// Group response handling
const groupsTotal =
(Array.isArray(groupsRes)
? groupsRes.length
: (groupsRes as any)?.total) || 0;
const usersTotal = usersRes?.total || 0;
const logsTotal = logsRes?.total || 0;
setCounts({
devices: devicesTotal,
groups: groupsTotal,
users: usersTotal,
logs: logsTotal,
});
setDeviceMetadata(metadata);
} catch (error) {
console.error('Failed to fetch dashboard counts:', error);
}
};
fetchData();
}, []);
return (
<PageContainer>
<ProCard gutter={[16, 16]} ghost>
<ProCard colSpan={{ xs: 24, md: 10 }} ghost gutter={[16, 16]} wrap>
<ProCard
colSpan={24}
title={intl.formatMessage({
id: 'menu.manager.devices',
defaultMessage: 'Thiết bị',
})}
extra={
<Button
type="link"
onClick={() => history.push('/manager/devices')}
icon={<RightOutlined />}
>
Xem chi tiết
</Button>
}
>
<Statistic
value={counts.devices}
formatter={formatter as any}
valueStyle={{ color: '#1890ff' }}
prefix={
<IconFont
type="icon-gateway"
style={{ fontSize: 24, marginRight: 8 }}
/>
}
/>
</ProCard>
<ProCard
colSpan={24}
title={intl.formatMessage({
id: 'menu.manager.groups',
defaultMessage: 'Đơn vị',
})}
extra={
<Button
type="link"
onClick={() => history.push('/manager/groups')}
icon={<RightOutlined />}
>
Xem chi tiết
</Button>
}
>
<Statistic
value={counts.groups}
formatter={formatter as any}
valueStyle={{ color: '#52c41a' }}
prefix={
<IconFont
type="icon-tree"
style={{ fontSize: 24, marginRight: 8 }}
/>
}
/>
</ProCard>
<ProCard
colSpan={24}
title={intl.formatMessage({
id: 'menu.manager.users',
defaultMessage: 'Người dùng',
})}
extra={
<Button
type="link"
onClick={() => history.push('/manager/users')}
icon={<RightOutlined />}
>
Xem chi tiết
</Button>
}
>
<Statistic
value={counts.users}
formatter={formatter as any}
valueStyle={{ color: '#faad14' }}
prefix={
<IconFont
type="icon-users"
style={{ fontSize: 24, marginRight: 8 }}
/>
}
/>
</ProCard>
<ProCard
colSpan={24}
title={intl.formatMessage({
id: 'menu.manager.logs',
defaultMessage: 'Hoạt động',
})}
extra={
<Button
type="link"
onClick={() => history.push('/manager/logs')}
icon={<RightOutlined />}
>
Xem chi tiết
</Button>
}
>
<Statistic
value={counts.logs}
formatter={formatter as any}
valueStyle={{ color: '#f5222d' }}
prefix={
<IconFont
type="icon-diary"
style={{ fontSize: 24, marginRight: 8 }}
/>
}
/>
</ProCard>
</ProCard>
<ProCard
colSpan={{ xs: 24, md: 8 }}
title="Trạng thái thiết bị"
headerBordered
>
{deviceMetadata && (
<Row gutter={[0, 16]}>
<Col span={24}>
<Text type="secondary">Kết nối</Text>
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
<CountUp end={deviceMetadata.total_connected || 0} /> /{' '}
{deviceMetadata.total_thing}
</div>
</Col>
<Divider style={{ margin: 0 }} />
<Col span={24}>
<Text type="secondary">SOS</Text>
<div
style={{
fontSize: 16,
fontWeight: 'bold',
color:
(deviceMetadata.total_sos || 0) > 0
? '#ff4d4f'
: 'inherit',
}}
>
<CountUp end={deviceMetadata.total_sos || 0} />
</div>
</Col>
<Divider style={{ margin: 0 }} />
<Col span={24}>
<Text type="secondary">Bình thường</Text>
<div
style={{
fontSize: 16,
fontWeight: 'bold',
color: '#52c41a',
}}
>
<CountUp end={deviceMetadata.total_state_level_0 || 0} />
</div>
</Col>
<Divider style={{ margin: 0 }} />
<Col span={24}>
<Text type="secondary">Cảnh báo</Text>
<div
style={{
fontSize: 16,
fontWeight: 'bold',
color: '#faad14',
}}
>
<CountUp end={deviceMetadata.total_state_level_1 || 0} />
</div>
</Col>
<Divider style={{ margin: 0 }} />
<Col span={24}>
<Text type="secondary">Nghiêm trọng</Text>
<div
style={{
fontSize: 16,
fontWeight: 'bold',
color: '#f5222d',
}}
>
<CountUp end={deviceMetadata.total_state_level_2 || 0} />
</div>
</Col>
</Row>
)}
</ProCard>
</ProCard>
</PageContainer>
);
};
export default ManagerDashboard;

View File

@@ -1,3 +1,4 @@
import { useIntl } from '@umijs/max';
import {
Button,
Col,
@@ -8,6 +9,7 @@ import {
Row,
Select,
} from 'antd';
import { useEffect } from 'react';
// Camera types
const CAMERA_TYPES = [
@@ -16,35 +18,50 @@ const CAMERA_TYPES = [
{ label: 'GENERIC', value: 'GENERIC' },
];
interface CameraFormValues {
name: string;
type: string;
account: string;
password: string;
ipAddress: string;
rtspPort: number;
httpPort: number;
stream: number;
channel: number;
}
interface CameraFormModalProps {
open: boolean;
onCancel: () => void;
onSubmit: (values: CameraFormValues) => void;
onSubmit: (camera: MasterModel.Camera) => void;
isOnline?: boolean;
editingCamera?: MasterModel.Camera | null;
}
const CameraFormModal: React.FC<CameraFormModalProps> = ({
open,
onCancel,
onSubmit,
isOnline = true,
editingCamera,
}) => {
const [form] = Form.useForm<CameraFormValues>();
const [form] = Form.useForm<MasterModel.Camera>();
const intl = useIntl();
const isEditMode = !!editingCamera;
// Populate form when editing
useEffect(() => {
if (open && editingCamera) {
form.setFieldsValue({
...editingCamera,
cate_id: editingCamera.cate_id || 'HIKVISION',
rtsp_port: editingCamera.rtsp_port || 554,
http_port: editingCamera.http_port || 80,
});
} else if (open) {
form.resetFields();
}
}, [open, editingCamera, form]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
onSubmit(values);
// Values already in MasterModel.Camera format
const camera: MasterModel.Camera = {
...editingCamera,
...values,
// Ensure ID is set for new cameras
id: editingCamera?.id || `cam_${Date.now()}`,
};
onSubmit(camera);
form.resetFields();
} catch (error) {
console.error('Validation failed:', error);
@@ -58,15 +75,28 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
return (
<Modal
title="Tạo mới camera"
title={intl.formatMessage({
id: isEditMode
? 'master.devices.camera.form.title.edit'
: 'master.devices.camera.form.title.add',
defaultMessage: isEditMode ? 'Chỉnh sửa camera' : 'Tạo mới camera',
})}
open={open}
onCancel={handleCancel}
footer={[
<Button key="cancel" onClick={handleCancel}>
Hủy
{intl.formatMessage({
id: 'master.devices.camera.form.cancel',
defaultMessage: 'Hủy',
})}
</Button>,
<Button key="submit" type="primary" onClick={handleSubmit}>
Đng ý
{intl.formatMessage({
id: isEditMode
? 'master.devices.camera.form.update'
: 'master.devices.camera.form.submit',
defaultMessage: isEditMode ? 'Cập nhật' : 'Đồng ý',
})}
</Button>,
]}
width={500}
@@ -75,71 +105,168 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
form={form}
layout="vertical"
initialValues={{
type: 'HIKVISION',
rtspPort: 554,
httpPort: 80,
cate_id: 'HIKVISION',
ip: '192.168.1.10',
rtsp_port: 554,
http_port: 80,
stream: 0,
channel: 0,
}}
>
<Form.Item
label="Tên"
label={intl.formatMessage({
id: 'master.devices.camera.form.name',
defaultMessage: 'Tên',
})}
name="name"
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.name.required',
defaultMessage: 'Vui lòng nhập tên',
}),
},
]}
>
<Input placeholder="nhập dữ liệu" />
<Input
placeholder={intl.formatMessage({
id: 'master.devices.camera.form.name.placeholder',
defaultMessage: 'nhập dữ liệu',
})}
/>
</Form.Item>
<Form.Item
label="Loại"
name="type"
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
label={intl.formatMessage({
id: 'master.devices.camera.form.type',
defaultMessage: 'Loại',
})}
name="cate_id"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.type.required',
defaultMessage: 'Vui lòng chọn loại',
}),
},
]}
>
<Select options={CAMERA_TYPES} />
</Form.Item>
<Form.Item
label="Tài khoản"
name="account"
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
label={intl.formatMessage({
id: 'master.devices.camera.form.username',
defaultMessage: 'Tài khoản',
})}
name="username"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.username.required',
defaultMessage: 'Vui lòng nhập tài khoản',
}),
},
]}
>
<Input placeholder="nhập tài khoản" autoComplete="off" />
<Input
placeholder={intl.formatMessage({
id: 'master.devices.camera.form.username.placeholder',
defaultMessage: 'nhập tài khoản',
})}
autoComplete="off"
/>
</Form.Item>
<Form.Item
label="Mật khẩu"
label={intl.formatMessage({
id: 'master.devices.camera.form.password',
defaultMessage: 'Mật khẩu',
})}
name="password"
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.password.required',
defaultMessage: 'Vui lòng nhập mật khẩu',
}),
},
]}
>
<Input.Password
placeholder="nhập mật khẩu"
placeholder={intl.formatMessage({
id: 'master.devices.camera.form.password.placeholder',
defaultMessage: 'nhập mật khẩu',
})}
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
label="Địa chỉ IP"
name="ipAddress"
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
label={intl.formatMessage({
id: 'master.devices.camera.form.ip',
defaultMessage: ịa chỉ IP',
})}
name="ip"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.ip.required',
defaultMessage: 'Vui lòng nhập địa chỉ IP',
}),
},
]}
>
<Input placeholder="192.168.1.10" />
<Input
placeholder={intl.formatMessage({
id: 'master.devices.camera.form.ip.placeholder',
defaultMessage: '192.168.1.10',
})}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Cổng RTSP"
name="rtspPort"
rules={[{ required: true, message: 'Vui lòng nhập cổng RTSP' }]}
label={intl.formatMessage({
id: 'master.devices.camera.form.rtspPort',
defaultMessage: 'Cổng RTSP',
})}
name="rtsp_port"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.rtspPort.required',
defaultMessage: 'Vui lòng nhập cổng RTSP',
}),
},
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Cổng HTTP"
name="httpPort"
rules={[{ required: true, message: 'Vui lòng nhập cổng HTTP' }]}
label={intl.formatMessage({
id: 'master.devices.camera.form.httpPort',
defaultMessage: 'Cổng HTTP',
})}
name="http_port"
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.httpPort.required',
defaultMessage: 'Vui lòng nhập cổng HTTP',
}),
},
]}
>
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
</Form.Item>
@@ -149,18 +276,40 @@ const CameraFormModal: React.FC<CameraFormModalProps> = ({
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="Luồng"
label={intl.formatMessage({
id: 'master.devices.camera.form.stream',
defaultMessage: 'Luồng',
})}
name="stream"
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.stream.required',
defaultMessage: 'Vui lòng nhập luồng',
}),
},
]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
label="Kênh"
label={intl.formatMessage({
id: 'master.devices.camera.form.channel',
defaultMessage: 'Kênh',
})}
name="channel"
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.camera.form.channel.required',
defaultMessage: 'Vui lòng nhập kênh',
}),
},
]}
>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>

View File

@@ -4,22 +4,32 @@ import {
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { Button, Card, Checkbox, Space, Table, theme } from 'antd';
import { useIntl } from '@umijs/max';
import { Button, Card, Space, Table, theme, Tooltip } from 'antd';
import { useState } from 'react';
interface CameraTableProps {
cameraData: MasterModel.Camera[] | null;
onCreateCamera: () => void;
onEditCamera?: (camera: MasterModel.Camera) => void;
onDeleteCameras?: (cameraIds: string[]) => void;
onReload?: () => void;
loading?: boolean;
isOnline?: boolean;
}
const CameraTable: React.FC<CameraTableProps> = ({
cameraData,
onCreateCamera,
onEditCamera,
onDeleteCameras,
onReload,
loading = false,
isOnline = false,
}) => {
const { token } = theme.useToken();
const intl = useIntl();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const handleReload = () => {
console.log('Reload cameras');
@@ -27,24 +37,23 @@ const CameraTable: React.FC<CameraTableProps> = ({
};
const handleDelete = () => {
console.log('Delete selected cameras');
// TODO: Implement delete functionality
if (selectedRowKeys.length === 0) {
return;
}
onDeleteCameras?.(selectedRowKeys as string[]);
setSelectedRowKeys([]);
};
const handleEdit = (camera: MasterModel.Camera) => {
console.log('Edit camera:', camera);
// TODO: Implement edit functionality
onEditCamera?.(camera);
};
const columns = [
{
title: '',
dataIndex: 'checkbox',
width: 50,
render: () => <Checkbox />,
},
{
title: 'Tên',
title: intl.formatMessage({
id: 'master.devices.camera.table.column.name',
defaultMessage: 'Tên',
}),
dataIndex: 'name',
key: 'name',
render: (text: string) => (
@@ -52,26 +61,47 @@ const CameraTable: React.FC<CameraTableProps> = ({
),
},
{
title: 'Loại',
title: intl.formatMessage({
id: 'master.devices.camera.table.column.type',
defaultMessage: 'Loại',
}),
dataIndex: 'cate_id',
key: 'cate_id',
render: (text: string) => text || '-',
},
{
title: 'Địa chỉ IP',
title: intl.formatMessage({
id: 'master.devices.camera.table.column.ip',
defaultMessage: 'Địa chỉ IP',
}),
dataIndex: 'ip',
key: 'ip',
render: (text: string) => text || '-',
},
{
title: 'Thao tác',
title: intl.formatMessage({
id: 'master.devices.camera.table.column.action',
defaultMessage: 'Thao tác',
}),
key: 'action',
render: (_: any, record: MasterModel.Camera) => (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
/>
<Tooltip
title={
!isOnline
? intl.formatMessage({
id: 'master.devices.camera.table.offline.tooltip',
defaultMessage: 'Thiết bị đang ngoại tuyến',
})
: ''
}
>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
disabled={!isOnline}
/>
</Tooltip>
),
},
];
@@ -79,11 +109,50 @@ const CameraTable: React.FC<CameraTableProps> = ({
return (
<Card bodyStyle={{ padding: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateCamera}>
Tạo mới camera
</Button>
<Button icon={<ReloadOutlined />} onClick={handleReload} />
<Button icon={<DeleteOutlined />} onClick={handleDelete} />
<Tooltip
title={
!isOnline
? intl.formatMessage({
id: 'master.devices.camera.table.offline.tooltip',
defaultMessage: 'Thiết bị đang ngoại tuyến',
})
: ''
}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={onCreateCamera}
disabled={!isOnline}
>
{intl.formatMessage({
id: 'master.devices.camera.table.add',
defaultMessage: 'Tạo mới camera',
})}
</Button>
</Tooltip>
<Button
icon={<ReloadOutlined />}
onClick={handleReload}
loading={loading}
/>
<Tooltip
title={
!isOnline
? intl.formatMessage({
id: 'master.devices.camera.table.offline.tooltip',
defaultMessage: 'Thiết bị đang ngoại tuyến',
})
: ''
}
>
<Button
icon={<DeleteOutlined />}
onClick={handleDelete}
disabled={!isOnline || selectedRowKeys.length === 0}
danger
/>
</Tooltip>
</Space>
<Table
@@ -92,10 +161,26 @@ const CameraTable: React.FC<CameraTableProps> = ({
rowKey="id"
size="small"
loading={loading}
rowSelection={{
type: 'checkbox',
selectedRowKeys,
onChange: (newSelectedRowKeys) =>
setSelectedRowKeys(newSelectedRowKeys),
}}
pagination={{
size: 'small',
showTotal: (total: number, range: [number, number]) =>
`Hiển thị ${range[0]}-${range[1]} của ${total} camera`,
intl.formatMessage(
{
id: 'master.devices.camera.table.pagination',
defaultMessage: 'Hiển thị {0}-{1} của {2} camera',
},
{
0: range[0],
1: range[1],
2: total,
},
),
pageSize: 10,
}}
/>

View File

@@ -1,13 +1,10 @@
import { useIntl } from '@umijs/max';
import { Button, Card, Select, Typography } from 'antd';
import { useState } from 'react';
import { useMemo, useState } from 'react';
const { Text } = Typography;
// Recording modes for V5 - chỉ có không ghi và ghi 24/24
const RECORDING_MODES = [
{ label: 'Không ghi', value: 'none' },
{ label: 'Ghi 24/24', value: '24/7' },
];
interface CameraV5Props {
thing: MasterModel.Thing | null;
@@ -18,9 +15,29 @@ const CameraV5: React.FC<CameraV5Props> = ({
thing,
initialRecordingMode = 'none',
}) => {
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
const intl = useIntl();
console.log('ConfigCameraV5 - thing:', thing);
const RECORDING_MODES = useMemo(
() => [
{
label: intl.formatMessage({
id: 'master.devices.camera.config.recordingMode.none',
defaultMessage: 'Không ghi',
}),
value: 'none',
},
{
label: intl.formatMessage({
id: 'master.devices.camera.config.recordingMode.all',
defaultMessage: '24/24',
}),
value: 'all',
},
],
[intl],
);
const [recordingMode, setRecordingMode] = useState(initialRecordingMode);
const handleSubmit = () => {
console.log('Submit recording mode:', recordingMode);
@@ -30,23 +47,30 @@ const CameraV5: React.FC<CameraV5Props> = ({
return (
<Card bodyStyle={{ padding: 16 }}>
{/* Recording Mode */}
<div style={{ marginBottom: 24 }}>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
Ghi dữ liệu camera
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
style={{ width: 200 }}
/>
</div>
{/* Submit Button */}
<div style={{ textAlign: 'center' }}>
<Button type="primary" onClick={handleSubmit}>
Gửi đi
</Button>
<div className="mb-6">
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
<div className="w-full sm:w-1/3 lg:w-1/4">
<Text strong className="block mb-2">
{intl.formatMessage({
id: 'master.devices.camera.config.recording',
defaultMessage: 'Ghi dữ liệu camera',
})}
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full"
popupMatchSelectWidth={false}
/>
</div>
<Button type="primary" onClick={handleSubmit}>
{intl.formatMessage({
id: 'master.devices.camera.config.send',
defaultMessage: 'Gửi đi',
})}
</Button>
</div>
</div>
</Card>
);

View File

@@ -1,33 +1,70 @@
import { apiQueryConfigAlarm } from '@/services/master/MessageController';
import { useModel } from '@umijs/max';
import { Button, Card, Col, Row, Select, theme, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { useIntl, useModel } from '@umijs/max';
import {
Button,
Card,
Col,
Row,
Select,
theme,
Tooltip,
Typography,
} from 'antd';
import { useEffect, useMemo, useState } from 'react';
const { Text } = Typography;
// Recording modes for V6
const RECORDING_MODES = [
{ label: 'Không ghi', value: 'none' },
{ label: 'Theo cảnh báo', value: 'alarm' },
{ label: '24/24', value: 'all' },
];
interface CameraV6Props {
thing: MasterModel.Thing | null;
cameraConfig?: MasterModel.CameraV6 | null;
onSubmit?: (config: MasterModel.CameraV6) => 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 intl = useIntl();
const { initialState } = useModel('@@initialState');
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
const [recordingMode, setRecordingMode] = useState<'none' | 'alarm' | 'all'>(
'none',
);
const [recordingMode, setRecordingMode] =
useState<MasterModel.CameraRecordType>('none');
const [alarmConfig, setAlarmConfig] = useState<MasterModel.Alarm[] | null>(
null,
);
// Recording modes for V6 - using useMemo for i18n
const RECORDING_MODES = useMemo(
() => [
{
label: intl.formatMessage({
id: 'master.devices.camera.config.recordingMode.none',
defaultMessage: 'Không ghi',
}),
value: 'none',
},
{
label: intl.formatMessage({
id: 'master.devices.camera.config.recordingMode.alarm',
defaultMessage: 'Theo cảnh báo',
}),
value: 'alarm',
},
{
label: intl.formatMessage({
id: 'master.devices.camera.config.recordingMode.all',
defaultMessage: '24/24',
}),
value: 'all',
},
],
[intl],
);
// Initialize states from cameraConfig when it's available
useEffect(() => {
if (cameraConfig) {
@@ -95,31 +132,55 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
setSelectedAlerts([]);
};
const handleSubmitAlerts = () => {
console.log('Submit alerts:', {
recordingMode,
selectedAlerts,
const handleSubmitConfig = () => {
onSubmit?.({
...cameraConfig,
record_type: recordingMode,
record_alarm_list: recordingMode === 'alarm' ? selectedAlerts : [],
});
// TODO: Call API to save alert configuration
};
return (
<Card className="p-4">
{/* Recording Mode */}
<div className="mb-6">
<Text strong className="block mb-2">
Ghi dữ liệu camera
</Text>
<div className="flex gap-8 items-center">
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4"
/>
<Button type="primary" onClick={handleSubmitAlerts}>
Gửi đi
</Button>
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4 items-start sm:items-end">
<div className="w-full sm:w-1/3 lg:w-1/4">
<Text strong className="block mb-2">
{intl.formatMessage({
id: 'master.devices.camera.config.recording',
defaultMessage: 'Ghi dữ liệu camera',
})}
</Text>
<Select
value={recordingMode}
onChange={setRecordingMode}
options={RECORDING_MODES}
className="w-full"
popupMatchSelectWidth={false}
/>
</div>
<Tooltip
title={
!isOnline
? intl.formatMessage({
id: 'master.devices.camera.table.offline.tooltip',
defaultMessage: 'Thiết bị đang ngoại tuyến',
})
: ''
}
>
<Button
type="primary"
onClick={handleSubmitConfig}
disabled={!isOnline}
>
{intl.formatMessage({
id: 'master.devices.camera.config.send',
defaultMessage: 'Gửi đi',
})}
</Button>
</Tooltip>
</div>
</div>
@@ -127,7 +188,10 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
{recordingMode === 'alarm' && (
<div>
<Text strong className="block mb-2">
Danh sách cảnh báo
{intl.formatMessage({
id: 'master.devices.camera.config.alarmList',
defaultMessage: 'Danh sách cảnh báo',
})}
</Text>
<div
@@ -137,9 +201,22 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
borderColor: token.colorBorder,
}}
>
<Text type="secondary">đã chọn {selectedAlerts.length} mục</Text>
<Text type="secondary">
{intl.formatMessage(
{
id: 'master.devices.camera.config.selected',
defaultMessage: 'đã chọn {0} mục',
},
{
0: selectedAlerts.length,
},
)}
</Text>
<Button type="link" onClick={handleClearAlerts}>
Xóa
{intl.formatMessage({
id: 'master.devices.camera.config.clear',
defaultMessage: 'Xóa',
})}
</Button>
</div>
@@ -155,7 +232,7 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
size="small"
hoverable
onClick={() => handleAlertToggle(alarmId)}
className="cursor-pointer h-20 flex items-center justify-center"
className="cursor-pointer h-24 flex items-center justify-center"
style={{
borderColor: isSelected
? token.colorPrimary
@@ -166,14 +243,21 @@ const CameraV6: React.FC<CameraV6Props> = ({ thing, cameraConfig }) => {
: token.colorBgContainer,
}}
>
<div className="p-2 text-center w-full">
<div className="p-1 text-center w-full flex items-center justify-center h-full">
<Text
className="text-xs break-words"
className="text-xs"
style={{
color: isSelected
? token.colorPrimary
: token.colorText,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
lineHeight: '1.2em',
}}
title={alarm.name}
>
{alarm.name}
</Text>

View File

@@ -1,11 +1,12 @@
import { apiQueryCamera } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { wsClient } from '@/utils/wsClient';
import { mqttClient } from '@/utils/mqttClient';
import { ArrowLeftOutlined } from '@ant-design/icons';
import { PageContainer } from '@ant-design/pro-components';
import { history, useModel, useParams } from '@umijs/max';
import { Button, Col, Row, Space, Spin } from 'antd';
import { history, useIntl, useModel, useParams } from '@umijs/max';
import { Badge, Button, Col, message, Row, Space, Spin } from 'antd';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import CameraFormModal from './components/CameraFormModal';
import CameraTable from './components/CameraTable';
import ConfigCameraV5 from './components/ConfigCameraV5';
@@ -13,6 +14,7 @@ import ConfigCameraV6 from './components/ConfigCameraV6';
const CameraConfigPage = () => {
const { thingId } = useParams<{ thingId: string }>();
const intl = useIntl();
const [isModalVisible, setIsModalVisible] = useState(false);
const [loading, setLoading] = useState(true);
const [cameraLoading, setCameraLoading] = useState(false);
@@ -21,18 +23,49 @@ const CameraConfigPage = () => {
const [cameraConfig, setCameraConfig] = useState<MasterModel.CameraV6 | null>(
null,
);
const [isOnline, setIsOnline] = useState(false);
const [editingCamera, setEditingCamera] = useState<MasterModel.Camera | null>(
null,
);
const { initialState } = useModel('@@initialState');
// Initialize MQTT connection
useEffect(() => {
wsClient.connect('/mqtt', false);
const unsubscribe = wsClient.subscribe((data: any) => {
console.log('Received WS data:', data);
const { frontend_thing_id, frontend_thing_key } =
initialState?.currentUserProfile?.metadata || {};
if (!frontend_thing_id || !frontend_thing_key) return;
// Connect using mqttClient utility
mqttClient.connect({
username: frontend_thing_id,
password: frontend_thing_key,
});
const unConnect = mqttClient.onConnect(() => {
// MQTT connected
});
const unError = mqttClient.onError((error) => {
console.error('MQTT Error:', error);
});
const unClose = mqttClient.onClose(() => {
// MQTT connection closed
});
return () => {
unsubscribe();
unConnect();
unError();
unClose();
mqttClient.disconnect();
};
}, []);
}, [initialState]);
// Check device online status using connected property
useEffect(() => {
setIsOnline(thing?.metadata?.connected ?? false);
}, [thing]);
// Fetch thing info on mount
useEffect(() => {
@@ -99,27 +132,148 @@ const CameraConfigPage = () => {
const handleCloseModal = () => {
setIsModalVisible(false);
setEditingCamera(null);
};
const handleSubmitCamera = (values: any) => {
console.log('Camera values:', values);
// TODO: Call API to create camera
// Core function to send camera config via MQTT
const sendCameraConfig = (configPayload: MasterModel.CameraV6) => {
// Skip sending for gmsv5
if (thing?.metadata?.type === 'gmsv5') {
return true;
}
// Check if device is online
if (!isOnline) {
message.error(
intl.formatMessage({
id: 'master.devices.camera.config.error.deviceOffline',
defaultMessage: '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(
intl.formatMessage({
id: 'master.devices.camera.config.error.missingConfig',
defaultMessage: '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(
intl.formatMessage({
id: 'master.devices.camera.config.success',
defaultMessage: 'Đã gửi cấu hình thành công',
}),
);
return true;
} else {
message.error(
intl.formatMessage({
id: 'master.devices.camera.config.error.mqttNotConnected',
defaultMessage: 'MQTT chưa kết nối',
}),
);
return false;
}
};
// Handle camera list config submission (add/edit/delete cameras)
const handleSubmitCameraConfig = (updatedCams: MasterModel.Camera[]) => {
const configPayload: MasterModel.CameraV6 = {
cams: updatedCams,
record_type: cameraConfig?.record_type || 'all',
...(cameraConfig?.record_type === 'alarm' && {
record_alarm_list: cameraConfig?.record_alarm_list || [],
}),
};
if (sendCameraConfig(configPayload)) {
// Update local state
setCameraConfig(configPayload);
setCameras(updatedCams);
}
};
// Handle open edit modal - open modal with camera data for editing
const handleOpenEditModal = (camera: MasterModel.Camera) => {
setEditingCamera(camera);
setIsModalVisible(true);
};
// Handle submit camera (add or edit)
const handleSubmitCamera = (camera: MasterModel.Camera) => {
if (editingCamera) {
// Edit mode: update existing camera
const updatedCams = (cameraConfig?.cams || []).map((cam) =>
cam.id === camera.id ? camera : cam,
);
handleSubmitCameraConfig(updatedCams);
} else {
// Add mode: add new camera
const updatedCams = [...(cameraConfig?.cams || []), camera];
handleSubmitCameraConfig(updatedCams);
}
handleCloseModal();
};
// Handle delete cameras - submit config with cameras removed
const handleDeleteCameras = (cameraIds: string[]) => {
const updatedCams = (cameraConfig?.cams || []).filter(
(cam) => !cameraIds.includes(cam.id || ''),
);
handleSubmitCameraConfig(updatedCams);
};
// Handle recording config submission from ConfigCameraV6
const handleSubmitConfig = (configPayload: MasterModel.CameraV6) => {
if (sendCameraConfig(configPayload)) {
// Update local state
setCameraConfig(configPayload);
}
};
// Helper function to determine which camera component to render
const renderCameraRecordingComponent = () => {
const thingType = thing?.metadata?.type;
if (thingType === 'gmsv5') {
if (thing?.metadata?.type === 'gmsv5') {
return <ConfigCameraV5 thing={thing} />;
}
if (thingType === 'spole' || thingType === 'gmsv6') {
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
}
return <ConfigCameraV6 thing={thing} cameraConfig={cameraConfig} />;
// Default: gmsv6, spole, and other types
return (
<ConfigCameraV6
thing={thing}
cameraConfig={cameraConfig}
onSubmit={handleSubmitConfig}
isOnline={isOnline}
/>
);
};
return (
@@ -133,7 +287,27 @@ const CameraConfigPage = () => {
icon={<ArrowLeftOutlined />}
onClick={() => history.push('/manager/devices')}
/>
<span>{thing?.name || 'Loading...'}</span>
<span>
{thing?.name ||
intl.formatMessage({
id: 'master.devices.camera.loading',
defaultMessage: 'Loading...',
})}
</span>
<Badge
status={isOnline ? 'success' : 'default'}
text={
isOnline
? intl.formatMessage({
id: 'master.devices.online',
defaultMessage: 'Trực tuyến',
})
: intl.formatMessage({
id: 'master.devices.offline',
defaultMessage: 'Ngoại tuyến',
})
}
/>
</Space>
),
}}
@@ -144,8 +318,11 @@ const CameraConfigPage = () => {
<CameraTable
cameraData={cameras}
onCreateCamera={handleOpenModal}
onEditCamera={handleOpenEditModal}
onDeleteCameras={handleDeleteCameras}
onReload={fetchCameraConfig}
loading={cameraLoading}
isOnline={isOnline}
/>
</Col>
@@ -155,11 +332,13 @@ const CameraConfigPage = () => {
</Col>
</Row>
{/* Create Camera Modal */}
{/* Create/Edit Camera Modal */}
<CameraFormModal
open={isModalVisible}
onCancel={handleCloseModal}
onSubmit={handleSubmitCamera}
isOnline={isOnline}
editingCamera={editingCamera}
/>
</PageContainer>
</Spin>

View File

@@ -1,14 +1,6 @@
import IconFont from '@/components/IconFont';
import { StatisticCard } from '@ant-design/pro-components';
import {
Divider,
Flex,
GlobalToken,
Grid,
theme,
Tooltip,
Typography,
} from 'antd';
import { Flex, GlobalToken, Grid, theme, Tooltip, Typography } from 'antd';
const { Text } = Typography;
type BinarySensorsProps = {
nodeConfigs: MasterModel.NodeConfig[];
@@ -148,7 +140,6 @@ const BinarySensors = ({ nodeConfigs }: BinarySensorsProps) => {
return (
<Flex wrap="wrap" gap="middle">
<Divider orientation="left">Cảm biến</Divider>
{binarySensors.map((entity) => (
<div
key={entity.entityId}

View File

@@ -0,0 +1,610 @@
import '@/utils/chart';
import type { ChartData, ChartOptions } from 'chart.js';
import { Line } from 'react-chartjs-2';
type ChartComponentProps = {
message: MasterModel.SensorLogMessage[];
};
const exampleSensorLogMessages: MasterModel.SensorLogMessage[] = [
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770260026,
value: 76.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770259725,
value: 77.1,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770259424,
value: 75.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770259242,
value: 72.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770259142,
value: 69.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258926,
value: 66.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258877,
value: 69.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258839,
value: 73,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258678,
value: 76.1,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258377,
value: 77.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770258077,
value: 77.8,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257776,
value: 75.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257596,
value: 72.5,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257487,
value: 69.5,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257412,
value: 66.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257349,
value: 63.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770257117,
value: 60.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256816,
value: 62.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256625,
value: 65.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256563,
value: 68.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256529,
value: 71.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256363,
value: 74.7,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770256062,
value: 75.2,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255761,
value: 74.3,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255590,
value: 71.2,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255498,
value: 68.1,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255436,
value: 64.9,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255331,
value: 61.8,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255111,
value: 64.8,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255038,
value: 67.9,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770255002,
value: 71.1,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770254968,
value: 74.2,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770254688,
value: 77.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770254387,
value: 78.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770254086,
value: 77.9,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253786,
value: 75,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253631,
value: 72,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253549,
value: 68.9,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253481,
value: 65.8,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253434,
value: 62.7,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253364,
value: 59.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770253063,
value: 60.4,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770252763,
value: 62.5,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770252695,
value: 65.5,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770252657,
value: 68.6,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770252489,
value: 71.7,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770252188,
value: 71.2,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770251886,
value: 68.7,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770251585,
value: 65.9,
},
{
channel: 'c2552f8e-6e2f-4703-bdc2-681d2a0646d9',
subtopic: 'log.gmsv5.hum.0:20',
publisher: 'f4bd927c-f578-420d-9b63-ef7fb053d901',
protocol: 'mqtt',
name: 'urn:dev:mac:0038310555e4:0:20',
unit: '%',
time: 1770251469,
value: 68.9,
},
];
// Custom plugin để vẽ đường kẻ dọc khi hover
const verticalLinePlugin = {
id: 'verticalLine',
afterDraw: (chart: any) => {
if (chart.tooltip?._active?.length) {
const ctx = chart.ctx;
const x = chart.tooltip._active[0].element.x;
const topY = chart.scales.y.top;
const bottomY = chart.scales.y.bottom;
ctx.save();
ctx.beginPath();
ctx.moveTo(x, topY);
ctx.lineTo(x, bottomY);
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.stroke();
ctx.restore();
}
},
};
const ChartComponent = () => {
const data: ChartData<'line', number[], string> = {
labels: exampleSensorLogMessages.map((msg) =>
new Date(msg.time! * 1000).toLocaleTimeString(),
),
datasets: [
{
label: exampleSensorLogMessages[0]?.unit || '',
data: exampleSensorLogMessages.map((msg) => msg.value),
borderColor: 'rgba(75, 192, 192, 0.2)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
tension: 0.1,
fill: 'start',
pointRadius: 0,
},
],
};
const options: ChartOptions<'line'> = {
interaction: {
mode: 'index',
intersect: true,
},
scales: {
x: {
ticks: {
display: false,
},
grid: {
display: false,
},
},
y: {
display: false,
min: 0,
max: 100,
},
},
plugins: {
tooltip: {
enabled: true,
mode: 'index',
intersect: false,
callbacks: {
label: (context) => {
return `${context.parsed.y} ${
exampleSensorLogMessages[0]?.unit || ''
}`;
},
title: (context) => {
const index = context[0]?.dataIndex;
if (index !== undefined) {
const msg = exampleSensorLogMessages[index];
return new Date(msg.time! * 1000).toLocaleString();
}
return '';
},
},
},
legend: {
display: false,
},
},
};
return (
<Line
title="Nooo"
data={data}
options={options}
plugins={[verticalLinePlugin]}
/>
);
};
export default ChartComponent;

View File

@@ -3,11 +3,17 @@ import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import { ROUTER_HOME } from '@/constants/routes';
import { apiQueryNodeConfigMessage } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import {
EditOutlined,
EllipsisOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { PageContainer, ProCard } from '@ant-design/pro-components';
import { history, useIntl, useModel, useParams } from '@umijs/max';
import { Divider, Flex, Grid } from 'antd';
import { useEffect, useState } from 'react';
import BinarySensors from './components/BinarySensors';
import ChartComponent from './components/Chart';
import ThingTitle from './components/ThingTitle';
const DetailDevicePage = () => {
@@ -109,8 +115,21 @@ const DetailDevicePage = () => {
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 18, xl: 18 }}>
<Flex wrap gap="small">
<Divider orientation="left">Cảm biến</Divider>
<BinarySensors nodeConfigs={nodeConfigs} />
<Divider orientation="left">Trạng thái</Divider>
<ProCard
title="Mẫu 1"
bordered
style={{ maxWidth: 600 }}
actions={[
<SettingOutlined key="setting" />,
<EditOutlined key="edit" />,
<EllipsisOutlined key="ellipsis" />,
]}
>
<ChartComponent />
</ProCard>
</Flex>
</ProCard>
</ProCard>

View File

@@ -0,0 +1,142 @@
import type { CSSProperties } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
export interface XTermHandle {
write: (text: string) => void;
clear: () => void;
}
export interface XTermProps {
onData?: (data: string) => void;
cursorBlink?: boolean;
className?: string;
style?: CSSProperties;
disabled?: boolean;
welcomeMessage?: string;
theme?: {
background?: string;
foreground?: string;
cursor?: string;
selectionBackground?: string;
};
}
const DEFAULT_WELCOME = 'Welcome to Smatec IoT Agent';
const XTerm = forwardRef<XTermHandle, XTermProps>(function XTerm(
{
onData,
cursorBlink = false,
className,
style,
disabled = false,
welcomeMessage = DEFAULT_WELCOME,
theme,
},
ref,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const onDataRef = useRef(onData);
const disabledRef = useRef(disabled);
useImperativeHandle(
ref,
() => ({
write: (text: string) => {
if (terminalRef.current && text) {
terminalRef.current.write(text);
}
},
clear: () => {
terminalRef.current?.clear();
},
}),
[],
);
useEffect(() => {
onDataRef.current = onData;
}, [onData]);
useEffect(() => {
disabledRef.current = disabled;
// Update terminal's disableStdin option when disabled prop changes
if (terminalRef.current) {
terminalRef.current.options.disableStdin = disabled;
}
}, [disabled]);
useEffect(() => {
const terminal = new Terminal({
cursorBlink,
scrollback: 1000,
convertEol: true,
fontSize: 14,
disableStdin: disabled,
allowProposedApi: true,
theme,
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
if (containerRef.current) {
terminal.open(containerRef.current);
fitAddon.fit();
}
if (welcomeMessage) {
terminal.writeln(welcomeMessage);
}
const dataDisposable = terminal.onData((data) => {
if (!disabledRef.current) {
onDataRef.current?.(data);
}
});
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
const observer =
typeof ResizeObserver !== 'undefined' && containerRef.current
? new ResizeObserver(() => {
fitAddon.fit();
})
: null;
if (observer && containerRef.current) {
observer.observe(containerRef.current);
}
return () => {
dataDisposable.dispose();
observer?.disconnect();
terminal.dispose();
fitAddon.dispose();
terminalRef.current = null;
fitAddonRef.current = null;
};
// We intentionally want to run this once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (terminalRef.current && theme) {
terminalRef.current.options.theme = theme;
}
}, [theme]);
return (
<div
className={className}
style={{ height: '100%', ...style }}
ref={containerRef}
/>
);
});
export default XTerm;

View File

@@ -0,0 +1,663 @@
// Component Terminal - Giao diện điều khiển thiết bị từ xa qua MQTT
// Không hỗ trợ thiết bị loại GMSv5
import { getBadgeConnection } from '@/components/shared/ThingShared';
import { ROUTE_MANAGER_DEVICES } from '@/constants/routes';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { mqttClient } from '@/utils/mqttClient';
import { getTerminalTheme, setTerminalTheme } from '@/utils/storage';
import { BgColorsOutlined, ClearOutlined } from '@ant-design/icons';
import { PageContainer, ProCard } from '@ant-design/pro-components';
import { history, useIntl, useModel, useParams } from '@umijs/max';
import {
Alert,
Button,
Dropdown,
MenuProps,
Result,
Space,
Spin,
Typography,
theme,
} from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import XTerm, { XTermHandle } from './components/XTerm';
// Format SenML record nhận từ MQTT
const TERMINAL_THEMES: MasterModel.TerminalThemes = {
dark: {
name: 'Dark',
theme: {
background: '#141414',
foreground: '#ffffff',
cursor: '#ffffff',
selectionBackground: 'rgba(255, 255, 255, 0.3)',
},
},
light: {
name: 'Light',
theme: {
background: '#ffffff',
foreground: '#000000',
cursor: '#000000',
selectionBackground: 'rgba(0, 0, 0, 0.3)',
},
},
green: {
name: 'Green (Classic)',
theme: {
background: '#000000',
foreground: '#00ff00',
cursor: '#00ff00',
selectionBackground: 'rgba(0, 255, 0, 0.3)',
},
},
amber: {
name: 'Amber',
theme: {
background: '#1a0f00',
foreground: '#ffaa00',
cursor: '#ffaa00',
selectionBackground: 'rgba(255, 170, 0, 0.3)',
},
},
solarized: {
name: 'Solarized Dark',
theme: {
background: '#002b36',
foreground: '#839496',
cursor: '#93a1a1',
selectionBackground: 'rgba(7, 54, 66, 0.99)',
},
},
};
/**
* Encode chuỗi sang Base64 để gửi qua MQTT
* Hỗ trợ Unicode characters qua UTF-8 normalization
* Áp dụng cho tất cả thiết bị (ngoại trừ GMSv5)
*/
const encodeBase64 = (value: string) => {
try {
const normalized = encodeURIComponent(value).replace(
/%([0-9A-F]{2})/g,
(_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)),
);
if (typeof globalThis !== 'undefined' && globalThis.btoa) {
return globalThis.btoa(normalized);
}
} catch (error) {
console.error('Failed to encode terminal payload', error);
}
return value;
};
/**
* Parse payload nhận từ MQTT (format SenML)
* Trích xuất các field 'vs' và join thành string hoàn chỉnh
*/
const parseTerminalPayload = (payload: string): string => {
try {
const records: MasterModel.SenmlRecord[] = JSON.parse(payload);
if (!Array.isArray(records)) {
return '';
}
return records
.map((record) => record?.vs ?? '')
.filter(Boolean)
.join('');
} catch (error) {
console.error('Failed to parse terminal payload', error);
return '';
}
};
const DeviceTerminalPage = () => {
// Lấy thingId từ URL params
const { thingId } = useParams<{ thingId: string }>();
const { initialState } = useModel('@@initialState');
const { token } = theme.useToken();
const intl = useIntl();
// States quản lý trạng thái thiết bị và terminal
const [thing, setThing] = useState<MasterModel.Thing | null>(null); // Thông tin thiết bị
const [isLoading, setIsLoading] = useState<boolean>(false); // Đang load dữ liệu
const [terminalError, setTerminalError] = useState<string | null>(null); // Lỗi terminal
const [isSessionReady, setIsSessionReady] = useState<boolean>(false); // Session đã sẵn sàng
const [connectionState, setConnectionState] =
useState<MasterModel.TerminalConnection>({
online: false,
}); // Trạng thái online/offline
const [sessionAttempt, setSessionAttempt] = useState<number>(0); // Số lần thử kết nối lại
const [selectedThemeKey, setSelectedThemeKey] = useState<string>('dark'); // Theme được chọn
// Refs lưu trữ các giá trị không trigger re-render
const sessionIdRef = useRef(uuidv4()); // ID phiên terminal duy nhất
const terminalRef = useRef<XTermHandle | null>(null); // Reference đến XTerm component
const responseTopicRef = useRef<string | null>(null); // Topic MQTT nhận phản hồi
const requestTopicRef = useRef<string | null>(null); // Topic MQTT gửi yêu cầu
const handshakeCompletedRef = useRef<boolean>(false); // Đã hoàn thành handshake chưa
const mqttCleanupRef = useRef<Array<() => void>>([]); // Mảng cleanup functions
const credentialMetadata = initialState?.currentUserProfile?.metadata;
/**
* Lấy MQTT credentials từ metadata user
* Username: frontend_thing_id
* Password: frontend_thing_key
*/
const mqttCredentials = useMemo(() => {
const username = credentialMetadata?.frontend_thing_id;
const password = credentialMetadata?.frontend_thing_key;
if (username && password) {
return {
username,
password,
};
}
return null;
}, [credentialMetadata]);
// Kiểm tra thiết bị có hỗ trợ terminal không (không hỗ trợ GMSv5)
const supportsTerminal = thing?.metadata?.type !== 'gmsv5';
// Channel ID để gửi lệnh điều khiển
const ctrlChannelId = thing?.metadata?.ctrl_channel_id;
/**
* Dọn dẹp MQTT connection khi unmount hoặc reconnect
- Hủy subscribe topic
- Reset các ref
*/
const tearDownMqtt = useCallback(() => {
mqttCleanupRef.current.forEach((dispose) => {
try {
dispose();
} catch {
// ignore individual cleanup errors
}
});
mqttCleanupRef.current = [];
if (responseTopicRef.current) {
mqttClient.unsubscribe(responseTopicRef.current);
responseTopicRef.current = null;
}
requestTopicRef.current = null;
handshakeCompletedRef.current = false;
}, []);
/**
* Fetch thông tin chi tiết thiết bị từ API
- Cập nhật state thing
- Cập nhật trạng thái online/offline
- Xóa lỗi nếu fetch thành công
*/
const fetchThingDetail = useCallback(async () => {
if (!thingId) {
return;
}
setIsLoading(true);
try {
const resp = await apiGetThingDetail(thingId);
setThing(resp);
const isConnected = Boolean(resp?.metadata?.connected);
setConnectionState({
online: isConnected,
lastSeen: isConnected ? undefined : Date.now(),
});
setTerminalError(null);
} catch (error) {
console.error('Failed to load device details', error);
setTerminalError(
intl.formatMessage({
id: 'master.devices.terminal.loadDeviceError',
defaultMessage: 'Không thể tải thông tin thiết bị.',
}),
);
} finally {
setIsLoading(false);
}
}, [intl, thingId]);
// Fetch thông tin thiết bị khi component mount
useEffect(() => {
fetchThingDetail();
}, [fetchThingDetail]);
// Tạo sessionId mới khi thingId thay đổi
useEffect(() => {
sessionIdRef.current = uuidv4();
}, [thingId]);
// Khôi phục theme từ localStorage khi component mount
useEffect(() => {
const savedTheme = getTerminalTheme();
if (savedTheme && TERMINAL_THEMES[savedTheme]) {
setSelectedThemeKey(savedTheme);
}
}, []);
/**
* Gửi lệnh tới thiết bị qua MQTT
* Format SenML: bn(base name), n(name), bt(base time), vs(value string)
*/
const publishTerminalCommand = useCallback((command: string) => {
const topic = requestTopicRef.current;
if (!topic || !command) {
return;
}
const payload: MasterModel.MqttTerminalPayload = [
{
bn: `${sessionIdRef.current}:`,
n: 'term',
bt: Math.floor(Date.now() / 1000),
vs: encodeBase64(command),
},
];
mqttClient.publish(topic, JSON.stringify(payload));
}, []);
/**
* Setup MQTT connection và handlers
- Tạo topics để giao tiếp với thiết bị
- Đăng ký handlers cho các sự kiện MQTT
- Thực hiện handshake khi kết nối thành công
*/
useEffect(() => {
if (!supportsTerminal || !mqttCredentials || !ctrlChannelId) {
return;
}
tearDownMqtt();
// Tạo topics: response topic duy nhất cho mỗi session, request topic chung
const responseTopic = `channels/${ctrlChannelId}/messages/res/term/${sessionIdRef.current}`;
const requestTopic = `channels/${ctrlChannelId}/messages/req`;
responseTopicRef.current = responseTopic;
requestTopicRef.current = requestTopic;
handshakeCompletedRef.current = false;
/**
* Handler khi MQTT kết nối thành công
- Subscribe response topic
- Gửi lệnh handshake: 'open' và 'cd /'
*/
const handleConnect = () => {
setTerminalError(null);
if (!handshakeCompletedRef.current) {
mqttClient.subscribe(responseTopic);
publishTerminalCommand('open');
publishTerminalCommand('cd /');
handshakeCompletedRef.current = true;
}
setIsSessionReady(true);
};
/**
* Handler nhận message từ thiết bị
- Parse payload SenML
- Ghi dữ liệu ra terminal
*/
const handleMessage = (topic: string, message: Buffer) => {
if (topic !== responseTopic) {
return;
}
const text = parseTerminalPayload(message.toString());
if (text) {
terminalRef.current?.write(text);
}
};
/**
* Handler khi MQTT bị ngắt kết nối
*/
const handleClose = () => {
setIsSessionReady(false);
};
/**
* Handler khi có lỗi MQTT
- Dọn dẹp connection
- Hiển thị thông báo lỗi
*/
const handleError = (error: Error) => {
console.error('MQTT terminal error', error);
setIsSessionReady(false);
tearDownMqtt();
mqttClient.disconnect();
setTerminalError(
intl.formatMessage({
id: 'master.devices.terminal.mqttError',
defaultMessage: 'Không thể kết nối MQTT.',
}),
);
};
// Kết nối MQTT và đăng ký handlers
mqttClient.connect(mqttCredentials);
const offConnect = mqttClient.onConnect(handleConnect);
const offMessage = mqttClient.onMessage(handleMessage);
const offClose = mqttClient.onClose(handleClose);
const offError = mqttClient.onError(handleError);
mqttCleanupRef.current = [offConnect, offMessage, offClose, offError];
// Nếu đã kết nối thì xử lý luôn
if (mqttClient.isConnected()) {
handleConnect();
}
// Cleanup khi unmount hoặc dependencies thay đổi
return () => {
tearDownMqtt();
mqttClient.disconnect();
setIsSessionReady(false);
};
}, [
ctrlChannelId,
intl,
mqttCredentials,
publishTerminalCommand,
sessionAttempt,
supportsTerminal,
tearDownMqtt,
]);
/**
* Xử lý khi người dùng nhập liệu vào terminal
- Gửi từng ký tự với prefix 'c,'
- Chỉ gửi khi session đã sẵn sàng
*/
const handleTyping = useCallback(
(data: string) => {
if (!isSessionReady) {
return;
}
publishTerminalCommand(`c,${data}`);
},
[isSessionReady, publishTerminalCommand],
);
/**
* Xử lý retry khi có lỗi
- Tạo sessionId mới
- Tăng sessionAttempt để trigger reconnect
- Fetch lại thông tin thiết bị
*/
const handleRetry = () => {
setTerminalError(null);
setSessionAttempt((prev) => prev + 1);
sessionIdRef.current = uuidv4();
fetchThingDetail();
};
// Label hiển thị trạng thái kết nối
const connectionLabel = connectionState.online
? intl.formatMessage({ id: 'common.online', defaultMessage: 'Online' })
: intl.formatMessage({ id: 'common.offline', defaultMessage: 'Offline' });
/**
* Render kết quả blocking (lỗi, warning, info)
- Hiển thị nút quay lại và thử lại
*/
const renderBlockingResult = (
status: 'info' | 'warning' | 'error' | '404',
title: string,
subTitle?: string,
) => (
<Result
status={status}
title={title}
subTitle={subTitle}
extra={[
<Button key="back" onClick={() => history.push(ROUTE_MANAGER_DEVICES)}>
{intl.formatMessage({
id: 'common.back',
defaultMessage: 'Quay lại',
})}
</Button>,
<Button key="retry" type="primary" onClick={handleRetry}>
{intl.formatMessage({
id: 'common.retry',
defaultMessage: 'Thử lại',
})}
</Button>,
]}
/>
);
// Tiêu đề trang
const pageTitle =
thing?.name ||
intl.formatMessage({
id: 'master.devices.terminal.pageTitle',
defaultMessage: 'Terminal',
});
/**
* Điều kiện hiển thị terminal
- Tất cả điều kiện phải true
*/
const showTerminal =
!isLoading &&
!terminalError &&
thing &&
supportsTerminal &&
ctrlChannelId &&
mqttCredentials;
/**
* Theme của terminal theo dark/light mode
*/
const terminalTheme = useMemo(() => {
return (
TERMINAL_THEMES[selectedThemeKey]?.theme || TERMINAL_THEMES.dark.theme
);
}, [selectedThemeKey]);
/**
* Xử lý thay đổi theme
* - Cập nhật state
* - Lưu vào localStorage thông qua storage utility
*/
const handleThemeChange: MenuProps['onClick'] = (e) => {
const themeKey = e.key;
setSelectedThemeKey(themeKey);
setTerminalTheme(themeKey);
};
/**
* Menu items cho theme selector
*/
const themeMenuItems: MenuProps['items'] = Object.entries(
TERMINAL_THEMES,
).map(([key, preset]) => ({
key,
label: preset.name,
}));
// ===== RENDER =====
return (
<PageContainer
header={{
title: pageTitle,
onBack: () => history.push(ROUTE_MANAGER_DEVICES),
tags: (
<Space size={8}>
{getBadgeConnection(connectionState.online)}
<Typography.Text>{connectionLabel}</Typography.Text>
</Space>
),
}}
>
{/* Loading state */}
{isLoading && (
<div
style={{
minHeight: 320,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Spin size="large" />
</div>
)}
{/* Generic error state */}
{!isLoading && terminalError && (
<Result
status="error"
title={intl.formatMessage({
id: 'master.devices.terminal.genericError',
defaultMessage: 'Đã có lỗi xảy ra',
})}
subTitle={terminalError}
extra={[
<Button key="retry" type="primary" onClick={handleRetry}>
{intl.formatMessage({
id: 'common.retry',
defaultMessage: 'Thử lại',
})}
</Button>,
]}
/>
)}
{/* Thiết bị không hỗ trợ terminal (không phải GMSv6) */}
{!isLoading &&
!terminalError &&
thing &&
!supportsTerminal &&
renderBlockingResult(
'info',
intl.formatMessage({
id: 'master.devices.terminal.unsupported.title',
defaultMessage: 'Thiết bị không hỗ trợ terminal',
}),
intl.formatMessage({
id: 'master.devices.terminal.unsupported.desc',
defaultMessage:
'Thiết bị GMSv5 không được hỗ trợ. Vui lòng sử dụng thiết bị khác.',
}),
)}
{/* Thiếu ctrl_channel_id */}
{!isLoading &&
!terminalError &&
thing &&
supportsTerminal &&
!ctrlChannelId &&
renderBlockingResult(
'warning',
intl.formatMessage({
id: 'master.devices.terminal.missingChannel.title',
defaultMessage: 'Thiếu thông tin kênh điều khiển',
}),
intl.formatMessage({
id: 'master.devices.terminal.missingChannel.desc',
defaultMessage:
'Thiết bị chưa được cấu hình ctrl_channel_id nên không thể mở terminal.',
}),
)}
{/* Thiếu MQTT credentials */}
{!isLoading &&
!terminalError &&
thing &&
supportsTerminal &&
ctrlChannelId &&
!mqttCredentials &&
renderBlockingResult(
'warning',
intl.formatMessage({
id: 'master.devices.terminal.missingCredential.title',
defaultMessage: 'Thiếu thông tin xác thực',
}),
intl.formatMessage({
id: 'master.devices.terminal.missingCredential.desc',
defaultMessage:
'Tài khoản hiện tại chưa được cấp frontend_thing_id/frontend_thing_key.',
}),
)}
{/* Terminal UI - chỉ hiển thị khi tất cả điều kiện thỏa mãn */}
{showTerminal && (
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* Cảnh báo khi thiết bị offline */}
{!connectionState.online && (
<Alert
type="warning"
showIcon
message={intl.formatMessage({
id: 'master.devices.terminal.offline',
defaultMessage:
'Thiết bị đang ngoại tuyến. Terminal chuyển sang chế độ chỉ xem.',
})}
/>
)}
{/* Đang kết nối MQTT */}
{connectionState.online && !isSessionReady && (
<Alert
type="info"
showIcon
message={intl.formatMessage({
id: 'master.devices.terminal.connecting',
defaultMessage:
'Đang chuẩn bị phiên master.devices.terminal...',
})}
/>
)}
{/* XTerm component */}
<ProCard
bordered
bodyStyle={{ padding: 0, height: 560 }}
style={{
minHeight: 560,
border: `2px solid ${token.colorBorder}`,
backgroundColor: '#000',
}}
>
<XTerm
ref={terminalRef}
cursorBlink
disabled={!connectionState.online || !isSessionReady}
onData={handleTyping}
theme={terminalTheme}
/>
</ProCard>
{/* Nút xóa màn hình */}
<Space>
<Button
icon={<ClearOutlined />}
onClick={() => terminalRef.current?.clear()}
disabled={!isSessionReady}
>
{intl.formatMessage({
id: 'master.devices.terminal.action.clear',
defaultMessage: 'Xóa màn hình',
})}
</Button>
<Dropdown
menu={{
items: themeMenuItems,
onClick: handleThemeChange,
selectedKeys: [selectedThemeKey],
}}
placement="topLeft"
>
<Button icon={<BgColorsOutlined />}>
{intl.formatMessage({
id: 'master.devices.terminal.action.theme',
defaultMessage: 'Theme',
})}
: {TERMINAL_THEMES[selectedThemeKey]?.name || 'Dark'}
</Button>
</Dropdown>
</Space>
</Space>
)}
</PageContainer>
);
};
export default DeviceTerminalPage;

View File

@@ -13,7 +13,7 @@ import {
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
import { Button, Divider, Grid, Space, Tag, Tooltip, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
@@ -165,7 +165,6 @@ const ManagerDevicePage = () => {
'-'
),
},
{
key: 'type',
hideInSearch: true,
@@ -232,11 +231,21 @@ const ManagerDevicePage = () => {
}}
/>
{device?.metadata?.type === 'gmsv6' && (
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
/>
<Tooltip
title={intl.formatMessage({
id: 'master.devices.openTerminal',
defaultMessage: 'Open terminal',
})}
>
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
onClick={() =>
history.push(`/manager/devices/${device.id}/terminal`)
}
/>
</Tooltip>
)}
</Space>
);

View File

@@ -0,0 +1,136 @@
import IconFont from '@/components/IconFont';
import { apiAdminDisable2FA } from '@/services/master/AuthController';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Modal, Tooltip } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useState } from 'react';
interface Disable2FAProps {
user: MasterModel.UserResponse;
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
}
const Disable2FA = ({ user, message, onSuccess }: Disable2FAProps) => {
const intl = useIntl();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Only show if user has 2FA enabled
if (!user.enable_2fa) {
return null;
}
const handleConfirmDisable = async () => {
if (!user.id) return;
setIsLoading(true);
try {
await apiAdminDisable2FA(user.id);
message.success(
intl.formatMessage({
id: 'master.users.disable2fa.success',
defaultMessage: '2FA has been disabled successfully',
}),
);
setIsModalOpen(false);
onSuccess?.(true);
} catch (error) {
console.error('Disable 2FA error:', error);
message.error(
intl.formatMessage({
id: 'master.users.disable2fa.error',
defaultMessage: 'Failed to disable 2FA',
}),
);
onSuccess?.(false);
} finally {
setIsLoading(false);
}
};
return (
<>
<Tooltip
title={intl.formatMessage({
id: 'master.users.disable2fa.title',
defaultMessage: 'Disable 2FA',
})}
>
<Button
shape="default"
size="small"
danger
icon={<IconFont type="icon-security" />}
onClick={() => setIsModalOpen(true)}
style={{ borderColor: '#ff4d4f', color: '#ff4d4f' }}
/>
</Tooltip>
<Modal
title={
<span style={{ color: '#ff4d4f' }}>
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
{intl.formatMessage({
id: 'master.users.disable2fa.modal.title',
defaultMessage: 'Disable Two-Factor Authentication',
})}
</span>
}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={[
<Button key="cancel" onClick={() => setIsModalOpen(false)}>
{intl.formatMessage({
id: 'common.cancel',
defaultMessage: 'Cancel',
})}
</Button>,
<Button
key="confirm"
type="primary"
danger
loading={isLoading}
onClick={handleConfirmDisable}
>
{intl.formatMessage({
id: 'common.confirm',
defaultMessage: 'Confirm',
})}
</Button>,
]}
>
<div style={{ marginBottom: 16 }}>
<p>
{intl.formatMessage({
id: 'master.users.disable2fa.modal.warning',
defaultMessage:
'Are you sure you want to disable 2FA for this user?',
})}
</p>
<p style={{ fontWeight: 'bold' }}>{user.email}</p>
</div>
<div
style={{
backgroundColor: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 6,
padding: 12,
}}
>
<p style={{ color: '#ff4d4f', margin: 0 }}>
<ExclamationCircleOutlined style={{ marginRight: 8 }} />
{intl.formatMessage({
id: 'master.users.disable2fa.modal.caution',
defaultMessage:
'Warning: Disabling 2FA will reduce account security. The user will need to re-enable 2FA from their profile settings.',
})}
</p>
</div>
</Modal>
</>
);
};
export default Disable2FA;

View File

@@ -24,6 +24,7 @@ import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser';
import Disable2FA from './components/Disable2FA';
import ResetPassword from './components/ResetPassword';
type ResetUserPaswordProps = {
user: MasterModel.UserResponse | null;
@@ -158,6 +159,13 @@ const ManagerUserPage = () => {
})}
onClick={() => handleClickResetPassword(user)}
/>
<Disable2FA
user={user}
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) actionRef.current?.reload();
}}
/>
</Space>
);
},

View File

@@ -1,5 +1,257 @@
import {
apiDisable2FA,
apiEnable2FA,
apiVerify2FA,
} from '@/services/master/AuthController';
import { useIntl, useModel } from '@umijs/max';
import {
Card,
Input,
message,
Modal,
QRCode,
Space,
Switch,
Typography,
} from 'antd';
import { useState } from 'react';
const { Text, Title } = Typography;
const TwoFactorAuthentication = () => {
return <div>TwoFactorAuthentication</div>;
const intl = useIntl();
const { initialState, refresh } = useModel('@@initialState');
const [modal, contextHolder] = Modal.useModal();
const profile = initialState?.currentUserProfile;
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [qrData, setQrData] = useState<MasterModel.Enable2FAResponse | null>(
null,
);
const [otpCode, setOtpCode] = useState('');
const [verifying, setVerifying] = useState(false);
const handleEnable2FA = async () => {
setLoading(true);
try {
const response = await apiEnable2FA();
if (response?.data) {
setQrData(response.data);
setModalVisible(true);
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.enable.error',
defaultMessage: 'Không thể bật 2FA. Vui lòng thử lại.',
}),
);
} finally {
setLoading(false);
}
};
const handleVerifyOTP = async () => {
if (!otpCode || otpCode.length !== 6) {
message.warning(
intl.formatMessage({
id: 'master.profile.2fa.otp.invalid',
defaultMessage: 'Vui lòng nhập mã OTP 6 số',
}),
);
return;
}
setVerifying(true);
try {
const response = await apiVerify2FA({ otp: otpCode });
if (response?.status === 200 || response?.status === 201) {
message.success(
intl.formatMessage({
id: 'master.profile.2fa.enable.success',
defaultMessage: 'Bật 2FA thành công!',
}),
);
setModalVisible(false);
setOtpCode('');
setQrData(null);
refresh();
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.verify.error',
defaultMessage: 'Mã OTP không đúng. Vui lòng thử lại.',
}),
);
} finally {
setVerifying(false);
}
};
const handleDisable2FA = async () => {
modal.confirm({
title: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.title',
defaultMessage: 'Xác nhận tắt 2FA',
}),
content: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.content',
defaultMessage:
'Bạn có chắc chắn muốn tắt xác thực 2 bước? Điều này sẽ giảm bảo mật cho tài khoản của bạn.',
}),
okText: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.ok',
defaultMessage: 'Tắt 2FA',
}),
cancelText: intl.formatMessage({
id: 'master.profile.2fa.disable.confirm.cancel',
defaultMessage: 'Hủy',
}),
okButtonProps: { danger: true },
onOk: async () => {
try {
const response = await apiDisable2FA();
if (response?.status === 200 || response?.status === 204) {
message.success(
intl.formatMessage({
id: 'master.profile.2fa.disable.success',
defaultMessage: 'Đã tắt 2FA thành công!',
}),
);
refresh();
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.profile.2fa.disable.error',
defaultMessage: 'Không thể tắt 2FA. Vui lòng thử lại.',
}),
);
}
},
});
};
const handleToggle2FA = (checked: boolean) => {
if (checked) {
handleEnable2FA();
} else {
handleDisable2FA();
}
};
const handleModalCancel = () => {
setModalVisible(false);
setOtpCode('');
setQrData(null);
};
return (
<>
{contextHolder}
<Card>
<Title level={4}>
{intl.formatMessage({
id: 'master.profile.two-factor-authentication',
})}
</Title>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 16,
}}
>
<div>
<Text strong>
{intl.formatMessage({
id: 'master.profile.2fa.status',
defaultMessage: 'Trạng thái 2FA',
})}
</Text>
<br />
<Text type="secondary">
{intl.formatMessage({
id: 'master.profile.2fa.description',
defaultMessage:
'Bật xác thực 2 bước để tăng cường bảo mật cho tài khoản của bạn',
})}
</Text>
</div>
<Switch
checked={profile?.enable_2fa}
onChange={handleToggle2FA}
loading={loading}
checkedChildren={intl.formatMessage({
id: 'master.profile.2fa.enabled',
defaultMessage: 'Bật',
})}
unCheckedChildren={intl.formatMessage({
id: 'master.profile.2fa.disabled',
defaultMessage: 'Tắt',
})}
/>
</div>
</Card>
<Modal
title={intl.formatMessage({
id: 'master.profile.2fa.setup.title',
defaultMessage: 'Thiết lập xác thực 2 bước',
})}
open={modalVisible}
onOk={handleVerifyOTP}
onCancel={handleModalCancel}
confirmLoading={verifying}
okText={intl.formatMessage({
id: 'master.profile.2fa.verify',
defaultMessage: 'Xác nhận',
})}
cancelText={intl.formatMessage({
id: 'master.profile.2fa.cancel',
defaultMessage: 'Hủy',
})}
>
<Space direction="vertical" align="center" style={{ width: '100%' }}>
<Text>
{intl.formatMessage({
id: 'master.profile.2fa.scan.instruction',
defaultMessage:
'Quét mã QR bằng ứng dụng xác thực (Google Authenticator, Authy, ...)',
})}
</Text>
{qrData?.qr_code_base64 ? (
<img
src={`data:image/png;base64,${qrData.qr_code_base64}`}
alt="QR Code"
style={{ width: 200, height: 200 }}
/>
) : qrData?.otp_auth_url ? (
<QRCode value={qrData.otp_auth_url} size={200} />
) : null}
<Text type="secondary" style={{ marginTop: 16 }}>
{intl.formatMessage({
id: 'master.profile.2fa.otp.instruction',
defaultMessage: 'Nhập mã 6 số từ ứng dụng xác thực:',
})}
</Text>
<Input.OTP
length={6}
value={otpCode}
onChange={setOtpCode}
style={{ marginTop: 8 }}
/>
</Space>
</Modal>
</>
);
};
export default TwoFactorAuthentication;

View File

@@ -30,7 +30,6 @@ const ProfilePage = () => {
label: intl.formatMessage({
id: 'master.profile.two-factor-authentication',
}),
disabled: true,
},
];
const handleMenuSelect = (e: { key: string }) => {

View File

@@ -1,8 +1,13 @@
import {
API_ADMIN_DISABLE_2FA,
API_CHANGE_PASSWORD,
API_DISABLE_2FA,
API_ENABLE_2FA,
API_ENABLE_2FA_VERIFY,
API_FORGOT_PASSWORD,
API_PATH_GET_PROFILE,
API_PATH_LOGIN,
API_PATH_LOGIN_2FA,
API_PATH_REFRESH_TOKEN,
API_USERS,
} from '@/constants/api';
@@ -59,3 +64,46 @@ export async function apiForgotPassword(
data: body,
});
}
// 2FA
export async function apiLogin2FA(
token: string,
body: MasterModel.Verify2FARequestBody,
) {
return request<MasterModel.LoginResponse>(API_PATH_LOGIN_2FA, {
method: 'POST',
data: body,
headers: {
Authorization: token,
},
});
}
export async function apiEnable2FA() {
return request<MasterModel.Enable2FAResponse>(API_ENABLE_2FA, {
method: 'GET',
getResponse: true,
});
}
export async function apiVerify2FA(body: MasterModel.Verify2FARequestBody) {
return request(API_ENABLE_2FA_VERIFY, {
method: 'POST',
data: body,
getResponse: true,
});
}
export async function apiDisable2FA() {
return request(API_DISABLE_2FA, {
method: 'PUT',
getResponse: true,
});
}
export async function apiAdminDisable2FA(userId: string) {
return request(API_ADMIN_DISABLE_2FA + userId, {
method: 'PUT',
getResponse: true,
});
}

View File

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

View File

@@ -211,3 +211,19 @@ export async function apiQueryConfigAlarm(
return resp;
}
export async function apiQuerySensorLogMessage(
dataChanelId: string,
authorization: string,
params: MasterModel.SearchMessagePaginationBody,
) {
return request<
MasterModel.MesageReaderResponse<MasterModel.SensorLogMessage[]>
>(`${API_READER}/${dataChanelId}/messages`, {
method: 'GET',
headers: {
Authorization: authorization,
},
params: params,
});
}

View File

@@ -7,8 +7,8 @@ declare namespace MasterModel {
interface LoginResponse {
token?: string;
refresh_token: string;
enabled_2fa: boolean;
refresh_token?: string;
enabled2fa?: boolean;
}
interface RefreshTokenRequestBody {
refresh_token: string;

38
src/services/master/typings/camera.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
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[];
}
type CameraRecordType = 'none' | 'alarm' | 'all';
interface CameraV6 extends CameraV5 {
record_type?: CameraRecordType;
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

@@ -6,6 +6,15 @@ declare namespace MasterModel {
subtopic?: string;
}
interface MessageBasicInfo {
channel?: string;
subtopic?: string;
publisher?: string;
protocol?: string;
name?: string;
time?: number;
}
type LogTypeRequest = 'user_logs' | undefined;
interface MesageReaderResponse<T = MessageDataType> {
@@ -20,53 +29,27 @@ declare namespace MasterModel {
}
// Response types cho từng domain
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
type MessageDataType = NodeConfig[];
interface Message<T = MessageDataType> {
channel?: string;
subtopic?: string;
publisher?: string;
protocol?: string;
name?: string;
time?: number;
interface Message<T = MessageDataType> extends MessageBasicInfo {
string_value?: string;
string_value_parsed?: T;
}
// Message types cho từng domain
type CameraMessage = Message<CameraV5>;
type CameraV6Message = Message<CameraV6>;
type NodeConfigMessage = Message<NodeConfig[]>;
interface CameraV5 {
cams?: Camera[];
}
interface CameraV6 extends CameraV5 {
record_type?: 'none' | 'alarm' | 'all';
record_alarm_list?: string[];
}
interface Camera {
id?: string;
name?: string;
cate_id?: string;
username?: string;
password?: string;
rtsp_port?: number;
http_port?: number;
channel?: number;
ip?: string;
stream?: number;
}
interface Alarm {
id: string;
type: Type;
name: string;
}
interface SensorLogMessage extends MessageBasicInfo {
unit: string;
value: number;
}
}

View File

@@ -0,0 +1,59 @@
declare namespace MasterModel {
/**
* Trạng thái kết nối terminal
*/
interface TerminalConnection {
online: boolean;
lastSeen?: number;
}
/**
* Format SenML record nhận từ MQTT
*/
interface SenmlRecord {
vs?: string; // value string - dữ liệu terminal
}
/**
* Preset theme cho terminal
*/
interface TerminalThemePreset {
name: string;
theme: {
background: string;
foreground: string;
cursor: string;
selectionBackground: string;
};
}
/**
* Định nghĩa các preset themes cho terminal
*/
type TerminalThemes = Record<string, TerminalThemePreset>;
/**
* Record SenML gửi tới MQTT (dùng cho terminal commands)
* Format: SenML (Sensor Measurement List)
*/
interface MqttTerminalRecord {
bn: string; // base name - session ID
n: string; // name - tên field
bt: number; // base time - timestamp (seconds)
vs: string; // value string - command được encode Base64
}
/**
* Payload gửi tới MQTT cho terminal
* Là mảng các SenML records
*/
type MqttTerminalPayload = MqttTerminalRecord[];
/**
* Options cho MQTT publish
*/
interface MqttPublishOptions {
topic: string;
payload: string;
}
}

View File

@@ -12,6 +12,7 @@ declare namespace MasterModel {
interface UserResponse {
id?: string;
email?: string;
enable_2fa?: boolean;
metadata?: UserMetadata;
}
@@ -46,4 +47,14 @@ declare namespace MasterModel {
password: string;
confirm_password?: string;
}
// 2FA
interface Enable2FAResponse {
otp_auth_url?: string;
qr_code_base64?: string;
}
interface Verify2FARequestBody {
otp?: string;
}
}

22
src/utils/chart.ts Normal file
View File

@@ -0,0 +1,22 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Filler,
Legend,
LinearScale,
LineElement,
PointElement,
Tooltip,
} from 'chart.js';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Tooltip,
Legend,
);

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();

111
src/utils/rememberMe.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Utility functions for managing "Remember me" password saving functionality
* Uses AES-256 encryption for secure credential storage
*/
import CryptoJS from 'crypto-js';
import {
getRememberMeData,
removeRememberMeData,
setRememberMeData,
} from './storage';
const SECRET_KEY = 'smatec_secret_key_2024_secure_encryption';
export interface RememberedCredentials {
email: string;
password: string;
}
/**
* AES-256 encryption using CryptoJS
* Automatically generates random IV and encodes to Base64
* @param data Data to encrypt
* @returns Encrypted string (base64 encoded with IV)
*/
function encrypt(data: string): string {
try {
const encrypted = CryptoJS.AES.encrypt(data, SECRET_KEY).toString();
return encrypted;
} catch (error) {
console.error('Encryption error:', error);
return '';
}
}
/**
* AES-256 decryption using CryptoJS
* Automatically extracts IV and decrypts
* @param encrypted Encrypted data (base64 encoded with IV)
* @returns Decrypted string
*/
function decrypt(encrypted: string): string {
try {
const decrypted = CryptoJS.AES.decrypt(encrypted, SECRET_KEY);
const result = decrypted.toString(CryptoJS.enc.Utf8);
return result;
} catch (error) {
console.error('Decryption error:', error);
return '';
}
}
/**
* Clear saved credentials from localStorage
*/
export function clearCredentials(): void {
removeRememberMeData();
}
/**
* Save credentials to localStorage (encrypted with secret key)
* @param email User's email
* @param password User's password
*/
export function saveCredentials(email: string, password: string): void {
try {
const credentials: RememberedCredentials = { email, password };
const json = JSON.stringify(credentials);
const encrypted = encrypt(json);
setRememberMeData(encrypted);
} catch (error) {
console.error('Error saving credentials:', error);
}
}
/**
* Load credentials from localStorage
* @returns RememberedCredentials or null if not found or error occurs
*/
export function loadCredentials(): RememberedCredentials | null {
try {
const encrypted = getRememberMeData();
if (!encrypted) {
return null;
}
const decrypted = decrypt(encrypted);
if (!decrypted) {
clearCredentials();
return null;
}
const credentials: RememberedCredentials = JSON.parse(decrypted);
return credentials;
} catch (error) {
console.error('Error loading credentials:', error);
clearCredentials();
return null;
}
}
/**
* Check if there are saved credentials
* @returns true if credentials exist, false otherwise
*/
export function hasSavedCredentials(): boolean {
try {
const encrypted = getRememberMeData();
return encrypted !== null;
} catch (error) {
return false;
}
}

View File

@@ -1,4 +1,10 @@
import { ACCESS_TOKEN, REFRESH_TOKEN } from '@/constants';
import {
ACCESS_TOKEN,
REFRESH_TOKEN,
REMEMBER_ME_KEY,
TERMINAL_THEME_KEY,
THEME_KEY,
} from '@/constants';
export function getAccessToken(): string {
return localStorage.getItem(ACCESS_TOKEN) || '';
@@ -24,6 +30,30 @@ export function removeRefreshToken() {
localStorage.removeItem(REFRESH_TOKEN);
}
export function getTerminalTheme(): string {
return localStorage.getItem(TERMINAL_THEME_KEY) || 'dark';
}
export function setTerminalTheme(themeKey: string) {
localStorage.setItem(TERMINAL_THEME_KEY, themeKey);
}
export function removeTerminalTheme() {
localStorage.removeItem(TERMINAL_THEME_KEY);
}
export function getTheme(): string {
return localStorage.getItem(THEME_KEY) || 'light';
}
export function setTheme(themeKey: string) {
localStorage.setItem(THEME_KEY, themeKey);
}
export function removeTheme() {
localStorage.removeItem(THEME_KEY);
}
export function getBrowserId() {
const id = localStorage.getItem('sip-browserid');
if (!id) {
@@ -41,15 +71,62 @@ export function getBrowserId() {
return id;
}
// ============================================
// Remember Me Storage Operations
// ============================================
/**
* Clear all localStorage data except browserId
* Get encrypted credentials from localStorage
*/
export function getRememberMeData(): string | null {
try {
return localStorage.getItem(REMEMBER_ME_KEY);
} catch (error) {
console.error('Error reading remember me data:', error);
return null;
}
}
/**
* Save encrypted credentials to localStorage
*/
export function setRememberMeData(encryptedData: string): void {
try {
localStorage.setItem(REMEMBER_ME_KEY, encryptedData);
} catch (error) {
console.error('Error saving remember me data:', error);
}
}
/**
* Remove encrypted credentials from localStorage
*/
export function removeRememberMeData(): void {
try {
localStorage.removeItem(REMEMBER_ME_KEY);
} catch (error) {
console.error('Error removing remember me data:', error);
}
}
/**
* Clear all localStorage data except browserId, theme and terminal theme
*/
export function clearAllData() {
const browserId = localStorage.getItem('sip-browserid');
const rememberMe = getRememberMeData();
const theme = getTheme();
const terminalTheme = getTerminalTheme();
localStorage.clear();
// Khôi phục các giá trị cần thiết
if (browserId) {
localStorage.setItem('sip-browserid', browserId);
}
if (rememberMe) {
setRememberMeData(rememberMe);
}
setTheme(theme);
setTerminalTheme(terminalTheme);
}
/**

118
update-iconfont.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/bash
set -e
echo "=== Update Iconfont - Git Push Script with Merge Main ==="
# Hàm kiểm tra lỗi và thoát
handle_error() {
echo "❌ Lỗi: $1"
# Nếu có stash, hiển thị thông báo để người dùng biết cách xử lý
if [ "$stashed" = true ]; then
echo "⚠️ Có stash được lưu, hãy kiểm tra bằng 'git stash list' và xử lý thủ công nếu cần."
fi
exit 1
}
# Kiểm tra xem có trong Git repository không
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
handle_error "Thư mục hiện tại không phải là Git repository!"
fi
# Các file cần commit
FILE1="src/components/IconFont/index.tsx"
FILE2="src/app.tsx"
# Kiểm tra file tồn tại
if [ ! -f "$FILE1" ]; then
handle_error "Không tìm thấy file $FILE1"
fi
if [ ! -f "$FILE2" ]; then
handle_error "Không tìm thấy file $FILE2"
fi
# Lấy nhánh hiện tại
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
handle_error "Không thể xác định nhánh hiện tại!"
fi
echo "👉 Bạn đang ở nhánh: $current_branch"
# Hỏi nhánh chính, mặc định là 'master' nếu người dùng không nhập
read -p "Nhập tên nhánh chính (mặc định: master): " target_branch
target_branch=${target_branch:-master}
# Kiểm tra xem nhánh chính có tồn tại trên remote không
if ! git ls-remote --heads origin "$target_branch" >/dev/null 2>&1; then
handle_error "Nhánh $target_branch không tồn tại trên remote!"
fi
# Hiển thị thay đổi của 2 file
echo ""
echo "📋 Các thay đổi trong 2 file iconfont:"
echo "---"
git --no-pager diff "$FILE1" "$FILE2" 2>/dev/null || echo "(Không có thay đổi hoặc file chưa được track)"
echo "---"
# Kiểm tra có thay đổi không
if git diff --quiet "$FILE1" "$FILE2" 2>/dev/null && git diff --cached --quiet "$FILE1" "$FILE2" 2>/dev/null; then
echo "⚠️ Không có thay đổi nào trong 2 file iconfont."
exit 0
fi
# --- Bước 1: Stash nếu có thay đổi chưa commit ---
stashed=false
if [[ -n $(git status --porcelain) ]]; then
echo "💾 Có thay đổi chưa commit -> stash lại..."
git stash push -m "auto-stash-before-iconfont-$(date +%s)" || handle_error "Lỗi khi stash code!"
stashed=true
fi
# --- Bước 2: Đồng bộ code từ nhánh chính về nhánh hiện tại ---
echo "🔄 Đồng bộ code từ $target_branch về $current_branch..."
git fetch origin "$target_branch" || handle_error "Lỗi khi fetch $target_branch!"
git merge origin/"$target_branch" --no-edit || {
handle_error "Merge từ $target_branch về $current_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
}
# --- Bước 3: Nếu có stash thì pop lại ---
if [ "$stashed" = true ]; then
echo "📥 Pop lại code đã stash..."
git stash pop || handle_error "Stash pop bị conflict, hãy xử lý thủ công bằng 'git stash list' và 'git stash apply'!"
fi
# --- Bước 4: Add và Commit 2 file iconfont ---
echo "📥 Đang add 2 file iconfont..."
git add "$FILE1" "$FILE2" || handle_error "Lỗi khi add files!"
read -p "Nhập commit message (mặc định: 'chore: update iconfont url'): " commit_message
commit_message=${commit_message:-"chore: update iconfont url"}
git commit -m "$commit_message" || handle_error "Lỗi khi commit!"
# --- Bước 5: Push nhánh hiện tại ---
echo "🚀 Đang push nhánh $current_branch lên remote..."
git push origin "$current_branch" || handle_error "Push nhánh $current_branch thất bại!"
# --- Bước 6: Checkout sang nhánh chính ---
echo "🔄 Chuyển sang nhánh $target_branch..."
git checkout "$target_branch" || handle_error "Checkout sang $target_branch thất bại!"
# --- Bước 7: Pull nhánh chính ---
echo "🔄 Pull code mới nhất từ remote $target_branch..."
git pull origin "$target_branch" --no-rebase || handle_error "Pull $target_branch thất bại!"
# --- Bước 8: Merge nhánh hiện tại vào nhánh chính ---
echo "🔀 Merge $current_branch vào $target_branch..."
git merge "$current_branch" --no-edit || {
handle_error "Merge từ $current_branch vào $target_branch bị conflict, hãy xử lý thủ công rồi chạy lại."
}
# --- Bước 9: Push nhánh chính ---
git push origin "$target_branch" || handle_error "Push $target_branch thất bại!"
# --- Quay lại nhánh hiện tại ---
echo "🔄 Quay lại nhánh $current_branch..."
git checkout "$current_branch" || handle_error "Checkout về $current_branch thất bại!"
echo ""
echo "✅ Hoàn tất! Đã commit 2 file iconfont và merge vào $target_branch."