feat: add Manager Dashboard and Device Terminal features

This commit is contained in:
2026-02-09 16:38:28 +07:00
parent 4af34eab3e
commit 674d53bcc5
12 changed files with 1475 additions and 29 deletions

View File

@@ -159,6 +159,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 +182,11 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
},
token: {
header: {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorBgMenuItemSelected: isDark ? '#111b26' : '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
},
pageContainer: {
paddingInlinePageContainerContent: 8,
// paddingInlinePageContainerContent: 0,
paddingBlockPageContainerContent: 8,
},
},

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,661 @@
// 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;

View File

@@ -13,7 +13,7 @@ import {
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
import { Button, Divider, Grid, Space, Tag, Tooltip, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
@@ -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>
);

View File

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