Compare commits
10 Commits
8af31a0435
...
MinhNN
| Author | SHA1 | Date | |
|---|---|---|---|
| 057c7885cf | |||
| ea5fc0a617 | |||
| 9d211ed43c | |||
| 674d53bcc5 | |||
| 4af34eab3e | |||
| d619534a73 | |||
| 78162fc0cb | |||
| 155101491b | |||
| a011405d92 | |||
| afe50dbd07 |
@@ -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: '/',
|
||||
|
||||
@@ -9,8 +9,9 @@ import {
|
||||
getRefreshToken,
|
||||
setAccessToken,
|
||||
} from '@/utils/storage';
|
||||
import { history, request, RequestConfig } from '@umijs/max';
|
||||
import { history, RequestConfig } from '@umijs/max';
|
||||
import { message } from 'antd';
|
||||
import axios from 'axios';
|
||||
|
||||
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
|
||||
let refreshingTokenPromise: Promise<string | null> | null = null;
|
||||
@@ -125,7 +126,7 @@ export const handleRequestConfig: RequestConfig = {
|
||||
const isRefreshRequest = response.config.url?.includes(
|
||||
API_PATH_REFRESH_TOKEN,
|
||||
);
|
||||
console.log('Is refresh request:', isRefreshRequest);
|
||||
// console.log('Is refresh request:', isRefreshRequest);
|
||||
|
||||
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
|
||||
if (
|
||||
@@ -159,17 +160,32 @@ 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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
595
docs/encryption-flow.md
Normal 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
303
docs/mqtt-client.md
Normal file
@@ -0,0 +1,303 @@
|
||||
# MQTT Client - Hướng dẫn sử dụng
|
||||
|
||||
## Tổng quan
|
||||
|
||||
MQTT Client (`mqttClient`) là một utility singleton được thiết kế để quản lý kết nối MQTT trong ứng dụng. File này nằm tại `src/utils/mqttClient.ts`.
|
||||
|
||||
## Cài đặt
|
||||
|
||||
Package `mqtt` được cài đặt qua npm:
|
||||
|
||||
```bash
|
||||
npm install mqtt
|
||||
```
|
||||
|
||||
## Cấu trúc
|
||||
|
||||
```
|
||||
src/utils/
|
||||
├── mqttClient.ts # MQTT Client utility
|
||||
└── wsClient.ts # WebSocket Client utility (legacy)
|
||||
```
|
||||
|
||||
## Cách sử dụng
|
||||
|
||||
### 1. Import
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
```
|
||||
|
||||
### 2. Kết nối
|
||||
|
||||
```typescript
|
||||
// Lấy credentials từ user metadata
|
||||
const { frontend_thing_id, frontend_thing_key } =
|
||||
initialState?.currentUserProfile?.metadata || {};
|
||||
|
||||
// Kết nối
|
||||
mqttClient.connect({
|
||||
username: frontend_thing_id,
|
||||
password: frontend_thing_key,
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Đăng ký Event Handlers
|
||||
|
||||
```typescript
|
||||
// Khi kết nối thành công
|
||||
const unConnect = mqttClient.onConnect(() => {
|
||||
console.log('MQTT Connected!');
|
||||
});
|
||||
|
||||
// Khi có lỗi
|
||||
const unError = mqttClient.onError((error) => {
|
||||
console.error('MQTT Error:', error);
|
||||
});
|
||||
|
||||
// Khi kết nối đóng
|
||||
const unClose = mqttClient.onClose(() => {
|
||||
console.log('MQTT Closed');
|
||||
});
|
||||
|
||||
// Cleanup khi component unmount
|
||||
return () => {
|
||||
unConnect();
|
||||
unError();
|
||||
unClose();
|
||||
mqttClient.disconnect();
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Publish Message
|
||||
|
||||
```typescript
|
||||
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||
const payload = JSON.stringify(senmlData);
|
||||
|
||||
if (mqttClient.isConnected()) {
|
||||
mqttClient.publish(topic, payload);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Subscribe Topic
|
||||
|
||||
```typescript
|
||||
// Subscribe
|
||||
mqttClient.subscribe('channels/123/messages/#');
|
||||
|
||||
// Nhận message
|
||||
const unMessage = mqttClient.onMessage((topic, message, packet) => {
|
||||
console.log('Received:', topic, message.toString());
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
mqttClient.unsubscribe('channels/123/messages/#');
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Method | Mô tả |
|
||||
| ---------------------------- | ---------------------------------- |
|
||||
| `connect(credentials, url?)` | Kết nối MQTT với username/password |
|
||||
| `disconnect()` | Ngắt kết nối |
|
||||
| `subscribe(topic)` | Subscribe vào topic |
|
||||
| `unsubscribe(topic)` | Unsubscribe khỏi topic |
|
||||
| `publish(topic, payload)` | Publish message |
|
||||
| `onConnect(callback)` | Đăng ký callback khi kết nối |
|
||||
| `onClose(callback)` | Đăng ký callback khi đóng |
|
||||
| `onError(callback)` | Đăng ký callback khi lỗi |
|
||||
| `onMessage(callback)` | Đăng ký callback khi nhận message |
|
||||
| `isConnected()` | Kiểm tra trạng thái kết nối |
|
||||
| `getClient()` | Lấy MQTT client gốc |
|
||||
|
||||
## Cấu hình Proxy (Development)
|
||||
|
||||
Trong môi trường development, MQTT được proxy qua config trong `config/proxy.ts`:
|
||||
|
||||
```typescript
|
||||
"/mqtt": {
|
||||
target: "https://gms.smatec.com.vn",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
```
|
||||
|
||||
## Format SenML
|
||||
|
||||
Dữ liệu MQTT được format theo chuẩn SenML:
|
||||
|
||||
```typescript
|
||||
const senml = [
|
||||
{
|
||||
bn: `urn:dev:mac:${mac}:`, // Base name
|
||||
n: 'ack', // Name
|
||||
t: Date.now() / 1000, // Timestamp (seconds)
|
||||
vs: uuidv4(), // Value string (ACK ID)
|
||||
},
|
||||
{
|
||||
n: 'user@email.com', // Email (@ → :)
|
||||
t: Date.now() / 1000,
|
||||
vs: JSON.stringify(config), // Config dạng JSON string
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## Topic Structure
|
||||
|
||||
```
|
||||
channels/${channel_id}/messages/${type}/${gw_type}
|
||||
```
|
||||
|
||||
| Phần | Mô tả |
|
||||
| ------------ | ------------------------------------------------------- |
|
||||
| `channel_id` | ID của kênh (từ `thing.metadata.cfg_channel_id`) |
|
||||
| `type` | Loại message: `cameraconfig`, `config`, `log`, `notify` |
|
||||
| `gw_type` | Loại gateway: `gmsv6`, `gmsv5` |
|
||||
|
||||
## Ví dụ hoàn chỉnh
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
import { useModel } from '@umijs/max';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const MyComponent = ({ thing }) => {
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const { frontend_thing_id, frontend_thing_key } =
|
||||
initialState?.currentUserProfile?.metadata || {};
|
||||
|
||||
if (!frontend_thing_id || !frontend_thing_key) return;
|
||||
|
||||
mqttClient.connect({
|
||||
username: frontend_thing_id,
|
||||
password: frontend_thing_key,
|
||||
});
|
||||
|
||||
const unConnect = mqttClient.onConnect(() => setConnected(true));
|
||||
const unClose = mqttClient.onClose(() => setConnected(false));
|
||||
|
||||
return () => {
|
||||
unConnect();
|
||||
unClose();
|
||||
mqttClient.disconnect();
|
||||
};
|
||||
}, [initialState]);
|
||||
|
||||
const handlePublish = () => {
|
||||
const { cfg_channel_id, external_id } = thing.metadata;
|
||||
const topic = `channels/${cfg_channel_id}/messages/cameraconfig/gmsv6`;
|
||||
|
||||
const payload = [
|
||||
{
|
||||
bn: `urn:dev:mac:${external_id.replaceAll('-', '')}:`,
|
||||
n: 'ack',
|
||||
t: Date.now() / 1000,
|
||||
vs: uuidv4(),
|
||||
},
|
||||
{
|
||||
n: initialState?.currentUserProfile?.email?.replaceAll('@', ':'),
|
||||
t: Date.now() / 1000,
|
||||
vs: JSON.stringify({ record_type: 'all' }),
|
||||
},
|
||||
];
|
||||
|
||||
if (mqttClient.isConnected()) {
|
||||
mqttClient.publish(topic, JSON.stringify(payload));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handlePublish} disabled={!connected}>
|
||||
{connected ? 'Publish' : 'Connecting...'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## So sánh mqttClient vs wsClient
|
||||
|
||||
Dự án có 2 utilities để giao tiếp real-time:
|
||||
|
||||
### Bảng so sánh
|
||||
|
||||
| Tiêu chí | mqttClient | wsClient |
|
||||
| --- | --- | --- |
|
||||
| **Thư viện** | `mqtt` (MQTT.js) | `reconnecting-websocket` |
|
||||
| **Protocol** | MQTT over WebSocket | WebSocket thuần |
|
||||
| **Xác thực** | Username/Password (MQTT credentials) | Token (access_token) hoặc không |
|
||||
| **Topics** | Hỗ trợ MQTT topics, subscribe/unsubscribe | Không có khái niệm topic |
|
||||
| **Publish** | `publish(topic, payload)` | `send(data)` |
|
||||
| **Subscribe** | `subscribe(topic)` + `onMessage()` | `subscribe(callback)` |
|
||||
| **Reconnect** | Tự động (built-in) | Tự động (reconnecting-websocket) |
|
||||
| **Use case** | Giao tiếp với IoT Gateway/Devices | Giao tiếp WebSocket server |
|
||||
|
||||
### Khi nào dùng mqttClient?
|
||||
|
||||
✅ **Dùng mqttClient khi:**
|
||||
|
||||
- Gửi/nhận dữ liệu với IoT Gateways (GMSv5, GMSv6)
|
||||
- Cấu hình thiết bị (camera, nodes, schedules)
|
||||
- Cần subscribe nhiều topics khác nhau
|
||||
- Làm việc với Mainflux platform
|
||||
|
||||
### Khi nào dùng wsClient?
|
||||
|
||||
✅ **Dùng wsClient khi:**
|
||||
|
||||
- Cần WebSocket connection đơn giản
|
||||
- Giao tiếp với WebSocket server không phải MQTT broker
|
||||
- Xác thực bằng access_token
|
||||
|
||||
### Code comparison
|
||||
|
||||
**mqttClient:**
|
||||
|
||||
```typescript
|
||||
import { mqttClient } from '@/utils/mqttClient';
|
||||
|
||||
// Connect với username/password
|
||||
mqttClient.connect({
|
||||
username: 'thing_id',
|
||||
password: 'thing_key',
|
||||
});
|
||||
|
||||
// Subscribe topic
|
||||
mqttClient.subscribe('channels/123/messages/#');
|
||||
|
||||
// Publish với topic
|
||||
mqttClient.publish('channels/123/messages/config', payload);
|
||||
|
||||
// Nhận message
|
||||
mqttClient.onMessage((topic, message) => {
|
||||
console.log(topic, message.toString());
|
||||
});
|
||||
```
|
||||
|
||||
**wsClient:**
|
||||
|
||||
```typescript
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
|
||||
// Connect với hoặc không có token
|
||||
wsClient.connect('/mqtt', false);
|
||||
|
||||
// Không có subscribe topic
|
||||
// Chỉ nhận tất cả messages
|
||||
wsClient.subscribe((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
// Gửi data (không có topic)
|
||||
wsClient.send({ action: 'update', data: payload });
|
||||
```
|
||||
|
||||
## Xem thêm
|
||||
|
||||
- [Mainflux.md](../Mainflux.md) - Tài liệu giao tiếp MQTT chi tiết
|
||||
- [mqttClient.ts](../src/utils/mqttClient.ts) - Source code MQTT Client
|
||||
- [wsClient.ts](../src/utils/wsClient.ts) - Source code WebSocket Client
|
||||
394
package-lock.json
generated
394
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "smatec-frontend",
|
||||
"name": "SMATEC-FRONTEND",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -14,13 +14,18 @@
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.15.0",
|
||||
"ol": "^10.6.1",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"reconnecting-websocket": "^4.4.0"
|
||||
"reconnecting-websocket": "^4.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"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",
|
||||
@@ -4044,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",
|
||||
@@ -4068,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",
|
||||
@@ -7066,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",
|
||||
@@ -7952,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",
|
||||
@@ -8037,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",
|
||||
@@ -8707,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",
|
||||
@@ -8770,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",
|
||||
@@ -10751,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",
|
||||
@@ -10963,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",
|
||||
@@ -11866,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",
|
||||
@@ -12370,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",
|
||||
@@ -14310,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",
|
||||
@@ -14351,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",
|
||||
@@ -14731,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",
|
||||
@@ -18029,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": {
|
||||
@@ -18789,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",
|
||||
@@ -21015,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",
|
||||
@@ -21358,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",
|
||||
@@ -22198,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",
|
||||
@@ -22356,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",
|
||||
|
||||
10
package.json
10
package.json
@@ -23,15 +23,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",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"reconnecting-websocket": "^4.4.0"
|
||||
"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",
|
||||
|
||||
300
pnpm-lock.yaml
generated
300
pnpm-lock.yaml
generated
@@ -26,28 +26,52 @@ importers:
|
||||
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
|
||||
@@ -1311,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}
|
||||
|
||||
@@ -1397,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}
|
||||
|
||||
@@ -1409,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}
|
||||
|
||||
@@ -1824,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'}
|
||||
@@ -2090,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}
|
||||
|
||||
@@ -2117,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}
|
||||
|
||||
@@ -2160,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}
|
||||
|
||||
@@ -2318,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}
|
||||
|
||||
@@ -2338,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'}
|
||||
@@ -2405,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}
|
||||
|
||||
@@ -2422,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}
|
||||
@@ -3015,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}
|
||||
|
||||
@@ -3073,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}
|
||||
|
||||
@@ -3372,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}
|
||||
|
||||
@@ -3539,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'}
|
||||
@@ -3808,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}
|
||||
|
||||
@@ -4169,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'}
|
||||
@@ -4176,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}
|
||||
|
||||
@@ -4276,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'}
|
||||
@@ -5401,6 +5495,11 @@ packages:
|
||||
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:
|
||||
@@ -5524,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'}
|
||||
@@ -5859,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}
|
||||
|
||||
@@ -6288,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'}
|
||||
@@ -6377,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}
|
||||
|
||||
@@ -6506,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'}
|
||||
@@ -6540,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'}
|
||||
@@ -8120,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
|
||||
@@ -8213,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': {}
|
||||
@@ -8221,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':
|
||||
@@ -8518,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)':
|
||||
@@ -9012,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
|
||||
@@ -9419,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: {}
|
||||
@@ -9459,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:
|
||||
@@ -9533,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:
|
||||
@@ -9697,6 +9872,8 @@ snapshots:
|
||||
|
||||
commander@8.3.0: {}
|
||||
|
||||
commist@3.2.0: {}
|
||||
|
||||
common-path-prefix@3.0.0: {}
|
||||
|
||||
compressible@2.0.18:
|
||||
@@ -9721,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: {}
|
||||
@@ -9781,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
|
||||
@@ -9824,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
|
||||
@@ -10551,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: {}
|
||||
@@ -10662,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: {}
|
||||
@@ -10984,6 +11179,8 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
history@4.10.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
@@ -11158,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:
|
||||
@@ -11450,6 +11649,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
js-sdsl@4.3.0: {}
|
||||
|
||||
js-sdsl@4.4.2: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
@@ -11789,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: {}
|
||||
@@ -11933,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: {}
|
||||
@@ -13238,6 +13479,11 @@ snapshots:
|
||||
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
|
||||
@@ -13397,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
|
||||
@@ -13768,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
|
||||
@@ -14293,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):
|
||||
@@ -14424,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:
|
||||
@@ -14575,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
|
||||
@@ -14600,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: {}
|
||||
|
||||
20
src/app.tsx
20
src/app.tsx
@@ -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_0wzlbggptqk.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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_0wzlbggptqk.js',
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_3sthrd9e6y4.js',
|
||||
});
|
||||
|
||||
export default IconFont;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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ọ.',
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal file
47
src/pages/Auth/components/ForgotPasswordForm.tsx
Normal 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;
|
||||
68
src/pages/Auth/components/LoginForm.tsx
Normal file
68
src/pages/Auth/components/LoginForm.tsx
Normal 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;
|
||||
48
src/pages/Auth/components/OtpForm.tsx
Normal file
48
src/pages/Auth/components/OtpForm.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
259
src/pages/Manager/Dashboard/index.tsx
Normal file
259
src/pages/Manager/Dashboard/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
142
src/pages/Manager/Device/Terminal/components/XTerm.tsx
Normal file
142
src/pages/Manager/Device/Terminal/components/XTerm.tsx
Normal 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;
|
||||
663
src/pages/Manager/Device/Terminal/index.tsx
Normal file
663
src/pages/Manager/Device/Terminal/index.tsx
Normal 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;
|
||||
@@ -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';
|
||||
@@ -231,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>
|
||||
);
|
||||
|
||||
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal file
136
src/pages/Manager/User/components/Disable2FA.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,7 +30,6 @@ const ProfilePage = () => {
|
||||
label: intl.formatMessage({
|
||||
id: 'master.profile.two-factor-authentication',
|
||||
}),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
const handleMenuSelect = (e: { key: string }) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
4
src/services/master/typings/auth.d.ts
vendored
4
src/services/master/typings/auth.d.ts
vendored
@@ -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
38
src/services/master/typings/camera.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
27
src/services/master/typings/log.d.ts
vendored
27
src/services/master/typings/log.d.ts
vendored
@@ -29,12 +29,10 @@ declare namespace MasterModel {
|
||||
}
|
||||
|
||||
// Response types cho từng domain
|
||||
type CameraMessageResponse = MesageReaderResponse<CameraV5>;
|
||||
type CameraV6MessageResponse = MesageReaderResponse<CameraV6>;
|
||||
type AlarmMessageResponse = MesageReaderResponse<Alarm>;
|
||||
type NodeConfigMessageResponse = MesageReaderResponse<NodeConfig[]>;
|
||||
|
||||
type MessageDataType = NodeConfig[] | CameraV5 | CameraV6;
|
||||
type MessageDataType = NodeConfig[];
|
||||
|
||||
interface Message<T = MessageDataType> extends MessageBasicInfo {
|
||||
string_value?: string;
|
||||
@@ -42,31 +40,8 @@ declare namespace MasterModel {
|
||||
}
|
||||
|
||||
// Message types cho từng domain
|
||||
type CameraMessage = Message<CameraV5>;
|
||||
type CameraV6Message = Message<CameraV6>;
|
||||
type NodeConfigMessage = Message<NodeConfig[]>;
|
||||
|
||||
interface CameraV5 {
|
||||
cams?: Camera[];
|
||||
}
|
||||
interface CameraV6 extends CameraV5 {
|
||||
record_type?: 'none' | 'alarm' | 'all';
|
||||
record_alarm_list?: string[];
|
||||
}
|
||||
|
||||
interface Camera {
|
||||
id?: string;
|
||||
name?: string;
|
||||
cate_id?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rtsp_port?: number;
|
||||
http_port?: number;
|
||||
channel?: number;
|
||||
ip?: string;
|
||||
stream?: number;
|
||||
}
|
||||
|
||||
interface Alarm {
|
||||
id: string;
|
||||
type: Type;
|
||||
|
||||
59
src/services/master/typings/terminal.d.ts
vendored
Normal file
59
src/services/master/typings/terminal.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/services/master/typings/user.d.ts
vendored
11
src/services/master/typings/user.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
151
src/utils/mqttClient.ts
Normal file
151
src/utils/mqttClient.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import mqtt, { IClientOptions, MqttClient, Packet } from 'mqtt';
|
||||
|
||||
type MessageHandler = (topic: string, message: Buffer, packet: Packet) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
type ErrorHandler = (error: Error) => void;
|
||||
|
||||
interface MqttCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
class MQTTClientManager {
|
||||
private client: MqttClient | null = null;
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private connectHandlers = new Set<ConnectionHandler>();
|
||||
private closeHandlers = new Set<ConnectionHandler>();
|
||||
private errorHandlers = new Set<ErrorHandler>();
|
||||
|
||||
/**
|
||||
* Kết nối tới MQTT broker.
|
||||
* @param credentials Thông tin xác thực (username, password)
|
||||
* @param url Địa chỉ MQTT broker (mặc định là /mqtt)
|
||||
*/
|
||||
connect(credentials: MqttCredentials, url: string = '/mqtt') {
|
||||
if (this.client?.connected) return;
|
||||
|
||||
// Build WebSocket URL
|
||||
let mqttUrl = url;
|
||||
if (url.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
mqttUrl = `${protocol}//${window.location.host}${url}`;
|
||||
}
|
||||
|
||||
const opts: IClientOptions = {
|
||||
clean: true,
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 30 * 1000,
|
||||
};
|
||||
|
||||
this.client = mqtt.connect(mqttUrl, opts);
|
||||
|
||||
this.client.on('connect', () => {
|
||||
console.log('MQTT Connected successfully!');
|
||||
this.connectHandlers.forEach((fn) => fn());
|
||||
});
|
||||
|
||||
this.client.on('close', () => {
|
||||
console.log('MQTT Connection closed');
|
||||
this.closeHandlers.forEach((fn) => fn());
|
||||
});
|
||||
|
||||
this.client.on('error', (error: Error) => {
|
||||
console.error('MQTT Error:', error);
|
||||
this.errorHandlers.forEach((fn) => fn(error));
|
||||
});
|
||||
|
||||
this.client.on('message', (topic, message, packet) => {
|
||||
this.messageHandlers.forEach((fn) => fn(topic, message, packet));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ngắt kết nối MQTT và giải phóng tài nguyên.
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.client) {
|
||||
this.client.end();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe vào một topic.
|
||||
* @param topic Topic cần subscribe
|
||||
*/
|
||||
subscribe(topic: string | string[]) {
|
||||
this.client?.subscribe(topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe khỏi một topic.
|
||||
* @param topic Topic cần unsubscribe
|
||||
*/
|
||||
unsubscribe(topic: string | string[]) {
|
||||
this.client?.unsubscribe(topic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish message tới một topic.
|
||||
* @param topic Topic để publish
|
||||
* @param payload Payload (string hoặc object sẽ được stringify)
|
||||
*/
|
||||
publish(topic: string, payload: string | object) {
|
||||
const payloadStr =
|
||||
typeof payload === 'string' ? payload : JSON.stringify(payload);
|
||||
this.client?.publish(topic, payloadStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi nhận được message.
|
||||
* @param cb Hàm callback xử lý message
|
||||
* @returns Hàm hủy đăng ký callback
|
||||
*/
|
||||
onMessage(cb: MessageHandler) {
|
||||
this.messageHandlers.add(cb);
|
||||
return () => this.messageHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi kết nối thành công.
|
||||
*/
|
||||
onConnect(cb: ConnectionHandler) {
|
||||
this.connectHandlers.add(cb);
|
||||
return () => this.connectHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi kết nối bị đóng.
|
||||
*/
|
||||
onClose(cb: ConnectionHandler) {
|
||||
this.closeHandlers.add(cb);
|
||||
return () => this.closeHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Đăng ký callback khi có lỗi.
|
||||
*/
|
||||
onError(cb: ErrorHandler) {
|
||||
this.errorHandlers.add(cb);
|
||||
return () => this.errorHandlers.delete(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra trạng thái kết nối MQTT.
|
||||
* @returns true nếu đã kết nối, ngược lại là false
|
||||
*/
|
||||
isConnected() {
|
||||
return this.client?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy instance client MQTT gốc (nếu cần thao tác nâng cao).
|
||||
*/
|
||||
getClient() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
export const mqttClient = new MQTTClientManager();
|
||||
111
src/utils/rememberMe.ts
Normal file
111
src/utils/rememberMe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
0
update-iconfont.sh
Normal file → Executable file
0
update-iconfont.sh
Normal file → Executable file
Reference in New Issue
Block a user