Compare commits

1 Commits

20 changed files with 516 additions and 20 deletions

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@
.swc .swc
.turbopack .turbopack
/dist /dist
.DS_Store .DS_Store
/wdoc

View File

@@ -22,7 +22,7 @@ export default defineConfig({
name: 'gms.monitor', name: 'gms.monitor',
icon: 'icon-monitor', icon: 'icon-monitor',
path: '/monitor', path: '/monitor',
component: './Slave/GMS/Monitor', component: './Slave/Spole/Monitor',
}, },
{ {
...managerRouteBase, ...managerRouteBase,

View File

@@ -102,7 +102,9 @@ export const handleRequestConfig: RequestConfig = {
...options, ...options,
headers: { headers: {
...options.headers, ...options.headers,
...(token ? { Authorization: `${token}` } : {}), ...(token && !options.headers.Authorization
? { Authorization: `${token}` }
: {}),
}, },
}, },
}; };

View File

@@ -86,7 +86,9 @@ export const handleRequestConfig: RequestConfig = {
...options, ...options,
headers: { headers: {
...options.headers, ...options.headers,
...(token ? { Authorization: `${token}` } : {}), ...(token && !options.headers.Authorization
? { Authorization: `${token}` }
: {}),
}, },
}, },
}; };

View File

@@ -39,6 +39,10 @@ export const commonManagerRoutes = [
path: '/manager/devices', path: '/manager/devices',
component: './Manager/Device', component: './Manager/Device',
}, },
{
path: '/manager/devices/:thingId',
component: './Manager/Device/Detail',
},
], ],
}, },
{ {

View File

@@ -86,7 +86,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
contentWidth: 'Fluid', contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light', navTheme: isDark ? 'realDark' : 'light',
splitMenus: true, splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js', iconfontUrl: '//at.alicdn.com/t/c/font_5096559_gjd5149497o.js',
contentStyle: { contentStyle: {
padding: 0, padding: 0,
margin: 0, margin: 0,

View File

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

View File

@@ -0,0 +1,69 @@
import type { ButtonProps } from 'antd';
import { Button, Tooltip } from 'antd';
import React from 'react';
import IconFont from '../IconFont';
type TooltipIconFontButtonProps = {
tooltip?: string;
iconFontName: string;
color?: string;
onClick?: () => void;
} & Omit<ButtonProps, 'icon' | 'color'>;
const TooltipIconFontButton: React.FC<TooltipIconFontButtonProps> = ({
tooltip,
iconFontName,
color,
onClick,
...buttonProps
}) => {
const wrapperClassName = `tooltip-iconfont-wrapper-${color?.replace(
/[^a-zA-Z0-9]/g,
'-',
)}`;
const icon = (
<IconFont
type={iconFontName}
style={{ color: color || 'black' }}
className={wrapperClassName}
/>
);
if (tooltip) {
return (
<Tooltip title={tooltip}>
<Button
onClick={onClick}
icon={icon}
{...buttonProps}
style={
color
? {
...buttonProps.style,
['--icon-button-color' as string]: color,
}
: buttonProps.style
}
/>
</Tooltip>
);
}
return (
<Button
onClick={onClick}
icon={icon}
{...buttonProps}
style={
color
? {
...buttonProps.style,
['--icon-button-color' as string]: color,
}
: buttonProps.style
}
/>
);
};
export default TooltipIconFontButton;

View File

@@ -76,3 +76,29 @@
.table-row-select tbody tr:hover { .table-row-select tbody tr:hover {
cursor: pointer; cursor: pointer;
} }
// Target icon inside the color wrapper - higher specificity
:local(.tooltip-iconfont-wrapper) .iconfont,
.tooltip-iconfont-wrapper .iconfont,
.tooltip-iconfont-wrapper svg {
color: currentcolor !important;
}
// Even more specific - target within Button
.ant-btn .tooltip-iconfont-wrapper .iconfont {
color: currentcolor !important;
}
// Most aggressive - global selector
:global {
.ant-btn .tooltip-iconfont-wrapper .iconfont,
.ant-btn .tooltip-iconfont-wrapper svg {
color: currentcolor !important;
}
// Use CSS variable approach
.ant-btn[style*='--icon-button-color'] .iconfont,
.ant-btn[style*='--icon-button-color'] svg {
color: var(--icon-button-color) !important;
}
}

View File

@@ -9,7 +9,7 @@ export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants // Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search'; export const API_THINGS_SEARCH = '/api/things/search';
export const API_THING_POLICY = '/api/things/policy2'; export const API_THING_POLICY = '/api/things/policy2';
export const API_SHARE_THING = '/api/things'; export const API_THING = '/api/things';
// Group API Constants // Group API Constants
export const API_GROUPS = '/api/groups'; export const API_GROUPS = '/api/groups';
@@ -17,7 +17,7 @@ export const API_GROUP_MEMBERS = '/api/members';
export const API_GROUP_CHILDREN = '/api/groups'; export const API_GROUP_CHILDREN = '/api/groups';
// Log API Constants // Log API Constants
export const API_LOGS = '/api/reader/channels'; export const API_READER = '/api/reader/channels';
// User API Constants // User API Constants
export const API_USERS = '/api/users'; export const API_USERS = '/api/users';

View File

@@ -2,4 +2,5 @@ export const ROUTE_LOGIN = '/login';
export const ROUTER_HOME = '/'; export const ROUTER_HOME = '/';
export const ROUTE_PROFILE = '/profile'; export const ROUTE_PROFILE = '/profile';
export const ROUTE_MANAGER_USERS = '/manager/users'; export const ROUTE_MANAGER_USERS = '/manager/users';
export const ROUTE_MANAGER_DEVICES = '/manager/devices';
export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions'; export const ROUTE_MANAGER_USERS_PERMISSIONS = 'permissions';

View File

@@ -0,0 +1,34 @@
import { getBadgeConnection } from '@/components/shared/ThingShared';
import { useIntl } from '@umijs/max';
import { Flex, Typography } from 'antd';
import moment from 'moment';
const { Text, Title } = Typography;
const ThingTitle = ({ thing }: { thing: MasterModel.Thing | null }) => {
const intl = useIntl();
if (thing === null) {
return <Text>{intl.formatMessage({ id: 'common.undefined' })}</Text>;
}
const connectionDuration = thing.metadata!.connected
? thing.metadata!.uptime! * 1000
: (Math.round(new Date().getTime() / 1000) -
thing.metadata!.updated_time!) *
1000;
return (
<Flex gap={10}>
<Title level={4} style={{ margin: 0 }}>
{thing.name || intl.formatMessage({ id: 'common.undefined' })}
</Title>
<Flex gap={5} align="center" justify="center">
{getBadgeConnection(thing.metadata!.connected || false)}
<Text type={thing.metadata?.connected ? undefined : 'secondary'}>
{connectionDuration > 0
? moment.duration(connectionDuration).humanize()
: ''}
</Text>
</Flex>
</Flex>
);
};
export default ThingTitle;

View File

@@ -0,0 +1,90 @@
import TooltipIconFontButton from '@/components/shared/TooltipIconFontButton';
import { ROUTER_HOME } from '@/constants/routes';
import { apiQueryMessage } from '@/services/master/MessageController';
import { apiGetThingDetail } from '@/services/master/ThingController';
import { PageContainer } from '@ant-design/pro-components';
import { history, useModel, useParams } from '@umijs/max';
import { useEffect, useState } from 'react';
import ThingTitle from './components/ThingTitle';
const DetailDevicePage = () => {
const { thingId } = useParams();
const [thing, setThing] = useState<MasterModel.Thing | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { initialState } = useModel('@@initialState');
const getThingDetail = async () => {
setIsLoading(true);
try {
const thing = await apiGetThingDetail(thingId || '');
setThing(thing);
} catch (error) {
console.error('Error when get Thing: ', error);
} finally {
setIsLoading(false);
}
};
const getDeviceConfig = async () => {
try {
const resp = await apiQueryMessage(
thing?.metadata?.data_channel_id || '',
initialState?.currentUserProfile?.metadata?.frontend_thing_key || '',
{
offset: 0,
limit: 1,
subtopic: `config.${thing?.metadata?.type}.node`,
},
);
console.log('Device Config:', resp.messages![0].string_value_parsed);
} catch (error) {}
};
useEffect(() => {
if (thingId) {
getThingDetail();
}
}, [thingId]);
useEffect(() => {
if (thing) {
getDeviceConfig();
}
}, [thing]);
return (
<PageContainer
title={isLoading ? 'Loading...' : <ThingTitle thing={thing} />}
header={{
onBack: () => history.push(ROUTER_HOME),
breadcrumb: undefined,
}}
extra={[
<TooltipIconFontButton
key="logs"
tooltip="Nhật ký"
iconFontName="icon-system-diary"
shape="circle"
size="middle"
onClick={() => {}}
/>,
<TooltipIconFontButton
key="notifications"
tooltip="Thông báo"
iconFontName="icon-bell"
shape="circle"
size="middle"
onClick={() => {}}
/>,
<TooltipIconFontButton
key="settings"
tooltip="Cài đặt"
iconFontName="icon-setting-device"
shape="circle"
size="middle"
onClick={() => {}}
/>,
]}
>
Thing ID: {thingId}
</PageContainer>
);
};
export default DetailDevicePage;

View File

@@ -5,6 +5,7 @@ import {
} from '@/components/shared/ThingShared'; } from '@/components/shared/ThingShared';
import TreeGroup from '@/components/shared/TreeGroup'; import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants'; import { DEFAULT_PAGE_SIZE } from '@/constants';
import { ROUTE_MANAGER_DEVICES } from '@/constants/routes';
import { apiSearchThings } from '@/services/master/ThingController'; import { apiSearchThings } from '@/services/master/ThingController';
import { import {
ActionType, ActionType,
@@ -12,12 +13,12 @@ import {
ProColumns, ProColumns,
ProTable, ProTable,
} from '@ant-design/pro-components'; } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max'; import { FormattedMessage, history, useIntl } from '@umijs/max';
import { Flex, Grid, theme, Typography } from 'antd'; import { Flex, Grid, theme, Typography } from 'antd';
import moment from 'moment'; import moment from 'moment';
import React, { useRef, useState } from 'react'; import React, { useRef, useState } from 'react';
import { TagStateCallbackPayload } from '../../SGW/Map/type'; import { TagStateCallbackPayload } from '../../SGW/Map/type';
const { Text } = Typography; const { Text, Link } = Typography;
const SpoleHome: React.FC = () => { const SpoleHome: React.FC = () => {
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
const intl = useIntl(); const intl = useIntl();
@@ -39,9 +40,6 @@ const SpoleHome: React.FC = () => {
{ {
key: 'name', key: 'name',
ellipsis: true, ellipsis: true,
title: (
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
),
tip: intl.formatMessage({ tip: intl.formatMessage({
id: 'master.devices.name.tip', id: 'master.devices.name.tip',
defaultMessage: 'The device name', defaultMessage: 'The device name',
@@ -49,6 +47,20 @@ const SpoleHome: React.FC = () => {
dataIndex: 'name', dataIndex: 'name',
hideInSearch: true, hideInSearch: true,
copyable: true, copyable: true,
render: (_, row) => {
return (
<Link
copyable
onClick={() => history.push(`${ROUTE_MANAGER_DEVICES}/${row.id}`)}
>
{row.name ||
intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Undefined',
})}
</Link>
);
},
}, },
{ {
key: 'connected', key: 'connected',

View File

@@ -1,11 +1,11 @@
import { API_LOGS } from '@/constants/api'; import { API_READER } from '@/constants/api';
import { request } from '@umijs/max'; import { request } from '@umijs/max';
export async function apiQueryLogs( export async function apiQueryLogs(
params: MasterModel.SearchLogPaginationBody, params: MasterModel.SearchLogPaginationBody,
type: MasterModel.LogTypeRequest, type: MasterModel.LogTypeRequest,
) { ) {
return request<MasterModel.LogResponse>(`${API_LOGS}/${type}/messages`, { return request<MasterModel.LogResponse>(`${API_READER}/${type}/messages`, {
params: params, params: params,
}); });
} }

View File

@@ -0,0 +1,148 @@
import { API_READER } from '@/constants/api';
import { request } from '@umijs/max';
// Transform functions
function transformEntityConfigChildDetail(
raw: MasterModel.PurpleC,
): MasterModel.EntityConfigChildDetail {
return {
entityId: raw.eid || '',
value: raw.v,
operation: raw.op,
duration: raw.for,
};
}
function transformEntityConfigChild(
raw: MasterModel.CElement,
): MasterModel.EntityConfigChild {
return {
type: raw.t || '',
children: raw.c ? [transformEntityConfigChildDetail(raw.c)] : undefined,
};
}
function transformEntityConfig(raw: MasterModel.EC): MasterModel.EntityConfig {
return {
level: raw.l as 0 | 1 | 2 | undefined,
normalCondition: raw.nc,
subType: raw.st,
children: raw.c?.map(transformEntityConfigChild),
};
}
function transformEntity(raw: MasterModel.E): MasterModel.Entity {
return {
entityId: raw.eid || '',
type: raw.t || '',
name: raw.n || '',
active: raw.a,
value: raw.v,
config: raw.c ? [transformEntityConfig(raw.c)] : undefined,
};
}
export function transformNodeConfig(
raw: MasterModel.RawNodeConfig,
): MasterModel.NodeConfig {
return {
nodeId: raw.nid || '',
type: raw.t || '',
name: raw.n || '',
entities: raw.e?.map(transformEntity) || [],
};
}
// Reverse transform functions
function reverseTransformEntityConfigChildDetail(
detail: MasterModel.EntityConfigChildDetail,
): MasterModel.PurpleC {
return {
eid: detail.entityId,
v: detail.value,
op: detail.operation,
for: detail.duration,
};
}
function reverseTransformEntityConfigChild(
child: MasterModel.EntityConfigChild,
): MasterModel.CElement {
return {
t: child.type,
c: child.children?.[0]
? reverseTransformEntityConfigChildDetail(child.children[0])
: undefined,
};
}
function reverseTransformEntityConfig(
raw: MasterModel.EntityConfig,
): MasterModel.EC {
return {
l: raw.level,
nc: raw.normalCondition,
st: raw.subType,
c: raw.children?.map(reverseTransformEntityConfigChild),
};
}
function reverseTransformEntity(entity: MasterModel.Entity): MasterModel.E {
return {
eid: entity.entityId,
t: entity.type,
n: entity.name,
a: entity.active,
v: entity.value,
c: entity.config?.[0]
? reverseTransformEntityConfig(entity.config[0])
: undefined,
};
}
export function transformRawNodeConfig(
node: MasterModel.NodeConfig,
): MasterModel.RawNodeConfig {
return {
nid: node.nodeId,
t: node.type,
n: node.name,
e: node.entities.map(reverseTransformEntity),
};
}
export async function apiQueryMessage(
dataChanelId: string,
authorization: string,
params: MasterModel.SearchMessagePaginationBody,
) {
const resp = await request<MasterModel.MesageReaderResponse>(
`${API_READER}/${dataChanelId}/messages`,
{
method: 'GET',
headers: {
Authorization: authorization,
},
params: params,
},
);
// Process messages to add string_value_parsed
if (resp.messages) {
resp.messages = resp.messages.map((message) => {
if (message.string_value) {
try {
const rawNodeConfigs: MasterModel.RawNodeConfig[] = JSON.parse(
message.string_value,
);
message.string_value_parsed = rawNodeConfigs.map(transformNodeConfig);
} catch (error) {
console.error('Failed to parse string_value:', error);
}
}
return message;
});
}
return resp;
}

View File

@@ -1,5 +1,5 @@
import { import {
API_SHARE_THING, API_THING,
API_THING_POLICY, API_THING_POLICY,
API_THINGS_SEARCH, API_THINGS_SEARCH,
} from '@/constants/api'; } from '@/constants/api';
@@ -55,7 +55,7 @@ export async function apiSearchThings(
export async function apiUpdateThing(value: MasterModel.Thing) { export async function apiUpdateThing(value: MasterModel.Thing) {
if (!value.id) throw new Error('Thing id is required'); if (!value.id) throw new Error('Thing id is required');
return request<MasterModel.Thing>(`${API_SHARE_THING}/${value.id}`, { return request<MasterModel.Thing>(`${API_THING}/${value.id}`, {
method: 'PUT', method: 'PUT',
data: value, data: value,
}); });
@@ -77,7 +77,7 @@ export async function apiDeleteUserThingPolicy(
thing_id: string, thing_id: string,
user_id: string, user_id: string,
) { ) {
return request(`${API_SHARE_THING}/${thing_id}/share`, { return request(`${API_THING}/${thing_id}/share`, {
method: 'DELETE', method: 'DELETE',
data: { data: {
policies: ['read', 'write', 'delete'], policies: ['read', 'write', 'delete'],
@@ -91,7 +91,7 @@ export async function apiShareThingToUser(
user_id: string, user_id: string,
policies: string[], policies: string[],
) { ) {
return request(`${API_SHARE_THING}/${thing_id}/share`, { return request(`${API_THING}/${thing_id}/share`, {
method: 'POST', method: 'POST',
data: { data: {
policies: policies, policies: policies,
@@ -100,3 +100,7 @@ export async function apiShareThingToUser(
getResponse: true, getResponse: true,
}); });
} }
export async function apiGetThingDetail(thing_id: string) {
return request<MasterModel.Thing>(`${API_THING}/${thing_id}`);
}

View File

@@ -8,7 +8,7 @@ declare namespace MasterModel {
type LogTypeRequest = 'user_logs' | undefined; type LogTypeRequest = 'user_logs' | undefined;
interface LogResponse { interface MesageReaderResponse {
offset?: number; offset?: number;
limit?: number; limit?: number;
publisher?: string; publisher?: string;
@@ -27,5 +27,6 @@ declare namespace MasterModel {
name?: string; name?: string;
time?: number; time?: number;
string_value?: string; string_value?: string;
string_value_parsed?: NodeConfig[];
} }
} }

101
src/services/master/typings/message.d.ts vendored Normal file
View File

@@ -0,0 +1,101 @@
declare namespace MasterModel {
interface SearchMessagePaginationBody extends SearchPaginationBody {
subtopic?: string;
}
interface RawNodeConfig {
nid?: string;
t?: string;
n?: string;
e?: E[];
}
interface E {
eid?: string;
t?: string;
n?: string;
a?: number;
v?: number;
c?: EC;
vs?: string;
}
interface EC {
l?: number;
nc?: number;
st?: string;
c?: CElement[];
}
interface CElement {
t?: string;
c?: PurpleC;
}
interface PurpleC {
eid?: string;
v?: number;
op?: number;
for?: number;
}
// Interface Tranformed RawNodeConfig
interface NodeConfig {
/** Node ID - Định danh duy nhất */
nodeId: string;
/** Type - Loại thiết bị */
type: string;
/** Name - Tên hiển thị */
name: string;
/** Entities - Danh sách cảm biến */
entities: Entity[];
}
interface Entity {
/** Entity ID - Định danh duy nhất của cảm biến */
entityId: string;
/** Type - Loại cảm biến (vd: 'bin' - nhị phân 0/1, 'bin_t' - nhị phân có trigger) */
type: string;
/** Name - Tên hiển thị */
name: string;
/** Active - Đã kích hoạt cảm biến này hay chưa (1=đã kích hoạt, 0=chưa kích hoạt) */
active?: number;
/** Value - Giá trị hiện tại (1=có, 0=không) */
value?: number;
/** EntityConfig - Cấu hình bổ sung */
config?: EntityConfig[];
}
interface EntityConfig {
/** Level - Mức độ cảnh báo
0 = info,
1 = warning,
2 = critical */
level?: 0 | 1 | 2;
/** Normal Condition - Điều kiện bình thường */
normalCondition?: number;
/** SubType - Phân loại chi tiết */
subType?: string;
/** Children - Các cấu hình con */
children?: EntityConfigChild[];
}
interface EntityConfigChild {
/** Type - Loại điều kiện */
type: string;
children?: EntityConfigChildDetail[];
}
interface EntityConfigChildDetail {
/** entity ID - Cảm biến được theo dõi */
entityId: string;
/** Value - Ngưỡng giá trị */
value?: number;
/** Operation - Toán tử so sánh:
* 0 = == (bằng)
* 1 = != (khác)
* 2 = > (lớn hơn)
* 3 = >= (lớn hơn hoặc bằng)
* 4 = < (nhỏ hơn)
* 5 = <= (nhỏ hơn hoặc bằng) */
operation?: number;
/** Duration - Thời gian duy trì (giây) */
duration?: number;
}
}

View File

@@ -30,6 +30,7 @@ declare namespace MasterModel {
state_updated_time?: number; state_updated_time?: number;
type?: string; type?: string;
updated_time?: number; updated_time?: number;
uptime?: number;
lat?: string; lat?: string;
lng?: string; lng?: string;
} }