662 lines
19 KiB
TypeScript
662 lines
19 KiB
TypeScript
// 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 { 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: '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 = localStorage.getItem('terminal_theme_key');
|
|
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: '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: '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
|
|
*/
|
|
const handleThemeChange: MenuProps['onClick'] = (e) => {
|
|
const themeKey = e.key;
|
|
setSelectedThemeKey(themeKey);
|
|
localStorage.setItem('terminal_theme_key', 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: '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: 'terminal.unsupported.title',
|
|
defaultMessage: 'Thiết bị không hỗ trợ terminal',
|
|
}),
|
|
intl.formatMessage({
|
|
id: '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: 'terminal.missingChannel.title',
|
|
defaultMessage: 'Thiếu thông tin kênh điều khiển',
|
|
}),
|
|
intl.formatMessage({
|
|
id: '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: 'terminal.missingCredential.title',
|
|
defaultMessage: 'Thiếu thông tin xác thực',
|
|
}),
|
|
intl.formatMessage({
|
|
id: '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: '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: 'terminal.connecting',
|
|
defaultMessage: 'Đang chuẩn bị phiên 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: '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: 'terminal.action.theme',
|
|
defaultMessage: 'Theme',
|
|
})}
|
|
: {TERMINAL_THEMES[selectedThemeKey]?.name || 'Dark'}
|
|
</Button>
|
|
</Dropdown>
|
|
</Space>
|
|
</Space>
|
|
)}
|
|
</PageContainer>
|
|
);
|
|
};
|
|
|
|
export default DeviceTerminalPage;
|