From 674d53bcc593f39f04a8cc0e89fcb9709706aa47 Mon Sep 17 00:00:00 2001 From: MinhNN Date: Mon, 9 Feb 2026 16:38:28 +0700 Subject: [PATCH] feat: add Manager Dashboard and Device Terminal features --- .umirc.gms.ts | 9 +- config/routes.ts | 11 + package-lock.json | 21 +- package.json | 5 +- pnpm-lock.yaml | 284 +++++++- src/app.tsx | 10 +- src/components/Theme/ThemeProvider.tsx | 21 +- src/pages/Manager/Dashboard/index.tsx | 259 +++++++ .../Device/Terminal/components/XTerm.tsx | 142 ++++ src/pages/Manager/Device/Terminal/index.tsx | 661 ++++++++++++++++++ src/pages/Manager/Device/index.tsx | 22 +- src/services/master/typings/terminal.d.ts | 59 ++ 12 files changed, 1475 insertions(+), 29 deletions(-) create mode 100644 src/pages/Manager/Dashboard/index.tsx create mode 100644 src/pages/Manager/Device/Terminal/components/XTerm.tsx create mode 100644 src/pages/Manager/Device/Terminal/index.tsx create mode 100644 src/services/master/typings/terminal.d.ts diff --git a/.umirc.gms.ts b/.umirc.gms.ts index 45f9122..d7105d6 100644 --- a/.umirc.gms.ts +++ b/.umirc.gms.ts @@ -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: '/', diff --git a/config/routes.ts b/config/routes.ts index 14c6961..4947714 100644 --- a/config/routes.ts +++ b/config/routes.ts @@ -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 = { diff --git a/package-lock.json b/package-lock.json index e3d7ef1..0e1d003 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,9 @@ "ol": "^10.6.1", "react-chartjs-2": "^5.3.1", "reconnecting-websocket": "^4.4.0", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@types/react": "^18.0.33", @@ -22725,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", diff --git a/package.json b/package.json index d0a99e1..6653797 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,11 @@ "mqtt": "^5.15.0", "ol": "^10.6.1", "react-chartjs-2": "^5.3.1", + "react-countup": "^6.5.3", "reconnecting-websocket": "^4.4.0", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" }, "devDependencies": { "@types/react": "^18.0.33", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ac5e54..48d6cf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,15 +32,30 @@ importers: 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/react': specifier: ^18.0.33 @@ -48,6 +63,9 @@ importers: '@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 @@ -1397,6 +1415,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 +1430,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 +1851,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 +2121,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 +2151,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 +2197,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 +2358,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 +2381,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 +2452,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} @@ -3015,6 +3065,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 +3127,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 +3430,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 +3600,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 +3873,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 +4237,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 +4247,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 +4355,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 +5483,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 +5611,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 +5950,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 +6387,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 +6479,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 +6612,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 +6658,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'} @@ -8213,6 +8341,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 +8353,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 +8656,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 +9150,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 +9561,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 +9608,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 +9689,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 +9858,8 @@ snapshots: commander@8.3.0: {} + commist@3.2.0: {} + common-path-prefix@3.0.0: {} compressible@2.0.18: @@ -9721,6 +9884,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 +9951,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + countup.js@2.9.0: {} + create-ecdh@4.0.4: dependencies: bn.js: 4.12.2 @@ -10551,6 +10723,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 +10836,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 +11163,8 @@ snapshots: he@1.2.0: {} + help-me@5.0.0: {} + history@4.10.1: dependencies: '@babel/runtime': 7.28.6 @@ -11158,6 +11339,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 +11633,8 @@ snapshots: jiti@2.6.1: {} + js-sdsl@4.3.0: {} + js-sdsl@4.4.2: {} js-tokens@4.0.0: {} @@ -11789,10 +11974,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 +12151,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 +13463,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 +13627,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 +14006,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 +14538,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 +14671,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 +14824,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 +14876,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: {} diff --git a/src/app.tsx b/src/app.tsx index 3ecef7b..194c618 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -159,6 +159,12 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => { }, layout: 'top', menuHeaderRender: undefined, + subMenuItemRender: (item, dom) => { + if (item.path) { + return {dom}; + } + 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, }, }, diff --git a/src/components/Theme/ThemeProvider.tsx b/src/components/Theme/ThemeProvider.tsx index f25410f..0682c5b 100644 --- a/src/components/Theme/ThemeProvider.tsx +++ b/src/components/Theme/ThemeProvider.tsx @@ -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 = ({ 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 ( { + const intl = useIntl(); + const [counts, setCounts] = useState({ + devices: 0, + groups: 0, + users: 0, + logs: 0, + }); + const [deviceMetadata, setDeviceMetadata] = + useState(null); + + const formatter = (value: number | string) => ( + + ); + + 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 ( + + + + history.push('/manager/devices')} + icon={} + > + Xem chi tiết + + } + > + + } + /> + + history.push('/manager/groups')} + icon={} + > + Xem chi tiết + + } + > + + } + /> + + history.push('/manager/users')} + icon={} + > + Xem chi tiết + + } + > + + } + /> + + history.push('/manager/logs')} + icon={} + > + Xem chi tiết + + } + > + + } + /> + + + + {deviceMetadata && ( + + + Kết nối +
+ /{' '} + {deviceMetadata.total_thing} +
+ + + + SOS +
0 + ? '#ff4d4f' + : 'inherit', + }} + > + +
+ + + + Bình thường +
+ +
+ + + + Cảnh báo +
+ +
+ + + + Nghiêm trọng +
+ +
+ +
+ )} +
+
+
+ ); +}; + +export default ManagerDashboard; diff --git a/src/pages/Manager/Device/Terminal/components/XTerm.tsx b/src/pages/Manager/Device/Terminal/components/XTerm.tsx new file mode 100644 index 0000000..d9067cf --- /dev/null +++ b/src/pages/Manager/Device/Terminal/components/XTerm.tsx @@ -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(function XTerm( + { + onData, + cursorBlink = false, + className, + style, + disabled = false, + welcomeMessage = DEFAULT_WELCOME, + theme, + }, + ref, +) { + const containerRef = useRef(null); + const terminalRef = useRef(null); + const fitAddonRef = useRef(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 ( +
+ ); +}); + +export default XTerm; diff --git a/src/pages/Manager/Device/Terminal/index.tsx b/src/pages/Manager/Device/Terminal/index.tsx new file mode 100644 index 0000000..28672fd --- /dev/null +++ b/src/pages/Manager/Device/Terminal/index.tsx @@ -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(null); // Thông tin thiết bị + const [isLoading, setIsLoading] = useState(false); // Đang load dữ liệu + const [terminalError, setTerminalError] = useState(null); // Lỗi terminal + const [isSessionReady, setIsSessionReady] = useState(false); // Session đã sẵn sàng + const [connectionState, setConnectionState] = + useState({ + online: false, + }); // Trạng thái online/offline + const [sessionAttempt, setSessionAttempt] = useState(0); // Số lần thử kết nối lại + const [selectedThemeKey, setSelectedThemeKey] = useState('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(null); // Reference đến XTerm component + const responseTopicRef = useRef(null); // Topic MQTT nhận phản hồi + const requestTopicRef = useRef(null); // Topic MQTT gửi yêu cầu + const handshakeCompletedRef = useRef(false); // Đã hoàn thành handshake chưa + const mqttCleanupRef = useRef 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, + ) => ( + history.push(ROUTE_MANAGER_DEVICES)}> + {intl.formatMessage({ + id: 'common.back', + defaultMessage: 'Quay lại', + })} + , + , + ]} + /> + ); + + // 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 ( + history.push(ROUTE_MANAGER_DEVICES), + tags: ( + + {getBadgeConnection(connectionState.online)} + {connectionLabel} + + ), + }} + > + {/* Loading state */} + {isLoading && ( +
+ +
+ )} + + {/* Generic error state */} + {!isLoading && terminalError && ( + + {intl.formatMessage({ + id: 'common.retry', + defaultMessage: 'Thử lại', + })} + , + ]} + /> + )} + + {/* 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 && ( + + {/* Cảnh báo khi thiết bị offline */} + {!connectionState.online && ( + + )} + {/* Đang kết nối MQTT */} + {connectionState.online && !isSessionReady && ( + + )} + {/* XTerm component */} + + + + {/* Nút xóa màn hình */} + + + + + + + + )} +
+ ); +}; + +export default DeviceTerminalPage; diff --git a/src/pages/Manager/Device/index.tsx b/src/pages/Manager/Device/index.tsx index d0c33b9..66aadc2 100644 --- a/src/pages/Manager/Device/index.tsx +++ b/src/pages/Manager/Device/index.tsx @@ -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' && ( -