48 Commits

Author SHA1 Message Date
Proddy
76acffdb2e Merge pull request #2715 from MichaelDvP/dev
add SRC climate humidity #2714
2025-11-05 15:03:01 +01:00
MichaelDvP
1ac96aa02e add SRC climate humidity #2714 2025-11-05 14:50:32 +01:00
Proddy
3386ac7f8a Merge pull request #2701 from proddy/dev
web optimizations
2025-11-05 14:06:26 +01:00
proddy
1e4c157c28 package update 2025-11-05 14:04:50 +01:00
proddy
a1c5297eef add aria-label to buttons and text fields with no label #2710 2025-11-05 14:02:00 +01:00
proddy
cda04bef26 don't print out file names in bundle 2025-11-05 09:12:36 +01:00
proddy
1edf60b617 formatting 2025-11-05 09:01:35 +01:00
proddy
32cf4dd6c9 3.7.3-dev.25 2025-11-05 09:01:29 +01:00
proddy
f0caeb089d package update 2025-11-05 08:58:04 +01:00
proddy
23f1e7569c formatting 2025-11-05 08:57:56 +01:00
proddy
5087fdb5d6 prevent flicker when refreshing 2025-11-04 18:17:35 +01:00
proddy
b654a42229 package update 2025-11-04 18:06:09 +01:00
proddy
d312c8e592 update 2025-11-04 18:06:04 +01:00
proddy
319787bae3 switch our dialog with box 2025-11-04 18:05:57 +01:00
proddy
d124b04a2c tidy up error page 2025-11-04 18:05:41 +01:00
proddy
4ef8a3a163 package update 2025-11-04 01:00:46 +01:00
proddy
41b5cdddf2 smaller window 2025-11-04 01:00:39 +01:00
proddy
e10453b2fd don't show border 2025-11-04 01:00:27 +01:00
proddy
d2f665ab70 fix name 2025-11-04 01:00:17 +01:00
proddy
4083478b65 smaller font 2025-11-04 01:00:08 +01:00
proddy
01136a19a5 make wait uint16_t to allow max 2000 2025-11-03 18:18:08 +01:00
proddy
0b2df96461 add message test 2025-11-03 18:15:29 +01:00
Proddy
f66c3ff322 Merge branch 'dev' into dev 2025-11-03 18:02:43 +01:00
proddy
5d8fe89e5a python optimizations 2025-11-03 18:01:09 +01:00
proddy
17bdd87576 optimized window size detection 2025-11-03 17:51:51 +01:00
proddy
8f7c0a1d97 all values 2025-11-03 17:51:04 +01:00
proddy
3b453b18bc add section for all values 2025-11-03 17:50:53 +01:00
proddy
a1fc5bf54b fix lint warning 2025-11-03 17:50:43 +01:00
proddy
0cb413a579 package update 2025-11-03 17:50:30 +01:00
proddy
f2e38330ea fix window positioning 2025-11-02 19:46:11 +01:00
proddy
9f1cd04d45 auto-formatting 2025-11-02 13:01:03 +01:00
proddy
fe67f3a982 fix syslog, remove filter 2025-11-02 13:00:55 +01:00
proddy
2fd6e9ebf5 package update 2025-11-02 13:00:45 +01:00
proddy
67655c4b06 package updates 2025-11-01 16:12:09 +01:00
Proddy
2970aa5ba3 Merge branch 'dev' into dev 2025-11-01 16:10:21 +01:00
proddy
99a3ffcf17 optimizations, use md5 for hash 2025-11-01 16:04:47 +01:00
proddy
0edb844225 remove pnpm build 2025-10-31 18:41:37 +01:00
proddy
e3feb8f11e webUI target also builds 2025-10-31 18:41:27 +01:00
proddy
6b7534b7fb optimizations 2025-10-31 18:38:38 +01:00
proddy
ca1506de8b add custom error page 2025-10-31 18:36:18 +01:00
proddy
1cb535dea3 optimized for speed 2025-10-31 18:35:01 +01:00
proddy
5d32b6d383 package update 2025-10-31 18:34:39 +01:00
proddy
e6dbe020c1 package update 2025-10-29 12:40:28 +01:00
proddy
63cf1603b0 modules menuitem resized 2025-10-29 12:40:21 +01:00
proddy
aadb67fa79 speed up mock logging 2025-10-28 22:19:43 +01:00
proddy
4949471518 package update 2025-10-28 22:19:43 +01:00
proddy
9504723ef2 add back security 2025-10-28 22:19:43 +01:00
proddy
3abfb7bb9c optimizations 2025-10-28 22:19:43 +01:00
127 changed files with 5737 additions and 4540 deletions

View File

@@ -57,3 +57,4 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/).
- updated core libraries like AsyncTCP, AsyncWebServer and Modbus - updated core libraries like AsyncTCP, AsyncWebServer and Modbus
- remove command `scan deep` - remove command `scan deep`
- ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641) - ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641)
- optimized web for performance

View File

@@ -13,11 +13,11 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"build-hosted": "typesafe-i18n && vite build --mode hosted", "build-hosted": "typesafe-i18n && vite build --mode hosted",
"preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"mock-rest": "bun --watch ../mock-api/restServer.ts", "mock-rest": "bun --watch ../mock-api/restServer.ts",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite\"", "preview-standalone": "typesafe-i18n --no-watch && vite build && concurrently -c \"auto\" \"pnpm:mock-rest\" \"vite preview\"",
"standalone": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite dev\"",
"typesafe-i18n": "typesafe-i18n --no-watch", "typesafe-i18n": "typesafe-i18n --no-watch",
"webUI": "node progmem-generator.js", "webUI": "vite build && node progmem-generator.js",
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\"" "standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
@@ -26,11 +26,13 @@
"@alova/adapter-xhr": "2.2.1", "@alova/adapter-xhr": "2.2.1",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.4", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.4", "@mui/material": "^7.3.5",
"@preact/compat": "^18.3.1",
"@table-library/react-table-library": "4.1.15", "@table-library/react-table-library": "4.1.15",
"alova": "3.3.4", "alova": "3.3.4",
"async-validator": "^4.2.5", "async-validator": "^4.2.5",
"etag": "^1.8.1",
"formidable": "^3.5.4", "formidable": "^3.5.4",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -46,23 +48,24 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.28.5", "@babel/core": "^7.28.5",
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.1",
"@preact/compat": "^18.3.1", "@preact/compat": "^18.3.1",
"@preact/preset-vite": "^2.10.2", "@preact/preset-vite": "^2.10.2",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@trivago/prettier-plugin-sort-imports": "^6.0.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.2", "@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
"axe-core": "^4.11.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.39.0", "eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"rollup-plugin-visualizer": "^6.0.5", "rollup-plugin-visualizer": "^6.0.5",
"terser": "^5.44.0", "terser": "^5.44.1",
"typescript-eslint": "^8.46.2", "typescript-eslint": "^8.46.3",
"vite": "^7.1.12", "vite": "^7.2.0",
"vite-plugin-imagemin": "^0.6.1", "vite-plugin-imagemin": "^0.6.1",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"packageManager": "pnpm@10.20.0" "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
} }

389
interface/pnpm-lock.yaml generated
View File

@@ -18,11 +18,14 @@ importers:
specifier: ^11.14.1 specifier: ^11.14.1
version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@mui/icons-material': '@mui/icons-material':
specifier: ^7.3.4 specifier: ^7.3.5
version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) version: 7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@mui/material': '@mui/material':
specifier: ^7.3.4 specifier: ^7.3.5
version: 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@preact/compat':
specifier: ^18.3.1
version: 18.3.1(preact@10.27.2)
'@table-library/react-table-library': '@table-library/react-table-library':
specifier: 4.1.15 specifier: 4.1.15
version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 4.1.15(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -32,6 +35,9 @@ importers:
async-validator: async-validator:
specifier: ^4.2.5 specifier: ^4.2.5
version: 4.2.5 version: 4.2.5
etag:
specifier: ^1.8.1
version: 1.8.1
formidable: formidable:
specifier: ^3.5.4 specifier: ^3.5.4
version: 3.5.4 version: 3.5.4
@@ -73,17 +79,14 @@ importers:
specifier: ^7.28.5 specifier: ^7.28.5
version: 7.28.5 version: 7.28.5
'@eslint/js': '@eslint/js':
specifier: ^9.39.0 specifier: ^9.39.1
version: 9.39.0 version: 9.39.1
'@preact/compat':
specifier: ^18.3.1
version: 18.3.1(preact@10.27.2)
'@preact/preset-vite': '@preact/preset-vite':
specifier: ^2.10.2 specifier: ^2.10.2
version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))
'@trivago/prettier-plugin-sort-imports': '@trivago/prettier-plugin-sort-imports':
specifier: ^5.2.2 specifier: ^6.0.0
version: 5.2.2(prettier@3.6.2) version: 6.0.0(prettier@3.6.2)
'@types/node': '@types/node':
specifier: ^24.10.0 specifier: ^24.10.0
version: 24.10.0 version: 24.10.0
@@ -93,15 +96,18 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.2 specifier: ^19.2.2
version: 19.2.2(@types/react@19.2.2) version: 19.2.2(@types/react@19.2.2)
axe-core:
specifier: ^4.11.0
version: 4.11.0
concurrently: concurrently:
specifier: ^9.2.1 specifier: ^9.2.1
version: 9.2.1 version: 9.2.1
eslint: eslint:
specifier: ^9.39.0 specifier: ^9.39.1
version: 9.39.0 version: 9.39.1
eslint-config-prettier: eslint-config-prettier:
specifier: ^10.1.8 specifier: ^10.1.8
version: 10.1.8(eslint@9.39.0) version: 10.1.8(eslint@9.39.1)
prettier: prettier:
specifier: ^3.6.2 specifier: ^3.6.2
version: 3.6.2 version: 3.6.2
@@ -109,20 +115,20 @@ importers:
specifier: ^6.0.5 specifier: ^6.0.5
version: 6.0.5(rollup@4.52.5) version: 6.0.5(rollup@4.52.5)
terser: terser:
specifier: ^5.44.0 specifier: ^5.44.1
version: 5.44.0 version: 5.44.1
typescript-eslint: typescript-eslint:
specifier: ^8.46.2 specifier: ^8.46.3
version: 8.46.2(eslint@9.39.0)(typescript@5.9.3) version: 8.46.3(eslint@9.39.1)(typescript@5.9.3)
vite: vite:
specifier: ^7.1.12 specifier: ^7.2.0
version: 7.1.12(@types/node@24.10.0)(terser@5.44.0) version: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
vite-plugin-imagemin: vite-plugin-imagemin:
specifier: ^0.6.1 specifier: ^0.6.1
version: 0.6.1(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) version: 0.6.1(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))
vite-tsconfig-paths: vite-tsconfig-paths:
specifier: ^5.1.4 specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) version: 5.1.4(typescript@5.9.3)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))
packages: packages:
@@ -473,8 +479,8 @@ packages:
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.39.0': '@eslint/js@9.39.1':
resolution: {integrity: sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==} resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.7': '@eslint/object-schema@2.1.7':
@@ -528,27 +534,27 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@mui/core-downloads-tracker@7.3.4': '@mui/core-downloads-tracker@7.3.5':
resolution: {integrity: sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==} resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==}
'@mui/icons-material@7.3.4': '@mui/icons-material@7.3.5':
resolution: {integrity: sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==} resolution: {integrity: sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@mui/material': ^7.3.4 '@mui/material': ^7.3.5
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@types/react': '@types/react':
optional: true optional: true
'@mui/material@7.3.4': '@mui/material@7.3.5':
resolution: {integrity: sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==} resolution: {integrity: sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@emotion/react': ^11.5.0 '@emotion/react': ^11.5.0
'@emotion/styled': ^11.3.0 '@emotion/styled': ^11.3.0
'@mui/material-pigment-css': ^7.3.3 '@mui/material-pigment-css': ^7.3.5
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -562,8 +568,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@mui/private-theming@7.3.3': '@mui/private-theming@7.3.5':
resolution: {integrity: sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==} resolution: {integrity: sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -572,8 +578,8 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@mui/styled-engine@7.3.3': '@mui/styled-engine@7.3.5':
resolution: {integrity: sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==} resolution: {integrity: sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@emotion/react': ^11.4.1 '@emotion/react': ^11.4.1
@@ -585,8 +591,8 @@ packages:
'@emotion/styled': '@emotion/styled':
optional: true optional: true
'@mui/system@7.3.3': '@mui/system@7.3.5':
resolution: {integrity: sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==} resolution: {integrity: sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@emotion/react': ^11.5.0 '@emotion/react': ^11.5.0
@@ -601,16 +607,16 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@mui/types@7.4.7': '@mui/types@7.4.8':
resolution: {integrity: sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==} resolution: {integrity: sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==}
peerDependencies: peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@types/react': '@types/react':
optional: true optional: true
'@mui/utils@7.3.3': '@mui/utils@7.3.5':
resolution: {integrity: sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==} resolution: {integrity: sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peerDependencies: peerDependencies:
'@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -794,17 +800,20 @@ packages:
react: '>=16.8.0' react: '>=16.8.0'
react-dom: '>=16.8.0' react-dom: '>=16.8.0'
'@trivago/prettier-plugin-sort-imports@5.2.2': '@trivago/prettier-plugin-sort-imports@6.0.0':
resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} resolution: {integrity: sha512-Xarx55ow0R8oC7ViL5fPmDsg1EBa1dVhyZFVbFXNtPPJyW2w9bJADIla8YFSaNG9N06XfcklA9O9vmw4noNxkQ==}
engines: {node: '>18.12'} engines: {node: '>= 20'}
peerDependencies: peerDependencies:
'@vue/compiler-sfc': 3.x '@vue/compiler-sfc': 3.x
prettier: 2.x - 3.x prettier: 2.x - 3.x
prettier-plugin-ember-template-tag: '>= 2.0.0'
prettier-plugin-svelte: 3.x prettier-plugin-svelte: 3.x
svelte: 4.x || 5.x svelte: 4.x || 5.x
peerDependenciesMeta: peerDependenciesMeta:
'@vue/compiler-sfc': '@vue/compiler-sfc':
optional: true optional: true
prettier-plugin-ember-template-tag:
optional: true
prettier-plugin-svelte: prettier-plugin-svelte:
optional: true optional: true
svelte: svelte:
@@ -879,63 +888,63 @@ packages:
'@types/svgo@2.6.4': '@types/svgo@2.6.4':
resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==}
'@typescript-eslint/eslint-plugin@8.46.2': '@typescript-eslint/eslint-plugin@8.46.3':
resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} resolution: {integrity: sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
'@typescript-eslint/parser': ^8.46.2 '@typescript-eslint/parser': ^8.46.3
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.46.2': '@typescript-eslint/parser@8.46.3':
resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} resolution: {integrity: sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.46.2': '@typescript-eslint/project-service@8.46.3':
resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} resolution: {integrity: sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.46.2': '@typescript-eslint/scope-manager@8.46.3':
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} resolution: {integrity: sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.46.2': '@typescript-eslint/tsconfig-utils@8.46.3':
resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} resolution: {integrity: sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.46.2': '@typescript-eslint/type-utils@8.46.3':
resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} resolution: {integrity: sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.46.2': '@typescript-eslint/types@8.46.3':
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} resolution: {integrity: sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.46.2': '@typescript-eslint/typescript-estree@8.46.3':
resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} resolution: {integrity: sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.46.2': '@typescript-eslint/utils@8.46.3':
resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} resolution: {integrity: sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0' typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.46.2': '@typescript-eslint/visitor-keys@8.46.3':
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
acorn-jsx@5.3.2: acorn-jsx@5.3.2:
@@ -999,6 +1008,10 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
axe-core@4.11.0:
resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==}
engines: {node: '>=4'}
babel-plugin-macros@3.1.0: babel-plugin-macros@3.1.0:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'} engines: {node: '>=10', npm: '>=6'}
@@ -1014,8 +1027,8 @@ packages:
base64-js@1.5.1: base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.23: baseline-browser-mapping@2.8.24:
resolution: {integrity: sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==} resolution: {integrity: sha512-uUhTRDPXamakPyghwrUcjaGvvBqGrWvBHReoiULMIpOJVM9IYzQh83Xk2Onx5HlGI2o10NNCzcs9TG/S3TkwrQ==}
hasBin: true hasBin: true
bin-build@3.0.0: bin-build@3.0.0:
@@ -1324,8 +1337,8 @@ packages:
duplexer3@0.1.5: duplexer3@0.1.5:
resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==}
electron-to-chromium@1.5.244: electron-to-chromium@1.5.245:
resolution: {integrity: sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==} resolution: {integrity: sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1515,8 +1528,8 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@9.39.0: eslint@9.39.1:
resolution: {integrity: sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==} resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -1548,6 +1561,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
exec-buffer@3.2.0: exec-buffer@3.2.0:
resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==} resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@@ -2105,12 +2122,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.merge@4.6.2: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
logalot@2.1.0: logalot@2.1.0:
resolution: {integrity: sha512-Ah4CgdSRfeCJagxQhcVNMi9BfGYyEKLa6d7OA6xSbld/Hg3Cf2QiOa1mDpmG7Ve8LOH6DN3mdttzjQAvWTyVkw==} resolution: {integrity: sha512-Ah4CgdSRfeCJagxQhcVNMi9BfGYyEKLa6d7OA6xSbld/Hg3Cf2QiOa1mDpmG7Ve8LOH6DN3mdttzjQAvWTyVkw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2351,6 +2368,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'} engines: {node: '>=6'}
parse-imports-exports@0.2.4:
resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==}
parse-json@2.2.0: parse-json@2.2.0:
resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2359,6 +2379,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'} engines: {node: '>=8'}
parse-statements@1.0.11:
resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==}
path-exists@2.1.0: path-exists@2.1.0:
resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==} resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2828,8 +2851,8 @@ packages:
resolution: {integrity: sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==} resolution: {integrity: sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==}
engines: {node: '>=4'} engines: {node: '>=4'}
terser@5.44.0: terser@5.44.1:
resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==}
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
@@ -2904,8 +2927,8 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=3.5.1' typescript: '>=3.5.1'
typescript-eslint@8.46.2: typescript-eslint@8.46.3:
resolution: {integrity: sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==} resolution: {integrity: sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 eslint: ^8.57.0 || ^9.0.0
@@ -2976,8 +2999,8 @@ packages:
vite: vite:
optional: true optional: true
vite@7.1.12: vite@7.2.0:
resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} resolution: {integrity: sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -3377,9 +3400,9 @@ snapshots:
'@esbuild/win32-x64@0.25.12': '@esbuild/win32-x64@0.25.12':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.39.0)': '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)':
dependencies: dependencies:
eslint: 9.39.0 eslint: 9.39.1
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {} '@eslint-community/regexpp@4.12.2': {}
@@ -3414,7 +3437,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@eslint/js@9.39.0': {} '@eslint/js@9.39.1': {}
'@eslint/object-schema@2.1.7': {} '@eslint/object-schema@2.1.7': {}
@@ -3464,23 +3487,23 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mui/core-downloads-tracker@7.3.4': {} '@mui/core-downloads-tracker@7.3.5': {}
'@mui/icons-material@7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': '@mui/icons-material@7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0 react: 19.2.0
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': '@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@mui/core-downloads-tracker': 7.3.4 '@mui/core-downloads-tracker': 7.3.5
'@mui/system': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@mui/types': 7.4.7(@types/react@19.2.2) '@mui/types': 7.4.8(@types/react@19.2.2)
'@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0)
'@popperjs/core': 2.11.8 '@popperjs/core': 2.11.8
'@types/react-transition-group': 4.4.12(@types/react@19.2.2) '@types/react-transition-group': 4.4.12(@types/react@19.2.2)
clsx: 2.1.1 clsx: 2.1.1
@@ -3495,16 +3518,16 @@ snapshots:
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@types/react': 19.2.2 '@types/react': 19.2.2
'@mui/private-theming@7.3.3(@types/react@19.2.2)(react@19.2.0)': '@mui/private-theming@7.3.5(@types/react@19.2.2)(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0)
prop-types: 15.8.1 prop-types: 15.8.1
react: 19.2.0 react: 19.2.0
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@mui/styled-engine@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)': '@mui/styled-engine@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@emotion/cache': 11.14.0 '@emotion/cache': 11.14.0
@@ -3517,13 +3540,13 @@ snapshots:
'@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0)
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': '@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@mui/private-theming': 7.3.3(@types/react@19.2.2)(react@19.2.0) '@mui/private-theming': 7.3.5(@types/react@19.2.2)(react@19.2.0)
'@mui/styled-engine': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) '@mui/styled-engine': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
'@mui/types': 7.4.7(@types/react@19.2.2) '@mui/types': 7.4.8(@types/react@19.2.2)
'@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0)
clsx: 2.1.1 clsx: 2.1.1
csstype: 3.1.3 csstype: 3.1.3
prop-types: 15.8.1 prop-types: 15.8.1
@@ -3533,16 +3556,16 @@ snapshots:
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)
'@types/react': 19.2.2 '@types/react': 19.2.2
'@mui/types@7.4.7(@types/react@19.2.2)': '@mui/types@7.4.8(@types/react@19.2.2)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
'@mui/utils@7.3.3(@types/react@19.2.2)(react@19.2.0)': '@mui/utils@7.3.5(@types/react@19.2.2)(react@19.2.0)':
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
'@mui/types': 7.4.7(@types/react@19.2.2) '@mui/types': 7.4.8(@types/react@19.2.2)
'@types/prop-types': 15.7.15 '@types/prop-types': 15.7.15
clsx: 2.1.1 clsx: 2.1.1
prop-types: 15.8.1 prop-types: 15.8.1
@@ -3575,18 +3598,18 @@ snapshots:
dependencies: dependencies:
preact: 10.27.2 preact: 10.27.2
'@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0))': '@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.5)
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.5)
'@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) '@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))
'@rollup/pluginutils': 4.2.1 '@rollup/pluginutils': 4.2.1
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5) babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5)
debug: 4.4.3 debug: 4.4.3
picocolors: 1.1.1 picocolors: 1.1.1
vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) vite-prerender-plugin: 0.5.12(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))
transitivePeerDependencies: transitivePeerDependencies:
- preact - preact
- supports-color - supports-color
@@ -3599,7 +3622,7 @@ snapshots:
'@prefresh/utils@1.2.1': {} '@prefresh/utils@1.2.1': {}
'@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0))': '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@prefresh/babel-plugin': 0.5.2 '@prefresh/babel-plugin': 0.5.2
@@ -3607,7 +3630,7 @@ snapshots:
'@prefresh/utils': 1.2.1 '@prefresh/utils': 1.2.1
'@rollup/pluginutils': 4.2.1 '@rollup/pluginutils': 4.2.1
preact: 10.27.2 preact: 10.27.2
vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3693,14 +3716,16 @@ snapshots:
react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-window: 1.8.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-window: 1.8.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)': '@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.6.2)':
dependencies: dependencies:
'@babel/generator': 7.28.5 '@babel/generator': 7.28.5
'@babel/parser': 7.28.5 '@babel/parser': 7.28.5
'@babel/traverse': 7.28.5 '@babel/traverse': 7.28.5
'@babel/types': 7.28.5 '@babel/types': 7.28.5
javascript-natural-sort: 0.7.1 javascript-natural-sort: 0.7.1
lodash: 4.17.21 lodash-es: 4.17.21
minimatch: 9.0.5
parse-imports-exports: 0.2.4
prettier: 3.6.2 prettier: 3.6.2
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -3781,15 +3806,15 @@ snapshots:
dependencies: dependencies:
'@types/node': 24.10.0 '@types/node': 24.10.0
'@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/type-utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.3
eslint: 9.39.0 eslint: 9.39.1
graphemer: 1.4.0 graphemer: 1.4.0
ignore: 7.0.5 ignore: 7.0.5
natural-compare: 1.4.0 natural-compare: 1.4.0
@@ -3798,56 +3823,56 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3)': '@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.3
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.0 eslint: 9.39.1
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': '@typescript-eslint/project-service@8.46.3(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3)
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
debug: 4.4.3 debug: 4.4.3
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/scope-manager@8.46.2': '@typescript-eslint/scope-manager@8.46.3':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
'@typescript-eslint/visitor-keys': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.3
'@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': '@typescript-eslint/tsconfig-utils@8.46.3(typescript@5.9.3)':
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
'@typescript-eslint/type-utils@8.46.2(eslint@9.39.0)(typescript@5.9.3)': '@typescript-eslint/type-utils@8.46.3(eslint@9.39.1)(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
debug: 4.4.3 debug: 4.4.3
eslint: 9.39.0 eslint: 9.39.1
ts-api-utils: 2.1.0(typescript@5.9.3) ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/types@8.46.2': {} '@typescript-eslint/types@8.46.3': {}
'@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': '@typescript-eslint/typescript-estree@8.46.3(typescript@5.9.3)':
dependencies: dependencies:
'@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) '@typescript-eslint/project-service': 8.46.3(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3)
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
'@typescript-eslint/visitor-keys': 8.46.2 '@typescript-eslint/visitor-keys': 8.46.3
debug: 4.4.3 debug: 4.4.3
fast-glob: 3.3.3 fast-glob: 3.3.3
is-glob: 4.0.3 is-glob: 4.0.3
@@ -3858,20 +3883,20 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/utils@8.46.2(eslint@9.39.0)(typescript@5.9.3)': '@typescript-eslint/utils@8.46.3(eslint@9.39.1)(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0) '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
'@typescript-eslint/scope-manager': 8.46.2 '@typescript-eslint/scope-manager': 8.46.3
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
eslint: 9.39.0 eslint: 9.39.1
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@typescript-eslint/visitor-keys@8.46.2': '@typescript-eslint/visitor-keys@8.46.3':
dependencies: dependencies:
'@typescript-eslint/types': 8.46.2 '@typescript-eslint/types': 8.46.3
eslint-visitor-keys: 4.2.1 eslint-visitor-keys: 4.2.1
acorn-jsx@5.3.2(acorn@8.15.0): acorn-jsx@5.3.2(acorn@8.15.0):
@@ -3922,6 +3947,8 @@ snapshots:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
axe-core@4.11.0: {}
babel-plugin-macros@3.1.0: babel-plugin-macros@3.1.0:
dependencies: dependencies:
'@babel/runtime': 7.28.4 '@babel/runtime': 7.28.4
@@ -3936,7 +3963,7 @@ snapshots:
base64-js@1.5.1: {} base64-js@1.5.1: {}
baseline-browser-mapping@2.8.23: {} baseline-browser-mapping@2.8.24: {}
bin-build@3.0.0: bin-build@3.0.0:
dependencies: dependencies:
@@ -3993,9 +4020,9 @@ snapshots:
browserslist@4.27.0: browserslist@4.27.0:
dependencies: dependencies:
baseline-browser-mapping: 2.8.23 baseline-browser-mapping: 2.8.24
caniuse-lite: 1.0.30001753 caniuse-lite: 1.0.30001753
electron-to-chromium: 1.5.244 electron-to-chromium: 1.5.245
node-releases: 2.0.27 node-releases: 2.0.27
update-browserslist-db: 1.1.4(browserslist@4.27.0) update-browserslist-db: 1.1.4(browserslist@4.27.0)
@@ -4340,7 +4367,7 @@ snapshots:
duplexer3@0.1.5: {} duplexer3@0.1.5: {}
electron-to-chromium@1.5.244: {} electron-to-chromium@1.5.245: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
@@ -4483,9 +4510,9 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-config-prettier@10.1.8(eslint@9.39.0): eslint-config-prettier@10.1.8(eslint@9.39.1):
dependencies: dependencies:
eslint: 9.39.0 eslint: 9.39.1
eslint-scope@8.4.0: eslint-scope@8.4.0:
dependencies: dependencies:
@@ -4496,15 +4523,15 @@ snapshots:
eslint-visitor-keys@4.2.1: {} eslint-visitor-keys@4.2.1: {}
eslint@9.39.0: eslint@9.39.1:
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0) '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1)
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.1 '@eslint/config-array': 0.21.1
'@eslint/config-helpers': 0.4.2 '@eslint/config-helpers': 0.4.2
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
'@eslint/eslintrc': 3.3.1 '@eslint/eslintrc': 3.3.1
'@eslint/js': 9.39.0 '@eslint/js': 9.39.1
'@eslint/plugin-kit': 0.4.1 '@eslint/plugin-kit': 0.4.1
'@humanfs/node': 0.16.7 '@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/module-importer': 1.0.1
@@ -4555,6 +4582,8 @@ snapshots:
esutils@2.0.3: {} esutils@2.0.3: {}
etag@1.8.1: {}
exec-buffer@3.2.0: exec-buffer@3.2.0:
dependencies: dependencies:
execa: 0.7.0 execa: 0.7.0
@@ -5138,9 +5167,9 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.merge@4.6.2: {} lodash-es@4.17.21: {}
lodash@4.17.21: {} lodash.merge@4.6.2: {}
logalot@2.1.0: logalot@2.1.0:
dependencies: dependencies:
@@ -5376,6 +5405,10 @@ snapshots:
dependencies: dependencies:
callsites: 3.1.0 callsites: 3.1.0
parse-imports-exports@0.2.4:
dependencies:
parse-statements: 1.0.11
parse-json@2.2.0: parse-json@2.2.0:
dependencies: dependencies:
error-ex: 1.3.4 error-ex: 1.3.4
@@ -5387,6 +5420,8 @@ snapshots:
json-parse-even-better-errors: 2.3.1 json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4 lines-and-columns: 1.2.4
parse-statements@1.0.11: {}
path-exists@2.1.0: path-exists@2.1.0:
dependencies: dependencies:
pinkie-promise: 2.0.1 pinkie-promise: 2.0.1
@@ -5820,7 +5855,7 @@ snapshots:
temp-dir: 1.0.0 temp-dir: 1.0.0
uuid: 3.4.0 uuid: 3.4.0
terser@5.44.0: terser@5.44.1:
dependencies: dependencies:
'@jridgewell/source-map': 0.3.11 '@jridgewell/source-map': 0.3.11
acorn: 8.15.0 acorn: 8.15.0
@@ -5884,13 +5919,13 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
typescript-eslint@8.46.2(eslint@9.39.0)(typescript@5.9.3): typescript-eslint@8.46.3(eslint@9.39.1)(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/parser': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3)
'@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3)
eslint: 9.39.0 eslint: 9.39.1
typescript: 5.9.3 typescript: 5.9.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5935,7 +5970,7 @@ snapshots:
spdx-correct: 3.2.0 spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1 spdx-expression-parse: 3.0.1
vite-plugin-imagemin@0.6.1(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): vite-plugin-imagemin@0.6.1(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)):
dependencies: dependencies:
'@types/imagemin': 7.0.1 '@types/imagemin': 7.0.1
'@types/imagemin-gifsicle': 7.0.4 '@types/imagemin-gifsicle': 7.0.4
@@ -5960,11 +5995,11 @@ snapshots:
imagemin-webp: 6.1.0 imagemin-webp: 6.1.0
jpegtran-bin: 6.0.1 jpegtran-bin: 6.0.1
pathe: 0.2.0 pathe: 0.2.0
vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): vite-prerender-plugin@0.5.12(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)):
dependencies: dependencies:
kolorist: 1.8.0 kolorist: 1.8.0
magic-string: 0.30.21 magic-string: 0.30.21
@@ -5972,20 +6007,20 @@ snapshots:
simple-code-frame: 1.3.0 simple-code-frame: 1.3.0
source-map: 0.7.6 source-map: 0.7.6
stack-trace: 1.0.0-pre2 stack-trace: 1.0.0-pre2
vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)):
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3
globrex: 0.1.2 globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3) tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies: optionalDependencies:
vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
- typescript - typescript
vite@7.1.12(@types/node@24.10.0)(terser@5.44.0): vite@7.2.0(@types/node@24.10.0)(terser@5.44.1):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -5996,7 +6031,7 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 24.10.0 '@types/node': 24.10.0
fsevents: 2.3.3 fsevents: 2.3.3
terser: 5.44.0 terser: 5.44.1
which-typed-array@1.1.19: which-typed-array@1.1.19:
dependencies: dependencies:

View File

@@ -1,10 +1,9 @@
import crypto from 'crypto'; import etag from 'etag';
import { import {
createWriteStream, createWriteStream,
existsSync, existsSync,
readFileSync, readFileSync,
readdirSync, readdirSync,
statSync,
unlinkSync unlinkSync
} from 'fs'; } from 'fs';
import mime from 'mime-types'; import mime from 'mime-types';
@@ -36,7 +35,7 @@ const generateWWWClass =
class WWWData { class WWWData {
${INDENT}public: ${INDENT}public:
${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { ${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "${f.hash}");`).join('\n')} ${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')}
${INDENT.repeat(2)}} ${INDENT.repeat(2)}}
}; };
`; `;
@@ -71,7 +70,8 @@ const writeFile = (relativeFilePath, buffer) => {
writeStream.write(`const uint8_t ${variable}[] = {`); writeStream.write(`const uint8_t ${variable}[] = {`);
const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); const zipBuffer = zlib.gzipSync(buffer, { level: 9 });
const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); // const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex');
const hash = etag(zipBuffer); // use smaller md5 instead of sha256
zipBuffer.forEach((b) => { zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) { if (!(size % bytesPerLine)) {

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { ToastContainer, Zoom } from 'react-toastify'; import { ToastContainer, Zoom } from 'react-toastify';
import AppRouting from 'AppRouting'; import AppRouting from 'AppRouting';
@@ -23,6 +23,26 @@ const AVAILABLE_LOCALES = [
'cz' 'cz'
] as Locales[]; ] as Locales[];
// Static toast configuration - no need to recreate on every render
const TOAST_CONTAINER_PROPS = {
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
};
const App = memo(() => { const App = memo(() => {
const [wasLoaded, setWasLoaded] = useState(false); const [wasLoaded, setWasLoaded] = useState(false);
const [locale, setLocale] = useState<Locales>('en'); const [locale, setLocale] = useState<Locales>('en');
@@ -41,36 +61,13 @@ const App = memo(() => {
void initializeLocale(); void initializeLocale();
}, [initializeLocale]); }, [initializeLocale]);
// Memoize toast container props to prevent recreation
const toastContainerProps = useMemo(
() => ({
position: 'bottom-left' as const,
autoClose: 3000,
hideProgressBar: false,
newestOnTop: false,
closeOnClick: true,
rtl: false,
pauseOnFocusLoss: true,
draggable: false,
pauseOnHover: false,
transition: Zoom,
closeButton: false,
theme: 'dark' as const,
toastStyle: {
border: '1px solid #177ac9',
width: 'fit-content'
}
}),
[]
);
if (!wasLoaded) return null; if (!wasLoaded) return null;
return ( return (
<TypesafeI18n locale={locale}> <TypesafeI18n locale={locale}>
<CustomTheme> <CustomTheme>
<AppRouting /> <AppRouting />
<ToastContainer {...toastContainerProps} /> <ToastContainer {...TOAST_CONTAINER_PROPS} />
</CustomTheme> </CustomTheme>
</TypesafeI18n> </TypesafeI18n>
); );

View File

@@ -1,32 +1,51 @@
import { useContext, useEffect } from 'react'; import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react';
import { Navigate, Route, Routes } from 'react-router'; import { Navigate, Route, Routes } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AuthenticatedRouting from 'AuthenticatedRouting'; import {
import SignIn from 'SignIn'; LoadingSpinner,
import { RequireAuthenticated, RequireUnauthenticated } from 'components'; RequireAuthenticated,
RequireUnauthenticated
} from 'components';
import { Authentication, AuthenticationContext } from 'contexts/authentication'; import { Authentication, AuthenticationContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
// Lazy load route components for better code splitting
const SignIn = lazy(() => import('SignIn'));
const AuthenticatedRouting = lazy(() => import('AuthenticatedRouting'));
interface SecurityRedirectProps { interface SecurityRedirectProps {
message: string; readonly message: string;
signOut?: boolean; readonly signOut?: boolean;
} }
const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => { const RootRedirect: FC<SecurityRedirectProps> = memo(
const authenticationContext = useContext(AuthenticationContext); ({ message, signOut = false }) => {
useEffect(() => { const { signOut: contextSignOut } = useContext(AuthenticationContext);
signOut && authenticationContext.signOut(false); const hasShownToast = useRef(false);
toast.success(message);
}, [message, signOut, authenticationContext]);
return <Navigate to="/" />;
};
const AppRouting = () => { useEffect(() => {
// Prevent duplicate toasts on strict mode or re-renders
if (!hasShownToast.current) {
hasShownToast.current = true;
if (signOut) {
contextSignOut(false);
}
toast.success(message);
}
// Only run once on mount - using ref to track execution
}, []);
return <Navigate to="/" replace />;
}
);
const AppRouting: FC = memo(() => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
return ( return (
<Authentication> <Authentication>
<Suspense fallback={<LoadingSpinner />}>
<Routes> <Routes>
<Route <Route
path="/unauthorized" path="/unauthorized"
@@ -53,8 +72,9 @@ const AppRouting = () => {
} }
/> />
</Routes> </Routes>
</Suspense>
</Authentication> </Authentication>
); );
}; });
export default AppRouting; export default AppRouting;

View File

@@ -1,38 +1,43 @@
import { useContext } from 'react'; import { Suspense, lazy, memo, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router'; import { Navigate, Route, Routes } from 'react-router';
import CustomEntities from 'app/main/CustomEntities'; import { Layout, LoadingSpinner } from 'components';
import Customizations from 'app/main/Customizations';
import Dashboard from 'app/main/Dashboard';
import Devices from 'app/main/Devices';
import Help from 'app/main/Help';
import Modules from 'app/main/Modules';
import Scheduler from 'app/main/Scheduler';
import Sensors from 'app/main/Sensors';
import APSettings from 'app/settings/APSettings';
import ApplicationSettings from 'app/settings/ApplicationSettings';
import DownloadUpload from 'app/settings/DownloadUpload';
import MqttSettings from 'app/settings/MqttSettings';
import NTPSettings from 'app/settings/NTPSettings';
import Settings from 'app/settings/Settings';
import Network from 'app/settings/network/Network';
import Security from 'app/settings/security/Security';
import APStatus from 'app/status/APStatus';
import Activity from 'app/status/Activity';
import HardwareStatus from 'app/status/HardwareStatus';
import MqttStatus from 'app/status/MqttStatus';
import NTPStatus from 'app/status/NTPStatus';
import NetworkStatus from 'app/status/NetworkStatus';
import Status from 'app/status/Status';
import SystemLog from 'app/status/SystemLog';
import Version from 'app/status/Version';
import { Layout } from 'components';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
const AuthenticatedRouting = () => { // Lazy load all route components for better code splitting
const Dashboard = lazy(() => import('app/main/Dashboard'));
const Devices = lazy(() => import('app/main/Devices'));
const Sensors = lazy(() => import('app/main/Sensors'));
const Help = lazy(() => import('app/main/Help'));
const Customizations = lazy(() => import('app/main/Customizations'));
const Scheduler = lazy(() => import('app/main/Scheduler'));
const CustomEntities = lazy(() => import('app/main/CustomEntities'));
const Modules = lazy(() => import('app/main/Modules'));
const Status = lazy(() => import('app/status/Status'));
const HardwareStatus = lazy(() => import('app/status/HardwareStatus'));
const Activity = lazy(() => import('app/status/Activity'));
const SystemLog = lazy(() => import('app/status/SystemLog'));
const MqttStatus = lazy(() => import('app/status/MqttStatus'));
const NTPStatus = lazy(() => import('app/status/NTPStatus'));
const APStatus = lazy(() => import('app/status/APStatus'));
const NetworkStatus = lazy(() => import('app/status/NetworkStatus'));
const Version = lazy(() => import('app/status/Version'));
const Settings = lazy(() => import('app/settings/Settings'));
const ApplicationSettings = lazy(() => import('app/settings/ApplicationSettings'));
const MqttSettings = lazy(() => import('app/settings/MqttSettings'));
const NTPSettings = lazy(() => import('app/settings/NTPSettings'));
const APSettings = lazy(() => import('app/settings/APSettings'));
const DownloadUpload = lazy(() => import('app/settings/DownloadUpload'));
const Network = lazy(() => import('app/settings/network/Network'));
const Security = lazy(() => import('app/settings/security/Security'));
const AuthenticatedRouting = memo(() => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
return ( return (
<Layout> <Layout>
<Suspense fallback={<LoadingSpinner />}>
<Routes> <Routes>
<Route path="/dashboard/*" element={<Dashboard />} /> <Route path="/dashboard/*" element={<Dashboard />} />
<Route path="/devices/*" element={<Devices />} /> <Route path="/devices/*" element={<Devices />} />
@@ -52,7 +57,10 @@ const AuthenticatedRouting = () => {
{me.admin && ( {me.admin && (
<> <>
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/settings/application" element={<ApplicationSettings />} /> <Route
path="/settings/application"
element={<ApplicationSettings />}
/>
<Route path="/settings/mqtt" element={<MqttSettings />} /> <Route path="/settings/mqtt" element={<MqttSettings />} />
<Route path="/settings/ntp" element={<NTPSettings />} /> <Route path="/settings/ntp" element={<NTPSettings />} />
<Route path="/settings/ap" element={<APSettings />} /> <Route path="/settings/ap" element={<APSettings />} />
@@ -70,8 +78,9 @@ const AuthenticatedRouting = () => {
<Route path="/*" element={<Navigate to="/" />} /> <Route path="/*" element={<Navigate to="/" />} />
</Routes> </Routes>
</Suspense>
</Layout> </Layout>
); );
}; });
export default AuthenticatedRouting; export default AuthenticatedRouting;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import ForwardIcon from '@mui/icons-material/Forward'; import ForwardIcon from '@mui/icons-material/Forward';
@@ -19,7 +19,7 @@ import type { SignInRequest } from 'types';
import { onEnterCallback, updateValue } from 'utils'; import { onEnterCallback, updateValue } from 'utils';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators'; import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators';
const SignIn = () => { const SignIn = memo(() => {
const authenticationContext = useContext(AuthenticationContext); const authenticationContext = useContext(AuthenticationContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -42,9 +42,18 @@ const SignIn = () => {
} }
}); });
const updateLoginRequestValue = updateValue(setSignInRequest); // Memoize callback to prevent recreation on every render
const updateLoginRequestValue = useMemo(
() =>
updateValue((updater) =>
setSignInRequest(
updater as unknown as (prevState: SignInRequest) => SignInRequest
)
),
[]
);
const signIn = async () => { const signIn = useCallback(async () => {
await callSignIn(signInRequest).catch((event: Error) => { await callSignIn(signInRequest).catch((event: Error) => {
if (event.message === 'Unauthorized') { if (event.message === 'Unauthorized') {
toast.warning(LL.INVALID_LOGIN()); toast.warning(LL.INVALID_LOGIN());
@@ -53,9 +62,9 @@ const SignIn = () => {
} }
setProcessing(false); setProcessing(false);
}); });
}; }, [callSignIn, signInRequest, LL]);
const validateAndSignIn = async () => { const validateAndSignIn = useCallback(async () => {
setProcessing(true); setProcessing(true);
SIGN_IN_REQUEST_VALIDATOR.messages({ SIGN_IN_REQUEST_VALIDATOR.messages({
required: LL.IS_REQUIRED('%s') required: LL.IS_REQUIRED('%s')
@@ -67,9 +76,10 @@ const SignIn = () => {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
setProcessing(false); setProcessing(false);
} }
}; }, [signInRequest, signIn, LL]);
const submitOnEnter = onEnterCallback(signIn); // Memoize callback to prevent recreation on every render
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
return ( return (
<Box <Box
@@ -144,6 +154,6 @@ const SignIn = () => {
</Paper> </Paper>
</Box> </Box>
); );
}; });
export default SignIn; export default SignIn;

View File

@@ -20,19 +20,18 @@ import type {
WriteTemperatureSensor WriteTemperatureSensor
} from '../app/main/types'; } from '../app/main/types';
const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const };
// Dashboard // Dashboard
export const readDashboard = () => export const readDashboard = () =>
alovaInstance.Get<DashboardData>('/rest/dashboardData', { alovaInstance.Get<DashboardData>('/rest/dashboardData', MSGPACK_CONFIG);
responseType: 'arraybuffer' // uses msgpack
});
// Devices // Devices
export const readCoreData = () => alovaInstance.Get<CoreData>(`/rest/coreData`); export const readCoreData = () => alovaInstance.Get<CoreData>('/rest/coreData');
export const readDeviceData = (id: number) => export const readDeviceData = (id: number) =>
alovaInstance.Get<DeviceData>('/rest/deviceData', { alovaInstance.Get<DeviceData>('/rest/deviceData', {
// alovaInstance.Get<DeviceData>(`/rest/deviceData/${id}`, {
params: { id }, params: { id },
responseType: 'arraybuffer' // uses msgpack ...MSGPACK_CONFIG
}); });
export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) => export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) =>
alovaInstance.Post('/rest/writeDeviceValue', data); alovaInstance.Post('/rest/writeDeviceValue', data);
@@ -66,13 +65,13 @@ export const callAction = (action: Action) =>
// SettingsCustomization // SettingsCustomization
export const readDeviceEntities = (id: number) => export const readDeviceEntities = (id: number) =>
// alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities/${id}`, { alovaInstance.Get<DeviceEntity[]>('/rest/deviceEntities', {
alovaInstance.Get<DeviceEntity[]>(`/rest/deviceEntities`, {
params: { id }, params: { id },
responseType: 'arraybuffer', ...MSGPACK_CONFIG,
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue // @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) { transform(data) {
return (data as DeviceEntity[]).map((de: DeviceEntity) => ({ const entities = data as DeviceEntity[];
return entities.map((de) => ({
...de, ...de,
o_m: de.m, o_m: de.m,
o_cn: de.cn, o_cn: de.cn,
@@ -95,7 +94,8 @@ export const readSchedule = () =>
alovaInstance.Get<ScheduleItem[]>('/rest/schedule', { alovaInstance.Get<ScheduleItem[]>('/rest/schedule', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue // @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) { transform(data) {
return (data as Schedule).schedule.map((si: ScheduleItem) => ({ const schedule = (data as Schedule).schedule;
return schedule.map((si) => ({
...si, ...si,
o_id: si.id, o_id: si.id,
o_active: si.active, o_active: si.active,
@@ -115,7 +115,8 @@ export const writeSchedule = (data: Schedule) =>
export const readModules = () => export const readModules = () =>
alovaInstance.Get<ModuleItem[]>('/rest/modules', { alovaInstance.Get<ModuleItem[]>('/rest/modules', {
transform(data) { transform(data) {
return (data as Modules).modules.map((mi: ModuleItem) => ({ const modules = (data as Modules).modules;
return modules.map((mi) => ({
...mi, ...mi,
o_enabled: mi.enabled, o_enabled: mi.enabled,
o_license: mi.license o_license: mi.license
@@ -133,7 +134,8 @@ export const readCustomEntities = () =>
alovaInstance.Get<EntityItem[]>('/rest/customEntities', { alovaInstance.Get<EntityItem[]>('/rest/customEntities', {
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue // @ts-expect-error - exactOptionalPropertyTypes compatibility issue
transform(data) { transform(data) {
return (data as Entities).entities.map((ei: EntityItem) => ({ const entities = (data as Entities).entities;
return entities.map((ei) => ({
...ei, ...ei,
o_id: ei.id, o_id: ei.id,
o_ram: ei.ram, o_ram: ei.ram,

View File

@@ -4,55 +4,57 @@ import ReactHook from 'alova/react';
import { unpack } from './unpack'; import { unpack } from './unpack';
export const ACCESS_TOKEN = 'access_token'; export const ACCESS_TOKEN = 'access_token' as const;
// Cached token to avoid repeated localStorage access
let cachedToken: string | null = null;
const getAccessToken = (): string | null => {
if (cachedToken === null) {
cachedToken = localStorage.getItem(ACCESS_TOKEN);
}
return cachedToken;
};
// Clear token cache when needed (e.g., on logout)
export const clearTokenCache = (): void => {
cachedToken = null;
};
const handleResponse = async (response: AlovaXHRResponse) => {
// Handle various HTTP status codes
if (response.status === 205) {
throw new Error('Reboot required');
}
if (response.status === 400) {
throw new Error('Request Failed');
}
if (response.status >= 400) {
throw new Error(response.statusText);
}
const data = (await response.data) as ArrayBuffer;
// Unpack MessagePack data if ArrayBuffer
if (data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
};
export const alovaInstance = createAlova({ export const alovaInstance = createAlova({
statesHook: ReactHook, statesHook: ReactHook,
// timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none
cacheFor: null, // disable cache cacheFor: null, // disable cache
// cacheFor: {
// GET: {
// mode: 'memory',
// expire: 60 * 10 * 1000 // 60 seconds in cache
// }
// },
requestAdapter: xhrRequestAdapter(), requestAdapter: xhrRequestAdapter(),
beforeRequest(method) { beforeRequest(method) {
if (localStorage.getItem(ACCESS_TOKEN)) { const token = getAccessToken();
method.config.headers.Authorization = if (token) {
'Bearer ' + localStorage.getItem(ACCESS_TOKEN); method.config.headers.Authorization = `Bearer ${token}`;
} }
// for simulating very slow networks
// return new Promise((resolve) => {
// const random = 3000 + Math.random() * 2000;
// setTimeout(resolve, Math.floor(random));
// });
}, },
responded: { responded: {
onSuccess: async (response: AlovaXHRResponse) => { onSuccess: handleResponse
// if (response.status === 202) {
// throw new Error('Wait'); // wifi scan in progress
// } else
if (response.status === 205) {
throw new Error('Reboot required');
} else if (response.status === 400) {
throw new Error('Request Failed');
} else if (response.status >= 400) {
throw new Error(response.statusText);
}
const data: ArrayBuffer = (await response.data) as ArrayBuffer;
if (response.data instanceof ArrayBuffer) {
return unpack(data) as ArrayBuffer;
}
return data;
}
// Interceptor for request failure. This interceptor will be entered when the request is wrong.
// http errors like 401 (unauthorized) are handled either in the methods or AuthenticatedRouting()
// onError: (error, method) => {
// alert(error.message);
// }
} }
}); });

View File

@@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty
import { alovaInstance } from './endpoints'; import { alovaInstance } from './endpoints';
const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds
export const readNetworkStatus = () => export const readNetworkStatus = () =>
alovaInstance.Get<NetworkStatusType>('/rest/networkStatus'); alovaInstance.Get<NetworkStatusType>('/rest/networkStatus');
export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks'); export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks');
export const listNetworks = () => export const listNetworks = () =>
alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', { alovaInstance.Get<WiFiNetworkList>('/rest/listNetworks', {
timeout: 20000 // 20 seconds timeout: LIST_NETWORKS_TIMEOUT
}); });
export const readNetworkSettings = () => export const readNetworkSettings = () =>
alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings'); alovaInstance.Get<NetworkSettingsType>('/rest/networkSettings');

View File

@@ -6,7 +6,7 @@ export const readNTPStatus = () =>
alovaInstance.Get<NTPStatusType>('/rest/ntpStatus'); alovaInstance.Get<NTPStatusType>('/rest/ntpStatus');
export const readNTPSettings = () => export const readNTPSettings = () =>
alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings', {}); alovaInstance.Get<NTPSettingsType>('/rest/ntpSettings');
export const updateNTPSettings = (data: NTPSettingsType) => export const updateNTPSettings = (data: NTPSettingsType) =>
alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data); alovaInstance.Post<NTPSettingsType>('/rest/ntpSettings', data);

View File

@@ -8,7 +8,7 @@ export const readSystemStatus = () =>
// SystemLog // SystemLog
export const readLogSettings = () => export const readLogSettings = () =>
alovaInstance.Get<LogSettings>(`/rest/logSettings`); alovaInstance.Get<LogSettings>('/rest/logSettings');
export const updateLogSettings = (data: LogSettings) => export const updateLogSettings = (data: LogSettings) =>
alovaInstance.Post('/rest/logSettings', data); alovaInstance.Post('/rest/logSettings', data);
export const fetchLogES = () => alovaInstance.Get('/es/log'); export const fetchLogES = () => alovaInstance.Get('/es/log');
@@ -36,10 +36,12 @@ export const getDevVersion = () =>
} }
}); });
const UPLOAD_TIMEOUT = 60000; // 1 minute
export const uploadFile = (file: File) => { export const uploadFile = (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return alovaInstance.Post('/rest/uploadFile', formData, { return alovaInstance.Post('/rest/uploadFile', formData, {
timeout: 60000 // override timeout for uploading firmware - 1 minute timeout: UPLOAD_TIMEOUT
}); });
}; };

View File

@@ -54,7 +54,7 @@ export class Unpackr {
} }
Object.assign(this, options); Object.assign(this, options);
} }
unpack(source, options?: any) { unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) {
if (src) { if (src) {
return saveState(() => { return saveState(() => {
clearSource(); clearSource();
@@ -184,7 +184,7 @@ export class Unpackr {
function getPosition() { function getPosition() {
return position; return position;
} }
function checkedRead(options: any) { function checkedRead(options?: { lazy?: boolean }) {
try { try {
if (!currentUnpackr.trusted && !sequentialMode) { if (!currentUnpackr.trusted && !sequentialMode) {
const sharedLength = currentStructures.sharedLength || 0; const sharedLength = currentStructures.sharedLength || 0;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { Entities, EntityItem } from './types'; import type { Entities, EntityItem } from './types';
import { entityItemValidation } from './validators'; import { entityItemValidation } from './validators';
const MIN_ID = -100;
const MAX_ID = 100;
const ICON_SIZE = 12;
const CustomEntities = () => { const CustomEntities = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0); const [numChanges, setNumChanges] = useState<number>(0);
@@ -53,18 +57,20 @@ const CustomEntities = () => {
initialData: [] initialData: []
}); });
useInterval(() => { const intervalCallback = useCallback(() => {
if (!dialogOpen && !numChanges) { if (!dialogOpen && !numChanges) {
void fetchEntities(); void fetchEntities();
} }
}); }, [dialogOpen, numChanges, fetchEntities]);
useInterval(intervalCallback);
const { send: writeEntities } = useRequest( const { send: writeEntities } = useRequest(
(data: Entities) => writeCustomEntities(data), (data: Entities) => writeCustomEntities(data),
{ immediate: false } { immediate: false }
); );
function hasEntityChanged(ei: EntityItem) { const hasEntityChanged = useCallback((ei: EntityItem) => {
return ( return (
ei.id !== ei.o_id || ei.id !== ei.o_id ||
ei.ram !== ei.o_ram || ei.ram !== ei.o_ram ||
@@ -80,9 +86,11 @@ const CustomEntities = () => {
ei.deleted !== ei.o_deleted || ei.deleted !== ei.o_deleted ||
(ei.value || '') !== (ei.o_value || '') (ei.value || '') !== (ei.o_value || '')
); );
} }, []);
const entity_theme = useTheme({ const entity_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
`, `,
@@ -132,9 +140,11 @@ const CustomEntities = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
}); }),
[]
);
const saveEntities = async () => { const saveEntities = useCallback(async () => {
await writeEntities({ await writeEntities({
entities: entities entities: entities
.filter((ei: EntityItem) => !ei.deleted) .filter((ei: EntityItem) => !ei.deleted)
@@ -163,7 +173,7 @@ const CustomEntities = () => {
await fetchEntities(); await fetchEntities();
setNumChanges(0); setNumChanges(0);
}); });
}; }, [entities, writeEntities, LL, fetchEntities]);
const editEntityItem = useCallback((ei: EntityItem) => { const editEntityItem = useCallback((ei: EntityItem) => {
setCreating(false); setCreating(false);
@@ -171,17 +181,18 @@ const CustomEntities = () => {
setDialogOpen(true); setDialogOpen(true);
}, []); }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchEntities().then(() => { await fetchEntities().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchEntities]);
const onDialogSave = (updatedItem: EntityItem) => { const onDialogSave = useCallback(
(updatedItem: EntityItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readCustomEntities(), (data: EntityItem[]) => { void updateState(readCustomEntities(), (data: EntityItem[]) => {
const new_data = creating const new_data = creating
@@ -195,12 +206,14 @@ const CustomEntities = () => {
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
return new_data; return new_data;
}); });
}; },
[creating, hasEntityChanged]
);
const onDialogDup = (item: EntityItem) => { const onDialogDup = useCallback((item: EntityItem) => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: item.name + '_', name: item.name + '_',
ram: item.ram, ram: item.ram,
device_id: item.device_id, device_id: item.device_id,
@@ -215,12 +228,12 @@ const CustomEntities = () => {
value: item.value value: item.value
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
const addEntityItem = () => { const addEntityItem = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedEntityItem({ setSelectedEntityItem({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
name: '', name: '',
ram: 0, ram: 0,
device_id: '0', device_id: '0',
@@ -235,22 +248,30 @@ const CustomEntities = () => {
value: '' value: ''
}); });
setDialogOpen(true); setDialogOpen(true);
}; }, []);
function formatValue(value: unknown, uom: number) { const formatValue = useCallback((value: unknown, uom: number) => {
return value === undefined return value === undefined
? '' ? ''
: typeof value === 'number' : typeof value === 'number'
? new Intl.NumberFormat().format(value) + ? new Intl.NumberFormat().format(value) +
(uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]) (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
: (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]); : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
} }, []);
function showHex(value: number, digit: number) { const showHex = useCallback((value: number, digit: number) => {
return '0x' + value.toString(16).toUpperCase().padStart(digit, '0'); return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
} }, []);
const renderEntity = () => { const filteredAndSortedEntities = useMemo(
() =>
entities
?.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
[entities]
);
const renderEntity = useCallback(() => {
if (!entities) { if (!entities) {
return ( return (
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
@@ -260,9 +281,7 @@ const CustomEntities = () => {
return ( return (
<Table <Table
data={{ data={{
nodes: entities nodes: filteredAndSortedEntities
.filter((ei: EntityItem) => !ei.deleted)
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name))
}} }}
theme={entity_theme} theme={entity_theme}
layout={{ custom: true }} layout={{ custom: true }}
@@ -285,7 +304,10 @@ const CustomEntities = () => {
<Cell> <Cell>
{ei.name}&nbsp; {ei.name}&nbsp;
{ei.writeable && ( {ei.writeable && (
<EditOutlinedIcon color="primary" sx={{ fontSize: 12 }} /> <EditOutlinedIcon
color="primary"
sx={{ fontSize: ICON_SIZE }}
/>
)} )}
</Cell> </Cell>
<Cell> <Cell>
@@ -304,7 +326,17 @@ const CustomEntities = () => {
)} )}
</Table> </Table>
); );
}; }, [
entities,
error,
fetchEntities,
entity_theme,
editEntityItem,
LL,
filteredAndSortedEntities,
showHex,
formatValue
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -33,6 +33,19 @@ import { validate } from 'validators';
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
import type { EntityItem } from './types'; import type { EntityItem } from './types';
// Constant value type options for the dropdown
const VALUE_TYPE_OPTIONS = [
DeviceValueType.BOOL,
DeviceValueType.INT8,
DeviceValueType.UINT8,
DeviceValueType.INT16,
DeviceValueType.UINT16,
DeviceValueType.UINT24,
DeviceValueType.TIME,
DeviceValueType.UINT32,
DeviceValueType.STRING
] as const;
interface CustomEntitiesDialogProps { interface CustomEntitiesDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -55,64 +68,97 @@ const CustomEntitiesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<EntityItem>(selectedItem); const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
setEditItem(selectedItem); // Convert to hex strings - combined into single setEditItem call
// convert to hex strings straight away const deviceIdHex =
typeof selectedItem.device_id === 'number'
? selectedItem.device_id.toString(16).toUpperCase()
: selectedItem.device_id;
const typeIdHex =
typeof selectedItem.type_id === 'number'
? selectedItem.type_id.toString(16).toUpperCase()
: selectedItem.type_id;
const factorValue =
selectedItem.value_type === DeviceValueType.BOOL &&
typeof selectedItem.factor === 'number'
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor;
setEditItem({ setEditItem({
...selectedItem, ...selectedItem,
device_id: selectedItem.device_id.toString(16).toUpperCase(), device_id: deviceIdHex,
type_id: selectedItem.type_id.toString(16).toUpperCase(), type_id: typeIdHex,
factor: factor: factorValue
selectedItem.value_type === DeviceValueType.BOOL
? selectedItem.factor.toString(16).toUpperCase()
: selectedItem.factor
}); });
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
if (typeof editItem.device_id === 'string') {
editItem.device_id = parseInt(editItem.device_id, 16); // Create a copy to avoid mutating the state directly
const processedItem: EntityItem = { ...editItem };
if (typeof processedItem.device_id === 'string') {
processedItem.device_id = Number.parseInt(processedItem.device_id, 16);
} }
if (typeof editItem.type_id === 'string') { if (typeof processedItem.type_id === 'string') {
editItem.type_id = parseInt(editItem.type_id, 16); processedItem.type_id = Number.parseInt(processedItem.type_id, 16);
} }
if ( if (
editItem.value_type === DeviceValueType.BOOL && processedItem.value_type === DeviceValueType.BOOL &&
typeof editItem.factor === 'string' typeof processedItem.factor === 'string'
) { ) {
editItem.factor = parseInt(editItem.factor, 16); processedItem.factor = Number.parseInt(processedItem.factor, 16);
} }
onSave(editItem); onSave(processedItem);
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
editItem.deleted = true; const itemWithDeleted = { ...editItem, deleted: true };
onSave(editItem); onSave(itemWithDeleted);
}; }, [editItem, onSave]);
const dup = () => { const dup = useCallback(() => {
onDup(editItem); onDup(editItem);
}; }, [editItem, onDup]);
// Memoize UOM menu items to avoid recreating on every render
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
@@ -120,9 +166,6 @@ const CustomEntitiesDialog = ({
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()} {creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()}&nbsp;{LL.ENTITY()}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box display="flex" flexWrap="wrap" mb={1}>
<Box flexWrap="nowrap" whiteSpace="nowrap" />
</Box>
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid size={12}> <Grid size={12}>
<ValidatedTextField <ValidatedTextField
@@ -187,11 +230,7 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue} onChange={updateFormValue}
select select
> >
{DeviceValueUOM_s.map((val, i) => ( {uomMenuItems}
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
</> </>
@@ -275,33 +314,11 @@ const CustomEntitiesDialog = ({
margin="normal" margin="normal"
select select
> >
<MenuItem value={DeviceValueType.BOOL}> {VALUE_TYPE_OPTIONS.map((valueType) => (
{DeviceValueTypeNames[DeviceValueType.BOOL]} <MenuItem key={valueType} value={valueType}>
</MenuItem> {DeviceValueTypeNames[valueType]}
<MenuItem value={DeviceValueType.INT8}>
{DeviceValueTypeNames[DeviceValueType.INT8]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT8}>
{DeviceValueTypeNames[DeviceValueType.UINT8]}
</MenuItem>
<MenuItem value={DeviceValueType.INT16}>
{DeviceValueTypeNames[DeviceValueType.INT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT16}>
{DeviceValueTypeNames[DeviceValueType.UINT16]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT24}>
{DeviceValueTypeNames[DeviceValueType.UINT24]}
</MenuItem>
<MenuItem value={DeviceValueType.TIME}>
{DeviceValueTypeNames[DeviceValueType.TIME]}
</MenuItem>
<MenuItem value={DeviceValueType.UINT32}>
{DeviceValueTypeNames[DeviceValueType.UINT32]}
</MenuItem>
<MenuItem value={DeviceValueType.STRING}>
{DeviceValueTypeNames[DeviceValueType.STRING]}
</MenuItem> </MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
@@ -333,11 +350,7 @@ const CustomEntitiesDialog = ({
onChange={updateFormValue} onChange={updateFormValue}
select select
> >
{DeviceValueUOM_s.map((val, i) => ( {uomMenuItems}
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
</> </>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker, useLocation } from 'react-router'; import { useBlocker, useLocation } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon';
import { DeviceEntityMask } from './types'; import { DeviceEntityMask } from './types';
import type { APIcall, Device, DeviceEntity } from './types'; import type { APIcall, Device, DeviceEntity } from './types';
export const APIURL = window.location.origin + '/api/'; export const APIURL = `${window.location.origin}/api/`;
const MAX_BUFFER_SIZE = 2000;
// Helper function to create masked entity ID - extracted to avoid duplication
const createMaskedEntityId = (de: DeviceEntity): string => {
const maskHex = de.m.toString(16).padStart(2, '0');
const hasCustomizations = !!(de.cn || de.mi || de.ma);
const customizations = [
de.cn || '',
de.mi ? `>${de.mi}` : '',
de.ma ? `<${de.ma}` : ''
]
.filter(Boolean)
.join('');
return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`;
};
const Customizations = () => { const Customizations = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -153,7 +170,9 @@ const Customizations = () => {
); );
}; };
const entities_theme = useTheme({ const entities_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
`, `,
@@ -216,7 +235,9 @@ const Customizations = () => {
padding-right: 8px; padding-right: 8px;
} }
` `
}); }),
[]
);
function hasEntityChanged(de: DeviceEntity) { function hasEntityChanged(de: DeviceEntity) {
return ( return (
@@ -229,19 +250,8 @@ const Customizations = () => {
useEffect(() => { useEffect(() => {
if (deviceEntities.length) { if (deviceEntities.length) {
setNumChanges( const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de));
deviceEntities setNumChanges(changedEntities.length);
.filter((de) => hasEntityChanged(de))
.map(
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
).length
);
} }
}, [deviceEntities]); }, [deviceEntities]);
@@ -275,18 +285,22 @@ const Customizations = () => {
return value as string; return value as string;
} }
const formatName = (de: DeviceEntity, withShortname: boolean) => const formatName = useCallback(
(de.n && de.n[0] === '!' (de: DeviceEntity, withShortname: boolean) => {
? de.t let name: string;
? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1) if (de.n && de.n[0] === '!') {
: LL.COMMAND(1) + ': ' + de.n.slice(1) name = de.t
: de.cn && de.cn !== '' ? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}`
? de.t : `${LL.COMMAND(1)}: ${de.n.slice(1)}`;
? de.t + ' ' + de.cn } else if (de.cn && de.cn !== '') {
: de.cn name = de.t ? `${de.t} ${de.cn}` : de.cn;
: de.t } else {
? de.t + ' ' + de.n name = de.t ? `${de.t} ${de.n}` : de.n || '';
: de.n) + (withShortname ? ' ' + de.id : ''); }
return withShortname ? `${name} ${de.id}` : name;
},
[LL]
);
const getMaskNumber = (newMask: string[]) => { const getMaskNumber = (newMask: string[]) => {
let new_mask = 0; let new_mask = 0;
@@ -316,34 +330,33 @@ const Customizations = () => {
return new_masks; return new_masks;
}; };
const filter_entity = (de: DeviceEntity) => const filter_entity = useCallback(
(de: DeviceEntity) =>
(de.m & selectedFilters || !selectedFilters) && (de.m & selectedFilters || !selectedFilters) &&
formatName(de, true).toLowerCase().includes(search.toLowerCase()); formatName(de, true).toLowerCase().includes(search.toLowerCase()),
[selectedFilters, search, formatName]
);
const maskDisabled = (set: boolean) => { const maskDisabled = useCallback(
setDeviceEntities( (set: boolean) => {
deviceEntities.map(function (de) { setDeviceEntities((prev) =>
prev.map((de) => {
if (filter_entity(de)) { if (filter_entity(de)) {
const excludeMask =
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
return { return {
...de, ...de,
m: set m: set ? de.m | excludeMask : de.m & ~excludeMask
? de.m |
(DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE)
: de.m &
~(
DeviceEntityMask.DV_API_MQTT_EXCLUDE |
DeviceEntityMask.DV_WEB_EXCLUDE
)
}; };
} else {
return de;
} }
return de;
}) })
); );
}; },
[filter_entity]
);
const resetCustomization = async () => { const resetCustomization = useCallback(async () => {
try { try {
await sendResetCustomizations(); await sendResetCustomizations();
toast.info(LL.CUSTOMIZATIONS_RESTART()); toast.info(LL.CUSTOMIZATIONS_RESTART());
@@ -352,24 +365,28 @@ const Customizations = () => {
} finally { } finally {
setConfirmReset(false); setConfirmReset(false);
} }
}; }, [sendResetCustomizations, LL]);
const onDialogClose = () => { const onDialogClose = () => {
setDialogOpen(false); setDialogOpen(false);
}; };
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setDeviceEntities( setDeviceEntities(
deviceEntities?.map((de) => (prev) =>
prev?.map((de) =>
de.id === updatedItem.id ? { ...de, ...updatedItem } : de de.id === updatedItem.id ? { ...de, ...updatedItem } : de
) ) ?? []
); );
}; }, []);
const onDialogSave = (updatedItem: DeviceEntity) => { const onDialogSave = useCallback(
(updatedItem: DeviceEntity) => {
setDialogOpen(false); setDialogOpen(false);
updateDeviceEntity(updatedItem); updateDeviceEntity(updatedItem);
}; },
[updateDeviceEntity]
);
const editDeviceEntity = useCallback((de: DeviceEntity) => { const editDeviceEntity = useCallback((de: DeviceEntity) => {
if (de.n === undefined || (de.n && de.n[0] === '!')) { if (de.n === undefined || (de.n && de.n[0] === '!')) {
@@ -384,23 +401,18 @@ const Customizations = () => {
setDialogOpen(true); setDialogOpen(true);
}, []); }, []);
const saveCustomization = async () => { const saveCustomization = useCallback(async () => {
if (devices && deviceEntities && selectedDevice !== -1) { if (!devices || !deviceEntities || selectedDevice === -1) {
return;
}
const masked_entities = deviceEntities const masked_entities = deviceEntities
.filter((de: DeviceEntity) => hasEntityChanged(de)) .filter((de: DeviceEntity) => hasEntityChanged(de))
.map( .map((new_de) => createMaskedEntityId(new_de));
(new_de) =>
new_de.m.toString(16).padStart(2, '0') +
new_de.id +
(new_de.cn || new_de.mi || new_de.ma ? '|' : '') +
(new_de.cn ? new_de.cn : '') +
(new_de.mi ? '>' + new_de.mi : '') +
(new_de.ma ? '<' + new_de.ma : '')
);
// check size in bytes to match buffer in CPP, which is 2048 // check size in bytes to match buffer in CPP, which is 2048
const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length;
if (bytes > 2000) { if (bytes > MAX_BUFFER_SIZE) {
toast.warning(LL.CUSTOMIZATIONS_FULL()); toast.warning(LL.CUSTOMIZATIONS_FULL());
return; return;
} }
@@ -422,22 +434,21 @@ const Customizations = () => {
.finally(() => { .finally(() => {
setOriginalSettings(deviceEntities); setOriginalSettings(deviceEntities);
}); });
} }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
};
const renameDevice = async () => { const renameDevice = useCallback(async () => {
await sendDeviceName({ id: selectedDevice, name: selectedDeviceName }) await sendDeviceName({ id: selectedDevice, name: selectedDeviceName })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.NAME(1))); toast.success(LL.UPDATED_OF(LL.NAME(1)));
}) })
.catch(() => { .catch(() => {
toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1)); toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`);
}) })
.finally(async () => { .finally(async () => {
setRename(false); setRename(false);
await fetchCoreData(); await fetchCoreData();
}); });
}; }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]);
const renderDeviceList = () => ( const renderDeviceList = () => (
<> <>
@@ -512,9 +523,12 @@ const Customizations = () => {
</> </>
); );
const renderDeviceData = () => { const filteredEntities = useMemo(
const shown_data = deviceEntities.filter((de) => filter_entity(de)); () => deviceEntities.filter((de) => filter_entity(de)),
[deviceEntities, filter_entity]
);
const renderDeviceData = () => {
return ( return (
<> <>
<Box color="warning.main"> <Box color="warning.main">
@@ -544,6 +558,7 @@ const Customizations = () => {
size="small" size="small"
variant="outlined" variant="outlined"
placeholder={LL.SEARCH()} placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); setSearch(event.target.value);
}} }}
@@ -612,13 +627,13 @@ const Customizations = () => {
</Grid> </Grid>
<Grid> <Grid>
<Typography variant="subtitle2" color="grey"> <Typography variant="subtitle2" color="grey">
{LL.SHOWING()}&nbsp;{shown_data.length}/{deviceEntities.length} {LL.SHOWING()}&nbsp;{filteredEntities.length}/{deviceEntities.length}
&nbsp;{LL.ENTITIES(deviceEntities.length)} &nbsp;{LL.ENTITIES(deviceEntities.length)}
</Typography> </Typography>
</Grid> </Grid>
</Grid> </Grid>
<Table <Table
data={{ nodes: shown_data }} data={{ nodes: filteredEntities }}
theme={entities_theme} theme={entities_theme}
layout={{ custom: true }} layout={{ custom: true }}
> >
@@ -719,7 +734,11 @@ const Customizations = () => {
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
color="secondary" color="secondary"
onClick={() => devices && sendDeviceEntities(selectedDevice)} onClick={() => {
if (devices) {
void sendDeviceEntities(selectedDevice);
}
}}
> >
{LL.CANCEL()} {LL.CANCEL()}
</Button> </Button>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
@@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps {
selectedItem: DeviceEntity; selectedItem: DeviceEntity;
} }
interface LabelValueProps {
label: string;
value: React.ReactNode;
}
const LabelValue = memo(({ label, value }: LabelValueProps) => (
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{label}:&nbsp;
</Typography>
<Typography variant="body2">{value}</Typography>
</Grid>
));
LabelValue.displayName = 'LabelValue';
const ICON_SIZE = 16;
const CustomizationsDialog = ({ const CustomizationsDialog = ({
open, open,
onClose, onClose,
@@ -40,12 +57,23 @@ const CustomizationsDialog = ({
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem); const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
const [error, setError] = useState<boolean>(false); const [error, setError] = useState<boolean>(false);
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
const isWriteableNumber = const isWriteableNumber = useMemo(
() =>
typeof editItem.v === 'number' && typeof editItem.v === 'number' &&
editItem.w && editItem.w &&
!(editItem.m & DeviceEntityMask.DV_READONLY); !(editItem.m & DeviceEntityMask.DV_READONLY),
[editItem.v, editItem.w, editItem.m]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -54,66 +82,59 @@ const CustomizationsDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = () => { const save = useCallback(() => {
if ( if (
isWriteableNumber && isWriteableNumber &&
editItem.mi && editItem.mi &&
editItem.ma && editItem.ma &&
editItem.mi > editItem?.ma editItem.mi > editItem.ma
) { ) {
setError(true); setError(true);
} else { } else {
onSave(editItem); onSave(editItem);
} }
}; }, [isWriteableNumber, editItem, onSave]);
const updateDeviceEntity = (updatedItem: DeviceEntity) => { const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
setEditItem({ ...editItem, m: updatedItem.m }); setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
}; }, []);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
const writeableIcon = useMemo(
() =>
editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
) : (
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
),
[editItem.w]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
<Typography variant="body2" color="warning.main"> <LabelValue
{LL.ID_OF(LL.ENTITY())}:&nbsp; label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
</Typography> value={editItem.n}
<Typography variant="body2">{editItem.id}</Typography> />
</Grid> <LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:&nbsp;
</Typography>
<Typography variant="body2">{editItem.n}</Typography>
</Grid>
<Grid container direction="row">
<Typography variant="body2" color="warning.main">
{LL.WRITEABLE()}:&nbsp;
</Typography>
<Typography variant="body2">
{editItem.w ? (
<DoneIcon color="success" sx={{ fontSize: 16 }} />
) : (
<CloseIcon color="error" sx={{ fontSize: 16 }} />
)}
</Typography>
</Grid>
<Box mt={1} mb={2}> <Box mt={1} mb={2}>
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} /> <EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
</Box> </Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <Grid>
<TextField <TextField
@@ -149,12 +170,14 @@ const CustomizationsDialog = ({
</> </>
)} )}
</Grid> </Grid>
{error && ( {error && (
<Typography variant="body2" color="error" mt={2}> <Typography variant="body2" color="error" mt={2}>
Error: Check min and max values Error: Check min and max values
</Typography> </Typography>
)} )}
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}

View File

@@ -133,7 +133,7 @@ const Dashboard = memo(() => {
); );
const tree = useTree( const tree = useTree(
{ nodes: data.nodes }, { nodes: [...data.nodes] },
{ {
onChange: () => {} // not used but needed onChange: () => {} // not used but needed
}, },
@@ -364,6 +364,9 @@ const Dashboard = memo(() => {
) && ( ) && (
<IconButton <IconButton
size="small" size="small"
aria-label={
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
}
onClick={() => editDashboardValue(di)} onClick={() => editDashboardValue(di)}
> >
<EditIcon <EditIcon

View File

@@ -1,13 +1,14 @@
import { memo } from 'react';
import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai'; import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/ai';
import { CgSmartHomeBoiler } from 'react-icons/cg'; import { CgSmartHomeBoiler } from 'react-icons/cg';
import { FaSolarPanel } from 'react-icons/fa'; import { FaSolarPanel } from 'react-icons/fa';
import { GiHeatHaze, GiTap } from 'react-icons/gi'; import { GiHeatHaze, GiTap } from 'react-icons/gi';
import { MdPlaylistAdd } from 'react-icons/md';
import { MdMoreTime } from 'react-icons/md';
import { import {
MdMoreTime,
MdOutlineDevices, MdOutlineDevices,
MdOutlinePool, MdOutlinePool,
MdOutlineSensors, MdOutlineSensors,
MdPlaylistAdd,
MdThermostatAuto MdThermostatAuto
} from 'react-icons/md'; } from 'react-icons/md';
import { PiFan, PiGauge } from 'react-icons/pi'; import { PiFan, PiGauge } from 'react-icons/pi';
@@ -18,9 +19,10 @@ import type { SvgIconProps } from '@mui/material';
import { DeviceType } from './types'; import { DeviceType } from './types';
const deviceIconLookup: { const deviceIconLookup: Record<
[key in DeviceType]: React.ComponentType<SvgIconProps> | undefined; DeviceType,
} = { React.ComponentType<SvgIconProps> | null
> = {
[DeviceType.TEMPERATURESENSOR]: TiThermometer, [DeviceType.TEMPERATURESENSOR]: TiThermometer,
[DeviceType.ANALOGSENSOR]: PiGauge, [DeviceType.ANALOGSENSOR]: PiGauge,
[DeviceType.BOILER]: CgSmartHomeBoiler, [DeviceType.BOILER]: CgSmartHomeBoiler,
@@ -39,15 +41,19 @@ const deviceIconLookup: {
[DeviceType.POOL]: MdOutlinePool, [DeviceType.POOL]: MdOutlinePool,
[DeviceType.CUSTOM]: MdPlaylistAdd, [DeviceType.CUSTOM]: MdPlaylistAdd,
[DeviceType.UNKNOWN]: MdOutlineSensors, [DeviceType.UNKNOWN]: MdOutlineSensors,
[DeviceType.SYSTEM]: undefined, [DeviceType.SYSTEM]: null,
[DeviceType.SCHEDULER]: MdMoreTime, [DeviceType.SCHEDULER]: MdMoreTime,
[DeviceType.GENERIC]: MdOutlineSensors, [DeviceType.GENERIC]: MdOutlineSensors,
[DeviceType.VENTILATION]: PiFan [DeviceType.VENTILATION]: PiFan
}; };
const DeviceIcon = ({ type_id }: { type_id: DeviceType }) => { interface DeviceIconProps {
type_id: DeviceType;
}
const DeviceIcon = memo(({ type_id }: DeviceIconProps) => {
const Icon = deviceIconLookup[type_id]; const Icon = deviceIconLookup[type_id];
return Icon ? <Icon /> : null; return Icon ? <Icon /> : null;
}; });
export default DeviceIcon; export default DeviceIcon;

View File

@@ -93,7 +93,7 @@ const Devices = memo(() => {
useLayoutTitle(LL.DEVICES()); useLayoutTitle(LL.DEVICES());
const { data: coreData, send: sendCoreData } = useRequest(() => readCoreData(), { const { data: coreData, send: sendCoreData } = useRequest(readCoreData, {
initialData: { initialData: {
connected: true, connected: true,
devices: [] devices: []
@@ -118,30 +118,28 @@ const Devices = memo(() => {
); );
useLayoutEffect(() => { useLayoutEffect(() => {
function updateSize() { let raf = 0;
const updateSize = () => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
setSize([window.innerWidth, window.innerHeight]); setSize([window.innerWidth, window.innerHeight]);
} });
};
window.addEventListener('resize', updateSize); window.addEventListener('resize', updateSize);
updateSize(); updateSize();
return () => window.removeEventListener('resize', updateSize); return () => {
window.removeEventListener('resize', updateSize);
cancelAnimationFrame(raf);
};
}, []); }, []);
const leftOffset = () => { const leftOffset = useCallback(() => {
const devicesWindow = document.getElementById('devices-window'); const devicesWindow = document.getElementById('devices-window');
if (!devicesWindow) { if (!devicesWindow) return 0;
return 0; const { left, right } = devicesWindow.getBoundingClientRect();
} if (!left || !right) return 0;
const clientRect = devicesWindow.getBoundingClientRect();
const left = clientRect.left;
const right = clientRect.right;
if (!left || !right) {
return 0;
}
return left + (right - left < 400 ? 0 : 200); return left + (right - left < 400 ? 0 : 200);
}; }, []);
const common_theme = useMemo( const common_theme = useMemo(
() => () =>
@@ -261,7 +259,7 @@ const Devices = memo(() => {
}; };
const dv_sort = useSort( const dv_sort = useSort(
{ nodes: deviceData.nodes }, { nodes: [...deviceData.nodes] },
{}, {},
{ {
sortIcon: { sortIcon: {
@@ -291,7 +289,7 @@ const Devices = memo(() => {
} }
const device_select = useRowSelect( const device_select = useRowSelect(
{ nodes: coreData.devices }, { nodes: [...coreData.devices] },
{ {
onChange: onSelectChange onChange: onSelectChange
} }
@@ -549,7 +547,7 @@ const Devices = memo(() => {
{coreData.connected && ( {coreData.connected && (
<Table <Table
data={{ nodes: coreData.devices }} data={{ nodes: [...coreData.devices] }}
select={device_select} select={device_select}
theme={device_theme} theme={device_theme}
layout={{ custom: true }} layout={{ custom: true }}
@@ -654,7 +652,7 @@ const Devices = memo(() => {
sx={{ sx={{
backgroundColor: 'black', backgroundColor: 'black',
position: 'absolute', position: 'absolute',
left: () => leftOffset(), left: leftOffset,
right: 0, right: 0,
bottom: 0, bottom: 0,
top: 64, top: 64,
@@ -671,7 +669,7 @@ const Devices = memo(() => {
</Typography> </Typography>
<Grid justifyContent="flex-end"> <Grid justifyContent="flex-end">
<ButtonTooltip title={LL.CLOSE()}> <ButtonTooltip title={LL.CLOSE()}>
<IconButton onClick={resetDeviceSelect}> <IconButton onClick={resetDeviceSelect} aria-label={LL.CLOSE()}>
<HighlightOffIcon color="primary" sx={{ fontSize: 18 }} /> <HighlightOffIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
@@ -683,6 +681,7 @@ const Devices = memo(() => {
variant="outlined" variant="outlined"
sx={{ width: '22ch' }} sx={{ width: '22ch' }}
placeholder={LL.SEARCH()} placeholder={LL.SEARCH()}
aria-label={LL.SEARCH()}
onChange={(event) => { onChange={(event) => {
setSearch(event.target.value); setSearch(event.target.value);
}} }}
@@ -697,19 +696,22 @@ const Devices = memo(() => {
}} }}
/> />
<ButtonTooltip title={LL.DEVICE_DETAILS()}> <ButtonTooltip title={LL.DEVICE_DETAILS()}>
<IconButton onClick={() => setShowDeviceInfo(true)}> <IconButton
onClick={() => setShowDeviceInfo(true)}
aria-label={LL.DEVICE_DETAILS()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
{me.admin && ( {me.admin && (
<ButtonTooltip title={LL.CUSTOMIZATIONS()}> <ButtonTooltip title={LL.CUSTOMIZATIONS()}>
<IconButton onClick={customize}> <IconButton onClick={customize} aria-label={LL.CUSTOMIZATIONS()}>
<ConstructionIcon color="primary" sx={{ fontSize: 18 }} /> <ConstructionIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
)} )}
<ButtonTooltip title={LL.EXPORT()}> <ButtonTooltip title={LL.EXPORT()}>
<IconButton onClick={handleDownloadCsv}> <IconButton onClick={handleDownloadCsv} aria-label={LL.EXPORT()}>
<DownloadIcon color="primary" sx={{ fontSize: 18 }} /> <DownloadIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
</ButtonTooltip> </ButtonTooltip>
@@ -744,7 +746,7 @@ const Devices = memo(() => {
</Box> </Box>
<Table <Table
data={{ nodes: shown_data }} data={{ nodes: Array.from(shown_data) }}
theme={data_theme} theme={data_theme}
sort={dv_sort} sort={dv_sort}
layout={{ custom: true, fixedHeader: true }} layout={{ custom: true, fixedHeader: true }}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -52,7 +52,7 @@ const DevicesDialog = ({
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem); const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -61,11 +61,7 @@ const DevicesDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const save = useCallback(async () => {
onClose();
};
const save = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -73,9 +69,10 @@ const DevicesDialog = ({
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [validator, editItem, onSave]);
const setUom = (uom?: DeviceValueUOM) => { const setUom = useCallback(
(uom?: DeviceValueUOM) => {
if (uom === undefined) { if (uom === undefined) {
return; return;
} }
@@ -89,30 +86,49 @@ const DevicesDialog = ({
default: default:
return DeviceValueUOM_s[uom]; return DeviceValueUOM_s[uom];
} }
}; },
[LL]
);
const showHelperText = (dv: DeviceValue) => const showHelperText = useCallback((dv: DeviceValue) => {
dv.h ? ( if (dv.h) return dv.h;
dv.h if (dv.l) return dv.l.join(' | ');
) : dv.l ? ( if (dv.m !== undefined && dv.x !== undefined) {
dv.l.join(' | ') return (
) : dv.m !== undefined && dv.x !== undefined ? (
<> <>
{dv.m}&nbsp;&rarr;&nbsp;{dv.x} {dv.m}&nbsp;&rarr;&nbsp;{dv.x}
</> </>
) : undefined; );
}
return undefined;
}, []);
const isCommand = useMemo(
() => selectedItem.v === '' && selectedItem.c,
[selectedItem.v, selectedItem.c]
);
const dialogTitle = useMemo(() => {
if (isCommand) return LL.RUN_COMMAND();
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
}, [isCommand, writeable, LL]);
const buttonLabel = useMemo(() => {
return isCommand ? LL.EXECUTE() : LL.UPDATE();
}, [isCommand, LL]);
const helperText = useMemo(
() => showHelperText(editItem),
[editItem, showHelperText]
);
const valueLabel = LL.VALUE(0);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={close}> <Dialog sx={dialogStyle} open={open} onClose={onClose}>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{selectedItem.v === '' && selectedItem.c
? LL.RUN_COMMAND()
: writeable
? LL.CHANGE_VALUE()
: LL.VALUE(0)}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}> <Box color="warning.main" mb={2}>
<Typography variant="body2">{editItem.id.slice(2)}</Typography> <Typography variant="body2">{editItem.id.slice(2)}</Typography>
</Box> </Box>
<Grid container> <Grid container>
@@ -120,8 +136,8 @@ const DevicesDialog = ({
{editItem.l ? ( {editItem.l ? (
<TextField <TextField
name="v" name="v"
// label={LL.VALUE(0)}
value={editItem.v} value={editItem.v}
aria-label={valueLabel}
disabled={!writeable} disabled={!writeable}
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
select select
@@ -137,7 +153,7 @@ const DevicesDialog = ({
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="v" name="v"
label={LL.VALUE(0)} label={valueLabel}
value={numberValue(Math.round((editItem.v as number) * 10) / 10)} value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
autoFocus autoFocus
disabled={!writeable} disabled={!writeable}
@@ -161,7 +177,7 @@ const DevicesDialog = ({
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
name="v" name="v"
label={LL.VALUE(0)} label={valueLabel}
value={editItem.v} value={editItem.v}
disabled={!writeable} disabled={!writeable}
sx={{ width: '30ch' }} sx={{ width: '30ch' }}
@@ -170,9 +186,9 @@ const DevicesDialog = ({
/> />
)} )}
</Grid> </Grid>
{writeable && ( {writeable && helperText && (
<Grid> <Grid>
<FormHelperText>{showHelperText(editItem)}</FormHelperText> <FormHelperText>{helperText}</FormHelperText>
</Grid> </Grid>
)} )}
</Grid> </Grid>
@@ -191,7 +207,7 @@ const DevicesDialog = ({
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={close} onClick={onClose}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -202,7 +218,7 @@ const DevicesDialog = ({
onClick={save} onClick={save}
color="primary" color="primary"
> >
{selectedItem.v === '' && selectedItem.c ? LL.EXECUTE() : LL.UPDATE()} {buttonLabel}
</Button> </Button>
{progress && ( {progress && (
<CircularProgress <CircularProgress
@@ -217,7 +233,7 @@ const DevicesDialog = ({
)} )}
</Box> </Box>
) : ( ) : (
<Button variant="outlined" onClick={close} color="secondary"> <Button variant="outlined" onClick={onClose} color="secondary">
{LL.CLOSE()} {LL.CLOSE()}
</Button> </Button>
)} )}

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import { ToggleButton, ToggleButtonGroup } from '@mui/material';
import OptionIcon from './OptionIcon'; import OptionIcon from './OptionIcon';
@@ -9,92 +11,132 @@ interface EntityMaskToggleProps {
de: DeviceEntity; de: DeviceEntity;
} }
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { // Available mask values
const getMaskNumber = (newMask: string[]) => { const MASK_VALUES = [
let new_mask = 0; DeviceEntityMask.DV_WEB_EXCLUDE, // 1
for (const entry of newMask) { DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
new_mask |= Number(entry); DeviceEntityMask.DV_READONLY, // 4
} DeviceEntityMask.DV_FAVORITE, // 8
return new_mask; DeviceEntityMask.DV_DELETED // 128
}; ];
const getMaskString = (m: number) => { /**
const new_masks: string[] = []; * Converts an array of mask strings to a bitmask number
if ((m & 1) === 1) { */
new_masks.push('1'); const getMaskNumber = (newMask: string[]): number => {
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
};
/**
* Converts a bitmask number to an array of mask strings
*/
const getMaskString = (mask: number): string[] => {
return MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
String(value)
);
};
/**
* Checks if a specific mask bit is set
*/
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
const handleChange = useCallback(
(_event: unknown, mask: string[]) => {
// Convert selected masks to a number
const newMask = getMaskNumber(mask);
const updatedDe = { ...de };
// Apply business logic for mask interactions
// If entity has no name and is set to readonly, also exclude from web
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
} else {
updatedDe.m = newMask;
} }
if ((m & 2) === 2) {
new_masks.push('2'); // If excluded from web, cannot be favorite
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
} }
if ((m & 4) === 4) {
new_masks.push('4'); onUpdate(updatedDe);
} },
if ((m & 8) === 8) { [de, onUpdate]
new_masks.push('8'); );
}
if ((m & 128) === 128) { // Memoize mask string value
new_masks.push('128'); const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
}
return new_masks; // Memoize disabled states
}; const isFavoriteDisabled = useMemo(
() =>
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
de.n === undefined,
[de.m, de.n]
);
const isReadonlyDisabled = useMemo(
() =>
!de.w ||
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
[de.w, de.m]
);
const isApiMqttExcludeDisabled = useMemo(
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
const isWebExcludeDisabled = useMemo(
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.n, de.m]
);
// Memoize mask flag checks
const isFavoriteSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
[de.m]
);
const isReadonlySet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
[de.m]
);
const isApiMqttExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
[de.m]
);
const isWebExcludeSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
[de.m]
);
const isDeletedSet = useMemo(
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
[de.m]
);
return ( return (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={getMaskString(de.m)} value={maskStringValue}
onChange={(_event, mask: string[]) => { onChange={handleChange}
de.m = getMaskNumber(mask);
if (de.n === '' && de.m & DeviceEntityMask.DV_READONLY) {
de.m = de.m | DeviceEntityMask.DV_WEB_EXCLUDE;
}
if (de.m & DeviceEntityMask.DV_WEB_EXCLUDE) {
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
}
onUpdate(de);
}}
> >
<ToggleButton value="8" disabled={(de.m & 0x81) !== 0 || de.n === undefined}> <ToggleButton value="8" disabled={isFavoriteDisabled}>
<OptionIcon <OptionIcon type="favorite" isSet={isFavoriteSet} />
type="favorite"
isSet={
(de.m & DeviceEntityMask.DV_FAVORITE) === DeviceEntityMask.DV_FAVORITE
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="4" disabled={!de.w || (de.m & 0x83) >= 3}> <ToggleButton value="4" disabled={isReadonlyDisabled}>
<OptionIcon <OptionIcon type="readonly" isSet={isReadonlySet} />
type="readonly"
isSet={
(de.m & DeviceEntityMask.DV_READONLY) === DeviceEntityMask.DV_READONLY
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="2" disabled={de.n === '' || (de.m & 0x80) !== 0}> <ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
<OptionIcon <OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
type="api_mqtt_exclude"
isSet={
(de.m & DeviceEntityMask.DV_API_MQTT_EXCLUDE) ===
DeviceEntityMask.DV_API_MQTT_EXCLUDE
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="1" disabled={de.n === undefined || (de.m & 0x80) !== 0}> <ToggleButton value="1" disabled={isWebExcludeDisabled}>
<OptionIcon <OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
type="web_exclude"
isSet={
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
DeviceEntityMask.DV_WEB_EXCLUDE
}
/>
</ToggleButton> </ToggleButton>
<ToggleButton value="128"> <ToggleButton value="128">
<OptionIcon <OptionIcon type="deleted" isSet={isDeletedSet} />
type="deleted"
isSet={
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
}
/>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
); );

View File

@@ -1,4 +1,5 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import type { ReactElement } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CommentIcon from '@mui/icons-material/CommentTwoTone'; import CommentIcon from '@mui/icons-material/CommentTwoTone';
@@ -19,6 +20,7 @@ import {
Stack, Stack,
Typography Typography
} from '@mui/material'; } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
@@ -29,26 +31,62 @@ import { saveFile } from 'utils';
import { API, callAction } from '../../api/app'; import { API, callAction } from '../../api/app';
import type { APIcall } from './types'; import type { APIcall } from './types';
const Help = () => { interface HelpLink {
href: string;
icon: ReactElement;
label: () => string;
}
interface CustomSupport {
img_url: string | null;
html: string | null;
}
// Constants moved outside component to prevent recreation
const DEFAULT_IMAGE_URL = 'https://docs.emsesp.org/_media/images/installer.jpeg';
const SUPPORT_BOX_STYLES: SxProps<Theme> = {
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
};
const IMAGE_STYLES: SxProps<Theme> = {
maxHeight: { xs: 100, md: 250 }
};
const AVATAR_STYLES: SxProps<Theme> = {
bgcolor: '#72caf9'
};
const HelpComponent = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.HELP()); useLayoutTitle(LL.HELP());
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [customSupportIMG, setCustomSupportIMG] = useState<string | null>(null); const [customSupport, setCustomSupport] = useState<CustomSupport>({
const [customSupportHTML, setCustomSupportHTML] = useState<string | null>(null); img_url: null,
const [notFound, setNotFound] = useState<boolean>(false); html: null
});
const [imgError, setImgError] = useState<boolean>(false);
useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => { // Memoize the request method to prevent re-creation on every render
if (event && event.data && Object.keys(event.data).length !== 0) { const getCustomSupportMethod = useMemo(
const data = (event.data as { Support: { img_url?: string; html?: string[] } }) () => callAction({ action: 'getCustomSupport' }),
.Support; []
if (data.img_url) { );
setCustomSupportIMG(data.img_url);
} useRequest(getCustomSupportMethod).onSuccess((event) => {
if (data.html) { if (event?.data && Object.keys(event.data).length !== 0) {
setCustomSupportHTML(data.html.join('<br/>')); const { Support } = event.data as {
} Support: { img_url?: string; html?: string[] };
};
setCustomSupport({
img_url: Support.img_url || null,
html: Support.html?.join('<br/>') || null
});
} }
}); });
@@ -63,90 +101,88 @@ const Help = () => {
toast.error(String(error.error?.message || 'An error occurred')); toast.error(String(error.error?.message || 'An error occurred'));
}); });
// Optimize API call memoization
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
const handleDownloadSystemInfo = useCallback(() => {
void sendAPI(apiCall);
}, [sendAPI, apiCall]);
const handleImageError = useCallback(() => {
setImgError(true);
}, []);
// Memoize help links to prevent recreation on every render
const helpLinks: HelpLink[] = useMemo(
() => [
{
href: 'https://docs.emsesp.org',
icon: <MenuBookIcon />,
label: () => LL.HELP_INFORMATION_1()
},
{
href: 'https://discord.gg/3J3GgnzpyT',
icon: <CommentIcon />,
label: () => LL.HELP_INFORMATION_2()
},
{
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
icon: <GitHubIcon />,
label: () => LL.HELP_INFORMATION_3()
}
],
[LL]
);
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
// Memoize image source computation
const imageSrc = useMemo(
() =>
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
[imgError, customSupport.img_url]
);
return ( return (
<SectionContent> <SectionContent>
{customSupportHTML && ( {customSupport.html && (
<Stack <Stack
padding={1} padding={1}
mb={2} mb={2}
direction="row" direction="row"
divider={<Divider orientation="vertical" flexItem />} divider={<Divider orientation="vertical" flexItem />}
sx={{ sx={SUPPORT_BOX_STYLES}
borderRadius: 3,
border: '1px solid lightblue',
justifyContent: 'space-evenly',
alignItems: 'center'
}}
> >
<Typography variant="subtitle1"> <Typography variant="subtitle1">
<div dangerouslySetInnerHTML={{ __html: customSupportHTML }} /> <div dangerouslySetInnerHTML={{ __html: customSupport.html }} />
</Typography> </Typography>
<Box <Box
component="img" component="img"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
sx={{ sx={IMAGE_STYLES}
maxHeight: { xs: 100, md: 250 } onError={handleImageError}
}} src={imageSrc}
onError={() => setNotFound(true)}
src={
notFound
? ''
: customSupportIMG ||
'https://docs.emsesp.org/_media/images/installer.jpeg'
}
/> />
</Stack> </Stack>
)} )}
{me.admin && ( {isAdmin && (
<List> <List>
<ListItem> {helpLinks.map(({ href, icon, label }) => (
<ListItem key={href}>
<ListItemButton <ListItemButton
component="a" component="a"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
href="https://docs.emsesp.org" href={href}
> >
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}> <Avatar sx={AVATAR_STYLES}>{icon}</Avatar>
<MenuBookIcon />
</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_1()} /> <ListItemText primary={label()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://discord.gg/3J3GgnzpyT"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<CommentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_2()} />
</ListItemButton>
</ListItem>
<ListItem>
<ListItemButton
component="a"
target="_blank"
rel="noreferrer"
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#72caf9' }}>
<GitHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HELP_INFORMATION_3()} />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
))}
</List> </List>
)} )}
@@ -158,7 +194,7 @@ const Help = () => {
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })} onClick={handleDownloadSystemInfo}
> >
{LL.SUPPORT_INFORMATION(0)} {LL.SUPPORT_INFORMATION(0)}
</Button> </Button>
@@ -174,11 +210,14 @@ const Help = () => {
href="https://emsesp.org" href="https://emsesp.org"
color="primary" color="primary"
> >
{'emsesp.org'} emsesp.org
</Link> </Link>
</Typography> </Typography>
</SectionContent> </SectionContent>
); );
}; };
// Memoize the component to prevent unnecessary re-renders
const Help = memo(HelpComponent);
export default Help; export default Help;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app';
import ModulesDialog from './ModulesDialog'; import ModulesDialog from './ModulesDialog';
import type { ModuleItem } from './types'; import type { ModuleItem } from './types';
const PENDING_COLOR = 'red';
const ACTIVATED_COLOR = '#00FF7F';
const hasModulesChanged = (mi: ModuleItem): boolean =>
mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
const ColorStatus = memo(({ status }: { status: number }) => {
if (status === 1) {
return <div style={{ color: PENDING_COLOR }}>Pending Activation</div>;
}
return <div style={{ color: ACTIVATED_COLOR }}>Activated</div>;
});
const Modules = () => { const Modules = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0); const [numChanges, setNumChanges] = useState<number>(0);
@@ -56,7 +69,9 @@ const Modules = () => {
} }
); );
const modules_theme = useTheme({ const modules_theme = useTheme(
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
`, `,
@@ -96,43 +111,46 @@ const Modules = () => {
background-color: #303030; background-color: #303030;
} }
` `
}); }),
[]
)
);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogSave = (updatedItem: ModuleItem) => { const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter(hasModulesChanged).length);
return new_data;
});
}, []);
const onDialogSave = useCallback(
(updatedItem: ModuleItem) => {
setDialogOpen(false); setDialogOpen(false);
updateModuleItem(updatedItem); updateModuleItem(updatedItem);
}; },
[updateModuleItem]
);
const editModuleItem = useCallback((mi: ModuleItem) => { const editModuleItem = useCallback((mi: ModuleItem) => {
setSelectedModuleItem(mi); setSelectedModuleItem(mi);
setDialogOpen(true); setDialogOpen(true);
}, []); }, []);
const onCancel = async () => { const onCancel = useCallback(async () => {
await fetchModules().then(() => { await fetchModules().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchModules]);
function hasModulesChanged(mi: ModuleItem) { const saveModules = useCallback(async () => {
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; try {
}
const updateModuleItem = (updatedItem: ModuleItem) => {
void updateState(readModules(), (data: ModuleItem[]) => {
const new_data = data.map((mi) =>
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
);
setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length);
return new_data;
});
};
const saveModules = async () => {
await Promise.all( await Promise.all(
modules.map((condensed_mi: ModuleItem) => modules.map((condensed_mi: ModuleItem) =>
updateModules({ updateModules({
@@ -141,20 +159,17 @@ const Modules = () => {
license: condensed_mi.license license: condensed_mi.license
}) })
) )
) );
.then(() => {
toast.success(LL.MODULES_UPDATED()); toast.success(LL.MODULES_UPDATED());
}) } catch (error) {
.catch((error: Error) => { toast.error(error instanceof Error ? error.message : String(error));
toast.error(error.message); } finally {
})
.finally(async () => {
await fetchModules(); await fetchModules();
setNumChanges(0); setNumChanges(0);
}); }
}; }, [modules, updateModules, LL, fetchModules]);
const renderContent = () => { const content = useMemo(() => {
if (!modules) { if (!modules) {
return ( return (
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
@@ -169,13 +184,6 @@ const Modules = () => {
); );
} }
const colorStatus = (status: number) => {
if (status === 1) {
return <div style={{ color: 'red' }}>Pending Activation</div>;
}
return <div style={{ color: '#00FF7F' }}>Activated</div>;
};
return ( return (
<> <>
<Box mb={2} color="warning.main"> <Box mb={2} color="warning.main">
@@ -218,7 +226,9 @@ const Modules = () => {
<Cell>{mi.author}</Cell> <Cell>{mi.author}</Cell>
<Cell>{mi.version}</Cell> <Cell>{mi.version}</Cell>
<Cell>{mi.message}</Cell> <Cell>{mi.message}</Cell>
<Cell>{colorStatus(mi.status)}</Cell> <Cell>
<ColorStatus status={mi.status} />
</Cell>
</Row> </Row>
))} ))}
</Body> </Body>
@@ -252,12 +262,22 @@ const Modules = () => {
</Box> </Box>
</> </>
); );
}; }, [
modules,
fetchModules,
error,
modules_theme,
editModuleItem,
LL,
numChanges,
onCancel,
saveModules
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{renderContent()} {content}
{selectedModuleItem && ( {selectedModuleItem && (
<ModulesDialog <ModulesDialog
open={dialogOpen} open={dialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DoneIcon from '@mui/icons-material/Done'; import DoneIcon from '@mui/icons-material/Done';
@@ -37,25 +37,35 @@ const ModulesDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem); const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
// Sync form state when dialog opens or selected item changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setEditItem(selectedItem); setEditItem(selectedItem);
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const close = () => { const handleSave = useCallback(() => {
onClose();
};
const save = () => {
onSave(editItem); onSave(editItem);
}; }, [editItem, onSave]);
const dialogTitle = useMemo(
() => `${LL.EDIT()} ${editItem.key}`,
[LL, editItem.key]
);
return ( return (
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}> <Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
<DialogTitle>{LL.EDIT() + ' ' + editItem.key}</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
@@ -85,7 +95,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={close} onClick={onClose}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -93,7 +103,7 @@ const ModulesDialog = ({
<Button <Button
startIcon={<DoneIcon />} startIcon={<DoneIcon />}
variant="outlined" variant="outlined"
onClick={save} onClick={handleSave}
color="primary" color="primary"
> >
{LL.UPDATE()} {LL.UPDATE()}

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined'; import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
@@ -10,33 +12,39 @@ import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined';
import type { SvgIconProps } from '@mui/material'; import type { SvgIconProps } from '@mui/material';
type OptionType = export type OptionType =
| 'deleted' | 'deleted'
| 'readonly' | 'readonly'
| 'web_exclude' | 'web_exclude'
| 'api_mqtt_exclude' | 'api_mqtt_exclude'
| 'favorite'; | 'favorite';
const OPTION_ICONS: { type IconPair = [
[type in OptionType]: [
React.ComponentType<SvgIconProps>, React.ComponentType<SvgIconProps>,
React.ComponentType<SvgIconProps> React.ComponentType<SvgIconProps>
]; ];
} = {
const OPTION_ICONS: Record<OptionType, IconPair> = {
deleted: [DeleteForeverIcon, DeleteOutlineIcon], deleted: [DeleteForeverIcon, DeleteOutlineIcon],
readonly: [EditOffOutlinedIcon, EditOutlinedIcon], readonly: [EditOffOutlinedIcon, EditOutlinedIcon],
web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon], web_exclude: [VisibilityOffOutlinedIcon, VisibilityOutlinedIcon],
api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon], api_mqtt_exclude: [CommentsDisabledOutlinedIcon, InsertCommentOutlinedIcon],
favorite: [StarIcon, StarOutlineIcon] favorite: [StarIcon, StarOutlineIcon]
} as const;
const ICON_SIZE = 16;
const ICON_SX = { fontSize: ICON_SIZE, verticalAlign: 'middle' } as const;
export interface OptionIconProps {
readonly type: OptionType;
readonly isSet: boolean;
}
const OptionIcon = ({ type, isSet }: OptionIconProps) => {
const [SetIcon, UnsetIcon] = OPTION_ICONS[type];
const Icon = isSet ? SetIcon : UnsetIcon;
return <Icon {...(isSet && { color: 'primary' })} sx={ICON_SX} />;
}; };
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => { export default memo(OptionIcon);
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
return isSet ? (
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
) : (
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
);
};
export default OptionIcon;

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -35,64 +35,30 @@ import { ScheduleFlag } from './types';
import type { Schedule, ScheduleItem } from './types'; import type { Schedule, ScheduleItem } from './types';
import { schedulerItemValidation } from './validators'; import { schedulerItemValidation } from './validators';
const Scheduler = () => { // Constants
const { LL, locale } = useI18nContext(); const INTERVAL_DELAY = 30000; // 30 seconds
const [numChanges, setNumChanges] = useState<number>(0); const MIN_ID = -100;
const blocker = useBlocker(numChanges !== 0); const MAX_ID = 100;
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>(); const ICON_SIZE = 16;
const [dow, setDow] = useState<string[]>([]); const SCHEDULE_FLAG_THRESHOLD = 127;
const [creating, setCreating] = useState<boolean>(false); const REFERENCE_YEAR = 2017;
const [dialogOpen, setDialogOpen] = useState<boolean>(false); const REFERENCE_MONTH = '01';
const LOG_2 = Math.log(2);
useLayoutTitle(LL.SCHEDULER()); // Days of week starting from Monday (1-7)
const WEEK_DAYS = [1, 2, 3, 4, 5, 6, 7] as const;
const { const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
data: schedule, active: false,
send: fetchSchedule, deleted: false,
error flags: ScheduleFlag.SCHEDULE_DAY,
} = useRequest(readSchedule, { time: '',
initialData: [] cmd: '',
}); value: '',
name: ''
};
const { send: updateSchedule } = useRequest( const scheduleTheme = {
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
function hasScheduleChanged(si: ScheduleItem) {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
si.active !== si.o_active ||
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}
useInterval(() => {
if (numChanges === 0) {
void fetchSchedule();
}
});
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = [1, 2, 3, 4, 5, 6, 7].map((day) => {
const dd = day < 10 ? `0${day}` : day;
return new Date(`2017-01-${dd}T00:00:00+00:00`);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px; --data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
`, `,
@@ -130,9 +96,80 @@ const Scheduler = () => {
background-color: #177ac9; background-color: #177ac9;
} }
` `
};
const scheduleTypeLabels: Record<number, string> = {
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
};
const Scheduler = () => {
const { LL, locale } = useI18nContext();
const [numChanges, setNumChanges] = useState<number>(0);
const blocker = useBlocker(numChanges !== 0);
const [selectedScheduleItem, setSelectedScheduleItem] = useState<ScheduleItem>();
const [dow, setDow] = useState<string[]>([]);
const [creating, setCreating] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
useLayoutTitle(LL.SCHEDULER());
const {
data: schedule,
send: fetchSchedule,
error
} = useRequest(readSchedule, {
initialData: []
}); });
const saveSchedule = async () => { const { send: updateSchedule } = useRequest(
(data: Schedule) => writeSchedule(data),
{
immediate: false
}
);
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
return (
si.id !== si.o_id ||
(si.name || '') !== (si.o_name || '') ||
si.active !== si.o_active ||
si.deleted !== si.o_deleted ||
si.flags !== si.o_flags ||
si.time !== si.o_time ||
si.cmd !== si.o_cmd ||
si.value !== si.o_value
);
}, []);
const intervalCallback = useCallback(() => {
if (numChanges === 0) {
void fetchSchedule();
}
}, [numChanges, fetchSchedule]);
useInterval(intervalCallback, INTERVAL_DELAY);
useEffect(() => {
const formatter = new Intl.DateTimeFormat(locale, {
weekday: 'short',
timeZone: 'UTC'
});
const days = WEEK_DAYS.map((day) => {
const dayStr = String(day).padStart(2, '0');
return new Date(
`${REFERENCE_YEAR}-${REFERENCE_MONTH}-${dayStr}T00:00:00+00:00`
);
});
setDow(days.map((date) => formatter.format(date)));
}, [locale]);
const schedule_theme = useTheme(scheduleTheme);
const saveSchedule = useCallback(async () => {
try {
await updateSchedule({ await updateSchedule({
schedule: schedule schedule: schedule
.filter((si: ScheduleItem) => !si.deleted) .filter((si: ScheduleItem) => !si.deleted)
@@ -145,18 +182,16 @@ const Scheduler = () => {
value: condensed_si.value, value: condensed_si.value,
name: condensed_si.name name: condensed_si.name
})) }))
}) });
.then(() => {
toast.success(LL.SCHEDULE_UPDATED()); toast.success(LL.SCHEDULE_UPDATED());
}) } catch (error: unknown) {
.catch((error: Error) => { const message = error instanceof Error ? error.message : String(error);
toast.error(error.message); toast.error(message);
}) } finally {
.finally(async () => {
await fetchSchedule(); await fetchSchedule();
setNumChanges(0); setNumChanges(0);
}); }
}; }, [LL, schedule, updateSchedule, fetchSchedule]);
const editScheduleItem = useCallback((si: ScheduleItem) => { const editScheduleItem = useCallback((si: ScheduleItem) => {
setCreating(false); setCreating(false);
@@ -167,24 +202,22 @@ const Scheduler = () => {
} }
}, []); }, []);
const onDialogClose = () => { const onDialogClose = useCallback(() => {
setDialogOpen(false); setDialogOpen(false);
}; }, []);
const onDialogCancel = async () => { const onDialogCancel = useCallback(async () => {
await fetchSchedule().then(() => { await fetchSchedule().then(() => {
setNumChanges(0); setNumChanges(0);
}); });
}; }, [fetchSchedule]);
const onDialogSave = (updatedItem: ScheduleItem) => { const onDialogSave = useCallback(
(updatedItem: ScheduleItem) => {
setDialogOpen(false); setDialogOpen(false);
void updateState(readSchedule(), (data: ScheduleItem[]) => { void updateState(readSchedule(), (data: ScheduleItem[]) => {
const new_data = creating const new_data = creating
? [ ? [...data, updatedItem]
...data.filter((si) => creating || si.o_id !== updatedItem.o_id),
updatedItem
]
: data.map((si) => : data.map((si) =>
si.id === updatedItem.id ? { ...si, ...updatedItem } : si si.id === updatedItem.id ? { ...si, ...updatedItem } : si
); );
@@ -193,69 +226,69 @@ const Scheduler = () => {
return new_data; return new_data;
}); });
}; },
[creating, hasScheduleChanged]
);
const addScheduleItem = () => { const addScheduleItem = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedScheduleItem({ const newItem: ScheduleItem = {
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
active: false, ...DEFAULT_SCHEDULE_ITEM
deleted: false,
flags: ScheduleFlag.SCHEDULE_DAY,
time: '',
cmd: '',
value: '',
name: ''
});
setDialogOpen(true);
}; };
setSelectedScheduleItem(newItem);
setDialogOpen(true);
}, []);
const renderSchedule = () => { const filteredAndSortedSchedule = useMemo(
() =>
schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
[schedule]
);
const dayBox = useCallback(
(si: ScheduleItem, flag: number) => {
const dayIndex = Math.log(flag) / LOG_2;
const isActive = (si.flags & flag) === flag;
return (
<>
<Box>
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
{dow[dayIndex]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
},
[dow]
);
const scheduleType = useCallback((si: ScheduleItem) => {
const label = scheduleTypeLabels[si.flags];
return (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{label || ''}
</Typography>
</Box>
);
}, []);
const renderSchedule = useCallback(() => {
if (!schedule) { if (!schedule) {
return ( return (
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} /> <FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
); );
} }
const dayBox = (si: ScheduleItem, flag: number) => (
<>
<Box>
<Typography
sx={{ fontSize: 11 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
</>
);
const scheduleType = (si: ScheduleItem) => (
<Box>
<Typography sx={{ fontSize: 11 }} color="primary">
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
<>Immediate</>
) : si.flags === ScheduleFlag.SCHEDULE_TIMER ? (
<>Timer</>
) : si.flags === ScheduleFlag.SCHEDULE_CONDITION ? (
<>Condition</>
) : si.flags === ScheduleFlag.SCHEDULE_ONCHANGE ? (
<>On Change</>
) : (
<></>
)}
</Typography>
</Box>
);
return ( return (
<Table <Table
data={{ data={{ nodes: filteredAndSortedSchedule }}
nodes: schedule
.filter((si: ScheduleItem) => !si.deleted)
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags)
}}
theme={schedule_theme} theme={schedule_theme}
layout={{ custom: true }} layout={{ custom: true }}
> >
@@ -275,22 +308,15 @@ const Scheduler = () => {
{tableList.map((si: ScheduleItem) => ( {tableList.map((si: ScheduleItem) => (
<Row key={si.id} item={si} onClick={() => editScheduleItem(si)}> <Row key={si.id} item={si} onClick={() => editScheduleItem(si)}>
<Cell stiff> <Cell stiff>
{si.active ? (
<CircleIcon <CircleIcon
color="success" color={si.active ? 'success' : 'error'}
sx={{ fontSize: 16, verticalAlign: 'middle' }} sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
/> />
) : (
<CircleIcon
color="error"
sx={{ fontSize: 16, verticalAlign: 'middle' }}
/>
)}
</Cell> </Cell>
<Cell stiff> <Cell stiff>
<Stack spacing={0.5} direction="row"> <Stack spacing={0.5} direction="row">
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
{si.flags > 127 ? ( {si.flags > SCHEDULE_FLAG_THRESHOLD ? (
scheduleType(si) scheduleType(si)
) : ( ) : (
<> <>
@@ -316,7 +342,17 @@ const Scheduler = () => {
)} )}
</Table> </Table>
); );
}; }, [
schedule,
error,
fetchSchedule,
filteredAndSortedSchedule,
schedule_theme,
editScheduleItem,
LL,
dayBox,
scheduleType
]);
return ( return (
<SectionContent> <SectionContent>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -31,6 +31,34 @@ import { validate } from 'validators';
import { ScheduleFlag } from './types'; import { ScheduleFlag } from './types';
import type { ScheduleItem } from './types'; import type { ScheduleItem } from './types';
// Constants
const FLAG_MASK_127 = 127;
const SCHEDULE_TYPE_THRESHOLD = 128;
const DEFAULT_TIME = '00:00';
const TYPOGRAPHY_FONT_SIZE = 10;
// Day of week flag configuration (static, defined outside component)
const DAY_FLAGS = [
{ value: '2', flag: ScheduleFlag.SCHEDULE_MON },
{ value: '4', flag: ScheduleFlag.SCHEDULE_TUE },
{ value: '8', flag: ScheduleFlag.SCHEDULE_WED },
{ value: '16', flag: ScheduleFlag.SCHEDULE_THU },
{ value: '32', flag: ScheduleFlag.SCHEDULE_FRI },
{ value: '64', flag: ScheduleFlag.SCHEDULE_SAT },
{ value: '1', flag: ScheduleFlag.SCHEDULE_SUN }
] as const;
// Day of week flag values array (static)
const FLAG_VALUES = [
ScheduleFlag.SCHEDULE_SUN,
ScheduleFlag.SCHEDULE_MON,
ScheduleFlag.SCHEDULE_TUE,
ScheduleFlag.SCHEDULE_WED,
ScheduleFlag.SCHEDULE_THU,
ScheduleFlag.SCHEDULE_FRI,
ScheduleFlag.SCHEDULE_SAT
] as const;
interface SchedulerDialogProps { interface SchedulerDialogProps {
open: boolean; open: boolean;
creating: boolean; creating: boolean;
@@ -53,110 +81,163 @@ const SchedulerDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem); const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [scheduleType, setScheduleType] = useState<ScheduleFlag>(); const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
const updateFormValue = updateValue(setEditItem); const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as React.Dispatch<
React.SetStateAction<Record<string, unknown>>
>
),
[]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
setEditItem(selectedItem); setEditItem(selectedItem);
// set the flags based on type when page is loaded... // Set the flags based on type when page is loaded:
// 0-127 is day schedule // 0-127 is day schedule
// 128 is timer // 128 is timer
// 129 is on change // 129 is on change
// 130 is on condition // 130 is on condition
// 132 is immediate // 132 is immediate
setScheduleType( setScheduleType(
selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags selectedItem.flags < SCHEDULE_TYPE_THRESHOLD
? ScheduleFlag.SCHEDULE_DAY
: selectedItem.flags
); );
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const save = async () => { // Helper function to handle save operations
const handleSave = useCallback(
async (itemToSave: ScheduleItem) => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, itemToSave);
onSave(editItem); onSave(itemToSave);
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; },
[validator, onSave]
const saveandactivate = async () => {
editItem.active = true;
try {
setFieldErrors(undefined);
await validate(validator, editItem);
onSave(editItem);
} catch (error) {
setFieldErrors(error as ValidateFieldsError);
}
};
const remove = () => {
editItem.deleted = true;
onSave(editItem);
};
const getFlagDOWnumber = (newFlag: string[]) => {
let new_flag = 0;
for (const entry of newFlag) {
new_flag |= Number(entry);
}
return new_flag & 127;
};
const getFlagDOWstring = (f: number) => {
const new_flags: string[] = [];
if ((f & 129) === 1) {
new_flags.push('1');
}
if ((f & 130) === 2) {
new_flags.push('2');
}
if ((f & 4) === 4) {
new_flags.push('4');
}
if ((f & 8) === 8) {
new_flags.push('8');
}
if ((f & 16) === 16) {
new_flags.push('16');
}
if ((f & 32) === 32) {
new_flags.push('32');
}
if ((f & 64) === 64) {
new_flags.push('64');
}
return new_flags;
};
const showDOW = (si: ScheduleItem, flag: number) => (
<Typography
sx={{ fontSize: 10 }}
color={(si.flags & flag) === flag ? 'primary' : 'grey'}
>
{dow[Math.log(flag) / Math.log(2)]}
</Typography>
); );
const handleClose = ( const save = useCallback(async () => {
_event: React.SyntheticEvent, await handleSave(editItem);
reason: 'backdropClick' | 'escapeKeyDown' }, [editItem, handleSave]);
) => {
const saveandactivate = useCallback(async () => {
await handleSave({ ...editItem, active: true });
}, [editItem, handleSave]);
const remove = useCallback(() => {
onSave({ ...editItem, deleted: true });
}, [editItem, onSave]);
// Optimize DOW flag conversion
const getFlagDOWnumber = useCallback((flags: string[]) => {
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
}, []);
const getFlagDOWstring = useCallback((f: number) => {
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
String(flag)
);
}, []);
// Day of week display component
const DayOfWeekButton = useCallback(
(flag: number) => {
const dayIndex = Math.log2(flag);
const isSelected = (editItem.flags & flag) === flag;
return (
<Typography
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={isSelected ? 'primary' : 'grey'}
>
{dow[dayIndex]}
</Typography>
);
},
[editItem.flags, dow]
);
const handleClose = useCallback(
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const handleScheduleTypeChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
// set the flags based on type
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag;
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
}
},
[]
);
const handleDOWChange = useCallback(
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
const newFlags = getFlagDOWnumber(flags);
setEditItem((prev) => ({ ...prev, flags: newFlags }));
},
[getFlagDOWnumber]
);
// Memoize derived values
const isDaySchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
[scheduleType]
);
const isTimerSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
[scheduleType]
);
const isImmediateSchedule = useMemo(
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
[scheduleType]
);
const needsTimeField = useMemo(
() => isDaySchedule || isTimerSchedule,
[isDaySchedule, isTimerSchedule]
);
const dowFlags = useMemo(
() => getFlagDOWstring(editItem.flags),
[editItem.flags, getFlagDOWstring]
);
const timeFieldValue = useMemo(() => {
if (needsTimeField) {
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
}
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
}, [editItem.time, needsTimeField]);
const timeFieldLabel = useMemo(() => {
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
return LL.TIME(1);
}, [scheduleType, LL]);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp; {creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}&nbsp;
{LL.SCHEDULE(1)} {LL.SCHEDULE(1)}
</DialogTitle> </DialogTitle>
<DialogContent dividers> <DialogContent dividers>
@@ -166,47 +247,27 @@ const SchedulerDialog = ({
value={scheduleType} value={scheduleType}
exclusive exclusive
disabled={!creating} disabled={!creating}
onChange={(_event, flag: ScheduleFlag) => { onChange={handleScheduleTypeChange}
if (flag !== null) {
setFieldErrors(undefined); // clear any validation errors
setScheduleType(flag);
// wipe the time field when changing the schedule type
setEditItem({ ...editItem, time: '' });
// set the flags based on type
// 0-127 is day schedule
// 128 is timer
// 129 is on change
// 130 is on condition
// 132 is immediate
setEditItem(
flag === ScheduleFlag.SCHEDULE_DAY
? { ...editItem, flags: 0 }
: { ...editItem, flags: flag }
);
}
}}
> >
<ToggleButton value={ScheduleFlag.SCHEDULE_DAY}> <ToggleButton value={ScheduleFlag.SCHEDULE_DAY}>
<Typography <Typography
sx={{ fontSize: 10 }} sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'} color={isDaySchedule ? 'primary' : 'grey'}
> >
{LL.SCHEDULE(0)} {LL.SCHEDULE(0)}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}> <ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
<Typography <Typography
sx={{ fontSize: 10 }} sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={ color={isTimerSchedule ? 'primary' : 'grey'}
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
}
> >
{LL.TIMER(0)} {LL.TIMER(0)}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}> <ToggleButton value={ScheduleFlag.SCHEDULE_ONCHANGE}>
<Typography <Typography
sx={{ fontSize: 10 }} sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={ color={
scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey' scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE ? 'primary' : 'grey'
} }
@@ -216,7 +277,7 @@ const SchedulerDialog = ({
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}> <ToggleButton value={ScheduleFlag.SCHEDULE_CONDITION}>
<Typography <Typography
sx={{ fontSize: 10 }} sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={ color={
scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey' scheduleType === ScheduleFlag.SCHEDULE_CONDITION ? 'primary' : 'grey'
} }
@@ -226,50 +287,30 @@ const SchedulerDialog = ({
</ToggleButton> </ToggleButton>
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}> <ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
<Typography <Typography
sx={{ fontSize: 10 }} sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
color={ color={isImmediateSchedule ? 'primary' : 'grey'}
scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE ? 'primary' : 'grey'
}
> >
{LL.IMMEDIATE()} {LL.IMMEDIATE()}
</Typography> </Typography>
</ToggleButton> </ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{scheduleType === ScheduleFlag.SCHEDULE_DAY && ( {isDaySchedule && (
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
color="secondary" color="secondary"
value={getFlagDOWstring(editItem.flags)} value={dowFlags}
onChange={(_event, flag: string[]) => { onChange={handleDOWChange}
setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) });
}}
> >
<ToggleButton value="2"> {DAY_FLAGS.map(({ value, flag }) => (
{showDOW(editItem, ScheduleFlag.SCHEDULE_MON)} <ToggleButton key={value} value={value}>
</ToggleButton> {DayOfWeekButton(flag)}
<ToggleButton value="4">
{showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)}
</ToggleButton>
<ToggleButton value="8">
{showDOW(editItem, ScheduleFlag.SCHEDULE_WED)}
</ToggleButton>
<ToggleButton value="16">
{showDOW(editItem, ScheduleFlag.SCHEDULE_THU)}
</ToggleButton>
<ToggleButton value="32">
{showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)}
</ToggleButton>
<ToggleButton value="64">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)}
</ToggleButton>
<ToggleButton value="1">
{showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)}
</ToggleButton> </ToggleButton>
))}
</ToggleButtonGroup> </ToggleButtonGroup>
)} )}
{scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && ( {!isImmediateSchedule && (
<> <>
<Grid container> <Grid container>
<BlockFormControlLabel <BlockFormControlLabel
@@ -284,22 +325,17 @@ const SchedulerDialog = ({
/> />
</Grid> </Grid>
<Grid container> <Grid container>
{scheduleType === ScheduleFlag.SCHEDULE_DAY || {needsTimeField ? (
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? (
<> <>
<TextField <TextField
name="time" name="time"
type="time" type="time"
label={ label={timeFieldLabel}
scheduleType === ScheduleFlag.SCHEDULE_TIMER value={timeFieldValue}
? LL.TIMER(1)
: LL.TIME(1)
}
value={editItem.time === '' ? '00:00' : editItem.time}
margin="normal" margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
/> />
{scheduleType === ScheduleFlag.SCHEDULE_TIMER && ( {isTimerSchedule && (
<Box color="warning.main" ml={2} mt={4}> <Box color="warning.main" ml={2} mt={4}>
<Typography variant="body2"> <Typography variant="body2">
{LL.SCHEDULER_HELP_2()} {LL.SCHEDULER_HELP_2()}
@@ -310,16 +346,10 @@ const SchedulerDialog = ({
) : ( ) : (
<TextField <TextField
name="time" name="time"
label={ label={timeFieldLabel}
scheduleType === ScheduleFlag.SCHEDULE_CONDITION
? LL.CONDITION()
: scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE
? LL.ONCHANGE()
: LL.IMMEDIATE()
}
multiline multiline
fullWidth fullWidth
value={editItem.time === '00:00' ? '' : editItem.time} value={timeFieldValue}
margin="normal" margin="normal"
onChange={updateFormValue} onChange={updateFormValue}
/> />
@@ -386,7 +416,7 @@ const SchedulerDialog = ({
> >
{creating ? LL.ADD(0) : LL.UPDATE()} {creating ? LL.ADD(0) : LL.UPDATE()}
</Button> </Button>
{scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && ( {isImmediateSchedule && editItem.cmd !== '' && (
<Button <Button
startIcon={<PlayArrowIcon />} startIcon={<PlayArrowIcon />}
variant="outlined" variant="outlined"

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
@@ -49,50 +49,28 @@ import {
temperatureSensorItemValidation temperatureSensorItemValidation
} from './validators'; } from './validators';
const Sensors = () => { // Constants
const { LL } = useI18nContext(); const MS_PER_SECOND = 1000;
const { me } = useContext(AuthenticatedContext); const MS_PER_MINUTE = 60 * MS_PER_SECOND;
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
const MS_PER_DAY = 24 * MS_PER_HOUR;
const DEFAULT_GPIO = 21; // Safe GPIO for all platforms
const MIN_TEMP_ID = -100;
const MAX_TEMP_ID = 100;
const GPIO_25 = 25;
const GPIO_26 = 26;
const [selectedTemperatureSensor, setSelectedTemperatureSensor] = const HEADER_BUTTON_STYLE: React.CSSProperties = {
useState<TemperatureSensor>(); fontSize: '14px',
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>(); justifyContent: 'flex-start'
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false); };
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
const [creating, setCreating] = useState<boolean>(false);
const { data: sensorData, send: fetchSensorData } = useRequest( const HEADER_BUTTON_STYLE_END: React.CSSProperties = {
() => readSensorData(), fontSize: '14px',
{ justifyContent: 'flex-end'
initialData: { };
ts: [],
as: [],
analog_enabled: false,
platform: 'ESP32'
}
}
);
const { send: sendTemperatureSensor } = useRequest( const common_theme = {
(data: WriteTemperatureSensor) => writeTemperatureSensor(data),
{
immediate: false
}
);
const { send: sendAnalogSensor } = useRequest(
(data: WriteAnalogSensor) => writeAnalogSensor(data),
{
immediate: false
}
);
useInterval(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
});
const common_theme = useTheme({
BaseRow: ` BaseRow: `
font-size: 14px; font-size: 14px;
.td { .td {
@@ -125,77 +103,66 @@ const Sensors = () => {
text-align: right; text-align: right;
}, },
` `
}); };
const temperature_theme = useTheme([ const temperature_theme_config = {
common_theme,
{
Table: ` Table: `
--data-table-library_grid-template-columns: minmax(0, 1fr) 35%; --data-table-library_grid-template-columns: minmax(0, 1fr) 35%;
` `
} };
]);
const analog_theme = useTheme([ const analog_theme_config = {
common_theme,
{
Table: ` Table: `
--data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px; --data-table-library_grid-template-columns: 80px repeat(1, minmax(0, 1fr)) 120px 110px;
` `
} };
]);
const RenderTemperatureSensors = () => ( const Sensors = () => {
<Table const { LL } = useI18nContext();
data={{ nodes: sensorData.ts }} const { me } = useContext(AuthenticatedContext);
theme={temperature_theme}
sort={temperature_sort} const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
layout={{ custom: true }} useState<TemperatureSensor>();
> const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
{(tableList: TemperatureSensor[]) => ( const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
<> const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
<Header> const [creating, setCreating] = useState<boolean>(false);
<HeaderRow>
<HeaderCell resize> const { data: sensorData, send: fetchSensorData } = useRequest(readSensorData, {
<Button initialData: {
fullWidth ts: [],
style={{ fontSize: '14px', justifyContent: 'flex-start' }} as: [],
endIcon={getSortIcon(temperature_sort.state, 'NAME')} analog_enabled: false,
onClick={() => platform: 'ESP32'
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
} }
> });
{LL.NAME(0)}
</Button> const { send: sendTemperatureSensor } = useRequest(
</HeaderCell> (data: WriteTemperatureSensor) => writeTemperatureSensor(data),
<HeaderCell stiff> {
<Button immediate: false
fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
} }
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row key={ts.id} item={ts} onClick={() => updateTemperatureSensor(ts)}>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
); );
const getSortIcon = (state: State, sortKey: unknown) => { const { send: sendAnalogSensor } = useRequest(
(data: WriteAnalogSensor) => writeAnalogSensor(data),
{
immediate: false
}
);
const intervalCallback = useCallback(() => {
if (!temperatureDialogOpen && !analogDialogOpen) {
void fetchSensorData();
}
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
useInterval(intervalCallback);
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
const analog_theme = useTheme([common_theme, analog_theme_config]);
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
if (state.sortKey === sortKey && state.reverse) { if (state.sortKey === sortKey && state.reverse) {
return <KeyboardArrowDownOutlinedIcon />; return <KeyboardArrowDownOutlinedIcon />;
} }
@@ -203,7 +170,7 @@ const Sensors = () => {
return <KeyboardArrowUpOutlinedIcon />; return <KeyboardArrowUpOutlinedIcon />;
} }
return <UnfoldMoreOutlinedIcon />; return <UnfoldMoreOutlinedIcon />;
}; }, []);
const analog_sort = useSort( const analog_sort = useSort(
{ nodes: sensorData.as }, { nodes: sensorData.as },
@@ -216,11 +183,16 @@ const Sensors = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
GPIO: (array) => array.sort((a, b) => a.g - b.g), GPIO: (array) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return [...array].sort((a, b) => (a as AnalogSensor).g - (b as AnalogSensor).g),
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), NAME: (array) =>
TYPE: (array) => array.sort((a, b) => a.t - b.t), [...array].sort((a, b) =>
VALUE: (array) => array.sort((a, b) => a.v - b.v) (a as AnalogSensor).n.localeCompare((b as AnalogSensor).n)
),
TYPE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).t - (b as AnalogSensor).t),
VALUE: (array) =>
[...array].sort((a, b) => (a as AnalogSensor).v - (b as AnalogSensor).v)
} }
} }
); );
@@ -236,34 +208,45 @@ const Sensors = () => {
}, },
sortToggleType: SortToggleType.AlternateWithReset, sortToggleType: SortToggleType.AlternateWithReset,
sortFns: { sortFns: {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return NAME: (array) =>
NAME: (array) => array.sort((a, b) => a.n.localeCompare(b.n)), [...array].sort((a, b) =>
VALUE: (array) => array.sort((a, b) => a.t - b.t) (a as TemperatureSensor).n.localeCompare((b as TemperatureSensor).n)
),
VALUE: (array) =>
[...array].sort(
(a, b) =>
((a as TemperatureSensor).t ?? 0) - ((b as TemperatureSensor).t ?? 0)
)
} }
} }
); );
useLayoutTitle(LL.SENSORS()); useLayoutTitle(LL.SENSORS());
const formatDurationMin = (duration_min: number) => { const formatDurationMin = useCallback(
const days = Math.trunc((duration_min * 60000) / 86400000); (duration_min: number) => {
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; const totalMs = duration_min * MS_PER_MINUTE;
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; const days = Math.trunc(totalMs / MS_PER_DAY);
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
let formatted = ''; const parts: string[] = [];
if (days) { if (days > 0) {
formatted += LL.NUM_DAYS({ num: days }) + ' '; parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours) { if (hours > 0) {
formatted += LL.NUM_HOURS({ num: hours }) + ' '; parts.push(LL.NUM_HOURS({ num: hours }));
} }
if (minutes) { if (minutes > 0) {
formatted += LL.NUM_MINUTES({ num: minutes }); parts.push(LL.NUM_MINUTES({ num: minutes }));
} }
return formatted; return parts.join(' ');
}; },
[LL]
);
function formatValue(value: unknown, uom: DeviceValueUOM) { const formatValue = useCallback(
(value: unknown, uom: DeviceValueUOM) => {
if (value === undefined) { if (value === undefined) {
return ''; return '';
} }
@@ -292,22 +275,28 @@ const Sensors = () => {
default: default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
} }
} },
[formatDurationMin, LL]
);
const updateTemperatureSensor = (ts: TemperatureSensor) => { const updateTemperatureSensor = useCallback(
(ts: TemperatureSensor) => {
if (me.admin) { if (me.admin) {
ts.o_n = ts.n; ts.o_n = ts.n;
setSelectedTemperatureSensor(ts); setSelectedTemperatureSensor(ts);
setTemperatureDialogOpen(true); setTemperatureDialogOpen(true);
} }
}; },
[me.admin]
);
const onTemperatureDialogClose = () => { const onTemperatureDialogClose = useCallback(() => {
setTemperatureDialogOpen(false); setTemperatureDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const onTemperatureDialogSave = async (ts: TemperatureSensor) => { const onTemperatureDialogSave = useCallback(
async (ts: TemperatureSensor) => {
await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o }) await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o })
.then(() => { .then(() => {
toast.success(LL.UPDATED_OF(LL.SENSOR(1))); toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
@@ -320,28 +309,33 @@ const Sensors = () => {
setSelectedTemperatureSensor(undefined); setSelectedTemperatureSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}; },
[sendTemperatureSensor, LL, fetchSensorData]
);
const updateAnalogSensor = (as: AnalogSensor) => { const updateAnalogSensor = useCallback(
(as: AnalogSensor) => {
if (me.admin) { if (me.admin) {
setCreating(false); setCreating(false);
as.o_n = as.n; as.o_n = as.n;
setSelectedAnalogSensor(as); setSelectedAnalogSensor(as);
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
} }
}; },
[me.admin]
);
const onAnalogDialogClose = () => { const onAnalogDialogClose = useCallback(() => {
setAnalogDialogOpen(false); setAnalogDialogOpen(false);
void fetchSensorData(); void fetchSensorData();
}; }, [fetchSensorData]);
const addAnalogSensor = () => { const addAnalogSensor = useCallback(() => {
setCreating(true); setCreating(true);
setSelectedAnalogSensor({ setSelectedAnalogSensor({
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), id: Math.floor(Math.random() * (MAX_TEMP_ID - MIN_TEMP_ID) + MIN_TEMP_ID),
n: '', n: '',
g: 21, // default GPIO 21 which is safe for all platforms g: DEFAULT_GPIO,
u: 0, u: 0,
v: 0, v: 0,
o: 0, o: 0,
@@ -351,9 +345,10 @@ const Sensors = () => {
o_n: '' o_n: ''
}); });
setAnalogDialogOpen(true); setAnalogDialogOpen(true);
}; }, []);
const onAnalogDialogSave = async (as: AnalogSensor) => { const onAnalogDialogSave = useCallback(
async (as: AnalogSensor) => {
await sendAnalogSensor({ await sendAnalogSensor({
id: as.id, id: as.id,
gpio: as.g, gpio: as.g,
@@ -375,9 +370,12 @@ const Sensors = () => {
setSelectedAnalogSensor(undefined); setSelectedAnalogSensor(undefined);
void fetchSensorData(); void fetchSensorData();
}); });
}; },
[sendAnalogSensor, LL, fetchSensorData]
);
const RenderAnalogSensors = () => ( const RenderAnalogSensors = useMemo(
() => (
<Table <Table
data={{ nodes: sensorData.as }} data={{ nodes: sensorData.as }}
theme={analog_theme} theme={analog_theme}
@@ -391,7 +389,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'GPIO')} endIcon={getSortIcon(analog_sort.state, 'GPIO')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
> >
@@ -401,7 +399,7 @@ const Sensors = () => {
<HeaderCell resize> <HeaderCell resize>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'NAME')} endIcon={getSortIcon(analog_sort.state, 'NAME')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
> >
@@ -411,7 +409,7 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-start' }} style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(analog_sort.state, 'TYPE')} endIcon={getSortIcon(analog_sort.state, 'TYPE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })} onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
> >
@@ -421,9 +419,11 @@ const Sensors = () => {
<HeaderCell stiff> <HeaderCell stiff>
<Button <Button
fullWidth fullWidth
style={{ fontSize: '14px', justifyContent: 'flex-end' }} style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(analog_sort.state, 'VALUE')} endIcon={getSortIcon(analog_sort.state, 'VALUE')}
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })} onClick={() =>
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
> >
{LL.VALUE(0)} {LL.VALUE(0)}
</Button> </Button>
@@ -436,12 +436,16 @@ const Sensors = () => {
<Cell stiff>{a.g}</Cell> <Cell stiff>{a.g}</Cell>
<Cell>{a.n}</Cell> <Cell>{a.n}</Cell>
<Cell stiff>{AnalogTypeNames[a.t]} </Cell> <Cell stiff>{AnalogTypeNames[a.t]} </Cell>
{(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) || {(a.t === AnalogType.DIGITAL_OUT &&
a.g !== GPIO_25 &&
a.g !== GPIO_26) ||
a.t === AnalogType.DIGITAL_IN || a.t === AnalogType.DIGITAL_IN ||
a.t === AnalogType.PULSE ? ( a.t === AnalogType.PULSE ? (
<Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell> <Cell stiff>{a.v ? LL.ON() : LL.OFF()}</Cell>
) : ( ) : (
<Cell stiff>{a.t ? formatValue(a.v, a.u) : ''}</Cell> <Cell stiff>
{a.t !== AnalogType.NOTUSED ? formatValue(a.v, a.u) : ''}
</Cell>
)} )}
</Row> </Row>
))} ))}
@@ -449,14 +453,89 @@ const Sensors = () => {
</> </>
)} )}
</Table> </Table>
),
[
analog_sort,
analog_theme,
getSortIcon,
sensorData.as,
LL,
updateAnalogSensor,
formatValue
]
);
const RenderTemperatureSensors = useMemo(
() => (
<Table
data={{ nodes: sensorData.ts }}
theme={temperature_theme}
sort={temperature_sort}
layout={{ custom: true }}
>
{(tableList: TemperatureSensor[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize>
<Button
fullWidth
style={HEADER_BUTTON_STYLE}
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
}
>
{LL.NAME(0)}
</Button>
</HeaderCell>
<HeaderCell stiff>
<Button
fullWidth
style={HEADER_BUTTON_STYLE_END}
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
onClick={() =>
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
}
>
{LL.VALUE(0)}
</Button>
</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((ts: TemperatureSensor) => (
<Row
key={ts.id}
item={ts}
onClick={() => updateTemperatureSensor(ts)}
>
<Cell>{ts.n}</Cell>
<Cell>{formatValue(ts.t, ts.u)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
),
[
temperature_sort,
temperature_theme,
getSortIcon,
sensorData.ts,
LL,
updateTemperatureSensor,
formatValue
]
); );
return ( return (
<SectionContent> <SectionContent>
<Typography sx={{ pb: 1 }} variant="h6" color="secondary"> <Typography sx={{ pb: 1 }} variant="h6" color="primary">
{LL.TEMP_SENSORS()} {LL.TEMP_SENSORS()}
</Typography> </Typography>
<RenderTemperatureSensors /> {RenderTemperatureSensors}
{selectedTemperatureSensor && ( {selectedTemperatureSensor && (
<DashboardSensorsTemperatureDialog <DashboardSensorsTemperatureDialog
open={temperatureDialogOpen} open={temperatureDialogOpen}
@@ -469,10 +548,10 @@ const Sensors = () => {
)} )}
/> />
)} )}
<Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="secondary"> <Typography sx={{ pt: 4, pb: 1 }} variant="h6" color="primary">
{LL.ANALOG_SENSORS()} {LL.ANALOG_SENSORS()}
</Typography> </Typography>
<RenderAnalogSensors /> {RenderAnalogSensors}
{selectedAnalogSensor && ( {selectedAnalogSensor && (
<DashboardSensorsAnalogDialog <DashboardSensorsAnalogDialog
open={analogDialogOpen} open={analogDialogOpen}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import RemoveIcon from '@mui/icons-material/RemoveCircleOutline'; import RemoveIcon from '@mui/icons-material/RemoveCircleOutline';
@@ -48,8 +48,72 @@ const SensorsAnalogDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem); const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue((updater) =>
setEditItem(
(prev) =>
updater(
prev as unknown as Record<string, unknown>
) as unknown as AnalogSensor
)
),
[setEditItem]
);
// Memoize helper functions to check sensor type conditions
const isCounterOrRate = useMemo(
() => editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE,
[editItem.t]
);
const isFreqType = useMemo(
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
[editItem.t]
);
const isPWM = useMemo(
() =>
editItem.t === AnalogType.PWM_0 ||
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2,
[editItem.t]
);
const isDigitalOutGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
(editItem.g === 25 || editItem.g === 26),
[editItem.t, editItem.g]
);
const isDigitalOutNonGPIO = useMemo(
() =>
editItem.t === AnalogType.DIGITAL_OUT &&
editItem.g !== 25 &&
editItem.g !== 26,
[editItem.t, editItem.g]
);
// Memoize menu items to avoid recreation on each render
const analogTypeMenuItems = useMemo(
() =>
AnalogTypeNames.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
const uomMenuItems = useMemo(
() =>
DeviceValueUOM_s.map((val, i) => (
<MenuItem key={val} value={i}>
{val}
</MenuItem>
)),
[]
);
// Reset form when dialog opens or selectedItem changes
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -57,16 +121,16 @@ const SensorsAnalogDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -74,19 +138,21 @@ const SensorsAnalogDialog = ({
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [validator, editItem, onSave]);
const remove = () => { const remove = useCallback(() => {
editItem.d = true; onSave({ ...editItem, d: true });
onSave(editItem); }, [editItem, onSave]);
};
const dialogTitle = useMemo(
() =>
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
[creating, LL]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}&nbsp;
{LL.ANALOG_SENSOR(0)}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <Grid>
@@ -128,16 +194,10 @@ const SensorsAnalogDialog = ({
select select
onChange={updateFormValue} onChange={updateFormValue}
> >
{AnalogTypeNames.map((val, i) => ( {analogTypeMenuItems}
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
{((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) || {(isCounterOrRate || isFreqType) && (
(editItem.t >= AnalogType.FREQ_0 &&
editItem.t <= AnalogType.FREQ_2)) && (
<Grid> <Grid>
<TextField <TextField
name="u" name="u"
@@ -147,11 +207,7 @@ const SensorsAnalogDialog = ({
select select
onChange={updateFormValue} onChange={updateFormValue}
> >
{DeviceValueUOM_s.map((val, i) => ( {uomMenuItems}
<MenuItem key={val} value={i}>
{val}
</MenuItem>
))}
</TextField> </TextField>
</Grid> </Grid>
)} )}
@@ -226,7 +282,7 @@ const SensorsAnalogDialog = ({
/> />
</Grid> </Grid>
)} )}
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && ( {isCounterOrRate && (
<Grid> <Grid>
<TextField <TextField
name="f" name="f"
@@ -242,8 +298,7 @@ const SensorsAnalogDialog = ({
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.DIGITAL_OUT && {isDigitalOutGPIO && (
(editItem.g === 25 || editItem.g === 26) && (
<Grid> <Grid>
<TextField <TextField
name="o" name="o"
@@ -259,9 +314,7 @@ const SensorsAnalogDialog = ({
/> />
</Grid> </Grid>
)} )}
{editItem.t === AnalogType.DIGITAL_OUT && {isDigitalOutNonGPIO && (
editItem.g !== 25 &&
editItem.g !== 26 && (
<> <>
<Grid> <Grid>
<TextField <TextField
@@ -309,9 +362,7 @@ const SensorsAnalogDialog = ({
</Grid> </Grid>
</> </>
)} )}
{(editItem.t === AnalogType.PWM_0 || {isPWM && (
editItem.t === AnalogType.PWM_1 ||
editItem.t === AnalogType.PWM_2) && (
<> <>
<Grid> <Grid>
<TextField <TextField

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -33,6 +33,12 @@ interface SensorsTemperatureDialogProps {
validator: Schema; validator: Schema;
} }
// Constants
const OFFSET_MIN = -5;
const OFFSET_MAX = 5;
const OFFSET_STEP = 0.1;
const TEMP_UNIT = '°C';
const SensorsTemperatureDialog = ({ const SensorsTemperatureDialog = ({
open, open,
onClose, onClose,
@@ -43,7 +49,18 @@ const SensorsTemperatureDialog = ({
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem); const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
const updateFormValue = updateValue(setEditItem);
const updateFormValue = useMemo(
() =>
updateValue(
setEditItem as unknown as (
updater: (
prevState: Readonly<Record<string, unknown>>
) => Record<string, unknown>
) => void
),
[setEditItem]
);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -52,16 +69,16 @@ const SensorsTemperatureDialog = ({
} }
}, [open, selectedItem]); }, [open, selectedItem]);
const handleClose = ( const handleClose = useCallback(
_event: React.SyntheticEvent, (_event: React.SyntheticEvent, reason?: string) => {
reason: 'backdropClick' | 'escapeKeyDown'
) => {
if (reason !== 'backdropClick') { if (reason !== 'backdropClick') {
onClose(); onClose();
} }
}; },
[onClose]
);
const save = async () => { const save = useCallback(async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(validator, editItem); await validate(validator, editItem);
@@ -69,15 +86,31 @@ const SensorsTemperatureDialog = ({
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [validator, editItem, onSave]);
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
const slotProps = useMemo(
() => ({
input: {
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
},
htmlInput: {
min: OFFSET_MIN,
max: OFFSET_MAX,
step: OFFSET_STEP
}
}),
[]
);
return ( return (
<Dialog sx={dialogStyle} open={open} onClose={handleClose}> <Dialog sx={dialogStyle} open={open} onClose={handleClose}>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{LL.EDIT()}&nbsp;{LL.TEMP_SENSOR()}
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}> <Box color="warning.main" mb={2}>
<Typography variant="body2"> <Typography variant="body2">
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id} {LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
</Typography> </Typography>
@@ -85,7 +118,7 @@ const SensorsTemperatureDialog = ({
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? {}}
name="n" name="n"
label={LL.NAME(0)} label={LL.NAME(0)}
value={editItem.n} value={editItem.n}
@@ -97,19 +130,12 @@ const SensorsTemperatureDialog = ({
<TextField <TextField
name="o" name="o"
label={LL.OFFSET()} label={LL.OFFSET()}
value={numberValue(editItem.o)} value={offsetValue}
sx={{ width: '11ch' }} sx={{ width: '11ch' }}
type="number" type="number"
variant="outlined" variant="outlined"
onChange={updateFormValue} onChange={updateFormValue}
slotProps={{ slotProps={slotProps}
input: {
startAdornment: (
<InputAdornment position="start">°C</InputAdornment>
)
},
htmlInput: { min: '-5', max: '5', step: '0.1' }
}}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -2,27 +2,30 @@ import type { TranslationFunctions } from 'i18n/i18n-types';
import { DeviceValueUOM, DeviceValueUOM_s } from './types'; import { DeviceValueUOM, DeviceValueUOM_s } from './types';
// Cache NumberFormat instances for better performance
const numberFormatter = new Intl.NumberFormat();
const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
});
const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => { const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => {
const days = Math.trunc((duration_min * 60000) / 86400000); const totalMs = duration_min * 60000;
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; const days = Math.trunc(totalMs / 86400000);
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; const hours = Math.trunc(totalMs / 3600000) % 24;
const minutes = Math.trunc(duration_min) % 60;
let formatted = ''; const parts: string[] = [];
if (days) { if (days) {
formatted += LL.NUM_DAYS({ num: days }); parts.push(LL.NUM_DAYS({ num: days }));
} }
if (hours) { if (hours) {
if (formatted) formatted += ' '; parts.push(LL.NUM_HOURS({ num: hours }));
formatted += LL.NUM_HOURS({ num: hours });
} }
if (minutes) { if (minutes) {
if (formatted) formatted += ' '; parts.push(LL.NUM_MINUTES({ num: minutes }));
formatted += LL.NUM_MINUTES({ num: minutes });
} }
return formatted; return parts.join(' ');
}; };
export function formatValue( export function formatValue(
@@ -30,18 +33,21 @@ export function formatValue(
value?: unknown, value?: unknown,
uom?: DeviceValueUOM uom?: DeviceValueUOM
) { ) {
// Handle non-numeric values or missing data
if (typeof value !== 'number' || uom === undefined || value === undefined) { if (typeof value !== 'number' || uom === undefined || value === undefined) {
if (value === undefined || typeof value === 'boolean') { if (value === undefined || typeof value === 'boolean') {
return ''; return '';
} }
// Type assertion is safe here since we know it's not a number, boolean, or undefined
return ( return (
(value as string) + (value as string) +
(value === '' || uom === undefined || uom === 0 (value === '' || uom === undefined || uom === DeviceValueUOM.NONE
? '' ? ''
: ' ' + DeviceValueUOM_s[uom]) : ' ' + DeviceValueUOM_s[uom])
); );
} }
// Handle numeric values
switch (uom) { switch (uom) {
case DeviceValueUOM.HOURS: case DeviceValueUOM.HOURS:
return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 }); return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 });
@@ -50,18 +56,12 @@ export function formatValue(
case DeviceValueUOM.SECONDS: case DeviceValueUOM.SECONDS:
return LL.NUM_SECONDS({ num: value }); return LL.NUM_SECONDS({ num: value });
case DeviceValueUOM.NONE: case DeviceValueUOM.NONE:
return new Intl.NumberFormat().format(value); return numberFormatter.format(value);
case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES:
case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.DEGREES_R:
case DeviceValueUOM.FAHRENHEIT: case DeviceValueUOM.FAHRENHEIT:
return ( return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom];
new Intl.NumberFormat(undefined, {
minimumFractionDigits: 1
}).format(value) +
' ' +
DeviceValueUOM_s[uom]
);
default: default:
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom];
} }
} }

View File

@@ -60,7 +60,7 @@ export interface Stat {
} }
export interface Activity { export interface Activity {
stats: Stat[]; readonly stats: readonly Stat[];
} }
export interface Device { export interface Device {
@@ -112,8 +112,8 @@ export interface SensorData {
} }
export interface CoreData { export interface CoreData {
connected: boolean; readonly connected: boolean;
devices: Device[]; readonly devices: readonly Device[];
} }
export interface DashboardItem { export interface DashboardItem {
@@ -122,11 +122,12 @@ export interface DashboardItem {
n?: string; // name, optional n?: string; // name, optional
dv?: DeviceValue; // device value, optional dv?: DeviceValue; // device value, optional
nodes?: DashboardItem[]; // children nodes, optional nodes?: DashboardItem[]; // children nodes, optional
parentNode: DashboardItem; // to stop lint errors
} }
export interface DashboardData { export interface DashboardData {
connected: boolean; // true if connected to EMS bus readonly connected: boolean; // true if connected to EMS bus
nodes: DashboardItem[]; readonly nodes: readonly DashboardItem[];
} }
export interface DeviceValue { export interface DeviceValue {
@@ -139,10 +140,11 @@ export interface DeviceValue {
s?: string; // steps for up/down, optional s?: string; // steps for up/down, optional
m?: number; // min, optional m?: number; // min, optional
x?: number; // max, optional x?: number; // max, optional
[key: string]: unknown;
} }
export interface DeviceData { export interface DeviceData {
nodes: DeviceValue[]; readonly nodes: readonly DeviceValue[];
} }
export interface DeviceEntity { export interface DeviceEntity {
@@ -221,7 +223,7 @@ export const DeviceValueUOM_s = [
'l/h', 'l/h',
'ct/kWh', 'ct/kWh',
'Hz' 'Hz'
]; ] as const;
export enum AnalogType { export enum AnalogType {
REMOVED = -1, REMOVED = -1,
@@ -260,11 +262,9 @@ export const AnalogTypeNames = [
'Freq 0', 'Freq 0',
'Freq 1', 'Freq 1',
'Freq 2' 'Freq 2'
]; ] as const;
type BoardProfiles = Record<string, string>; export const BOARD_PROFILES = {
export const BOARD_PROFILES: BoardProfiles = {
S32: 'BBQKees Gateway S32', S32: 'BBQKees Gateway S32',
S32S3: 'BBQKees Gateway S3', S32S3: 'BBQKees Gateway S3',
E32: 'BBQKees Gateway E32', E32: 'BBQKees Gateway E32',
@@ -278,7 +278,9 @@ export const BOARD_PROFILES: BoardProfiles = {
C3MINI: 'Wemos C3 Mini', C3MINI: 'Wemos C3 Mini',
S2MINI: 'Wemos S2 Mini', S2MINI: 'Wemos S2 Mini',
S3MINI: 'Liligo S3' S3MINI: 'Liligo S3'
}; } as const;
export type BoardProfileKey = keyof typeof BOARD_PROFILES;
export interface BoardProfile { export interface BoardProfile {
board_profile: string; board_profile: string;
@@ -346,7 +348,7 @@ export interface ScheduleItem {
} }
export interface Schedule { export interface Schedule {
schedule: ScheduleItem[]; readonly schedule: readonly ScheduleItem[];
} }
export interface ModuleItem { export interface ModuleItem {
@@ -364,7 +366,7 @@ export interface ModuleItem {
} }
export interface Modules { export interface Modules {
modules: ModuleItem[]; readonly modules: readonly ModuleItem[];
} }
export enum ScheduleFlag { export enum ScheduleFlag {
@@ -413,7 +415,7 @@ export interface EntityItem {
} }
export interface Entities { export interface Entities {
entities: EntityItem[]; readonly entities: readonly EntityItem[];
} }
// matches emsdevice.h DeviceType // matches emsdevice.h DeviceType
@@ -469,4 +471,4 @@ export const DeviceValueTypeNames = [
'ENUM', 'ENUM',
'RAW', 'RAW',
'CMD' 'CMD'
]; ] as const;

View File

@@ -11,273 +11,264 @@ import type {
TemperatureSensor TemperatureSensor
} from './types'; } from './types';
export const GPIO_VALIDATOR = { // Constants
const ERROR_MESSAGES = {
GPIO_INVALID: 'Must be an valid GPIO port',
NAME_DUPLICATE: 'Name already in use',
GPIO_DUPLICATE: 'GPIO already in use',
VALUE_OUT_OF_RANGE: 'Value out of range',
HEX_REQUIRED: 'Is required and must be in hex format'
} as const;
const VALIDATION_LIMITS = {
PORT_MIN: 0,
PORT_MAX: 65535,
MODBUS_MAX_CLIENTS_MIN: 0,
MODBUS_MAX_CLIENTS_MAX: 50,
MODBUS_TIMEOUT_MIN: 100,
MODBUS_TIMEOUT_MAX: 20000,
SYSLOG_MARK_INTERVAL_MIN: 0,
SYSLOG_MARK_INTERVAL_MAX: 10,
SHOWER_MIN_DURATION_MIN: 10,
SHOWER_MIN_DURATION_MAX: 360,
SHOWER_ALERT_TRIGGER_MIN: 1,
SHOWER_ALERT_TRIGGER_MAX: 20,
SHOWER_ALERT_COLDSHOT_MIN: 1,
SHOWER_ALERT_COLDSHOT_MAX: 10,
REMOTE_TIMEOUT_MIN: 1,
REMOTE_TIMEOUT_MAX: 240,
OFFSET_MIN: 0,
OFFSET_MAX: 255,
COMMAND_MIN: 1,
COMMAND_MAX: 300,
NAME_MAX_LENGTH: 19,
HEX_BASE: 16
} as const;
// Helper to create GPIO validator from invalid ranges
const createGPIOValidator = (
invalidRanges: Array<number | [number, number]>,
maxValue: number
) => ({
validator( validator(
_rule: InternalRuleItem, _rule: InternalRuleItem,
value: number, value: number,
callback: (error?: string) => void callback: (error?: string) => void
) { ) {
if (!value) {
callback();
return;
}
if (value < 0 || value > maxValue) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
for (const range of invalidRanges) {
if (typeof range === 'number') {
if (value === range) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
} else {
const [start, end] = range;
if (value >= start && value <= end) {
callback(ERROR_MESSAGES.GPIO_INVALID);
return;
}
}
}
callback();
}
});
export const GPIO_VALIDATOR = createGPIOValidator(
[[6, 11], 1, 20, 24, [28, 31]],
40
);
export const GPIO_VALIDATORC3 = createGPIOValidator([[11, 19]], 21);
export const GPIO_VALIDATORS2 = createGPIOValidator(
[
[19, 20],
[22, 32]
],
40
);
export const GPIO_VALIDATORS3 = createGPIOValidator(
[
[19, 20],
[22, 37],
[39, 42]
],
48
);
const GPIO_FIELD_NAMES = [
'led_gpio',
'dallas_gpio',
'pbutton_gpio',
'tx_gpio',
'rx_gpio'
] as const;
type ValidationRules = Array<{
required?: boolean;
message?: string;
[key: string]: unknown;
}>;
const createGPIOValidations = (
validator: typeof GPIO_VALIDATOR
): Record<string, ValidationRules> =>
GPIO_FIELD_NAMES.reduce(
(acc, field) => {
const fieldName = field.replace('_gpio', '').toUpperCase();
acc[field] = [
{ required: true, message: `${fieldName} GPIO is required` },
validator
];
return acc;
},
{} as Record<string, ValidationRules>
);
const PLATFORM_VALIDATORS = {
ESP32: GPIO_VALIDATOR,
ESP32C3: GPIO_VALIDATORC3,
ESP32S2: GPIO_VALIDATORS2,
ESP32S3: GPIO_VALIDATORS3
} as const;
export const createSettingsValidator = (settings: Settings) => {
const schema: Record<string, ValidationRules> = {};
// Add GPIO validations for CUSTOM board profiles
if ( if (
value && settings.board_profile === 'CUSTOM' &&
(value === 1 || settings.platform in PLATFORM_VALIDATORS
(value >= 6 && value <= 11) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) { ) {
callback('Must be an valid GPIO port'); Object.assign(
} else { schema,
callback(); createGPIOValidations(
PLATFORM_VALIDATORS[settings.platform as keyof typeof PLATFORM_VALIDATORS]
)
);
} }
}
};
export const GPIO_VALIDATORR = { // Syslog validations
validator( if (settings.syslog_enabled) {
_rule: InternalRuleItem, schema.syslog_host = [
value: number,
callback: (error?: string) => void
) {
if (
value &&
(value === 1 ||
(value >= 6 && value <= 11) ||
(value >= 16 && value <= 17) ||
value === 20 ||
value === 24 ||
(value >= 28 && value <= 31) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORC3 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS2 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 32) ||
value > 40 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const GPIO_VALIDATORS3 = {
validator(
_rule: InternalRuleItem,
value: number,
callback: (error?: string) => void
) {
if (
value &&
((value >= 19 && value <= 20) ||
(value >= 22 && value <= 37) ||
(value >= 39 && value <= 42) ||
value > 48 ||
value < 0)
) {
callback('Must be an valid GPIO port');
} else {
callback();
}
}
};
export const createSettingsValidator = (settings: Settings) =>
new Schema({
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATOR
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATOR
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATOR
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATOR
],
rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32C3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORC3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORC3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORC3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORC3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORC3
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S2' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS2
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS2
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS2
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS2
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS2
]
}),
...(settings.board_profile === 'CUSTOM' &&
settings.platform === 'ESP32S3' && {
led_gpio: [
{ required: true, message: 'LED GPIO is required' },
GPIO_VALIDATORS3
],
dallas_gpio: [
{ required: true, message: 'GPIO is required' },
GPIO_VALIDATORS3
],
pbutton_gpio: [
{ required: true, message: 'Button GPIO is required' },
GPIO_VALIDATORS3
],
tx_gpio: [
{ required: true, message: 'Tx GPIO is required' },
GPIO_VALIDATORS3
],
rx_gpio: [
{ required: true, message: 'Rx GPIO is required' },
GPIO_VALIDATORS3
]
}),
...(settings.syslog_enabled && {
syslog_host: [
{ required: true, message: 'Host is required' }, { required: true, message: 'Host is required' },
IP_OR_HOSTNAME_VALIDATOR IP_OR_HOSTNAME_VALIDATOR
], ];
syslog_port: [ schema.syslog_port = [
{ required: true, message: 'Port is required' }, { required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' } {
], type: 'number',
syslog_mark_interval: [ min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.syslog_mark_interval = [
{ required: true, message: 'Mark interval is required' }, { required: true, message: 'Mark interval is required' },
{ type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' } {
] type: 'number',
}), min: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN,
...(settings.modbus_enabled && { max: VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX,
modbus_max_clients: [ message: `Must be between ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MIN} and ${VALIDATION_LIMITS.SYSLOG_MARK_INTERVAL_MAX}`
}
];
}
// Modbus validations
if (settings.modbus_enabled) {
schema.modbus_max_clients = [
{ required: true, message: 'Max clients is required' }, { required: true, message: 'Max clients is required' },
{ type: 'number', min: 0, max: 50, message: 'Invalid number' } {
], type: 'number',
modbus_port: [ min: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MIN,
max: VALIDATION_LIMITS.MODBUS_MAX_CLIENTS_MAX,
message: 'Invalid number'
}
];
schema.modbus_port = [
{ required: true, message: 'Port is required' }, { required: true, message: 'Port is required' },
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' } {
], type: 'number',
modbus_timeout: [ min: VALIDATION_LIMITS.PORT_MIN,
max: VALIDATION_LIMITS.PORT_MAX,
message: 'Invalid Port'
}
];
schema.modbus_timeout = [
{ required: true, message: 'Timeout is required' }, { required: true, message: 'Timeout is required' },
{ {
type: 'number', type: 'number',
min: 100, min: VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN,
max: 20000, max: VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX,
message: 'Must be between 100 and 20000' message: `Must be between ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MIN} and ${VALIDATION_LIMITS.MODBUS_TIMEOUT_MAX}`
} }
] ];
}),
...(settings.shower_timer && {
shower_min_duration: [
{
type: 'number',
min: 10,
max: 360,
message: 'Time must be between 10 and 360 seconds'
} }
]
}),
...(settings.shower_alert && {
shower_alert_trigger: [
{
type: 'number',
min: 1,
max: 20,
message: 'Time must be between 1 and 20 minutes'
}
],
shower_alert_coldshot: [
{
type: 'number',
min: 1,
max: 10,
message: 'Time must be between 1 and 10 seconds'
}
]
}),
...(settings.remote_timeout_en && {
remote_timeout: [
{
type: 'number',
min: 1,
max: 240,
message: 'Timeout must be between 1 and 240 hours'
}
]
})
});
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({ // Shower timer validations
if (settings.shower_timer) {
schema.shower_min_duration = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN,
max: VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MIN} and ${VALIDATION_LIMITS.SHOWER_MIN_DURATION_MAX} seconds`
}
];
}
// Shower alert validations
if (settings.shower_alert) {
schema.shower_alert_trigger = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_TRIGGER_MAX} minutes`
}
];
schema.shower_alert_coldshot = [
{
type: 'number',
min: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN,
max: VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX,
message: `Time must be between ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MIN} and ${VALIDATION_LIMITS.SHOWER_ALERT_COLDSHOT_MAX} seconds`
}
];
}
// Remote timeout validations
if (settings.remote_timeout_en) {
schema.remote_timeout = [
{
type: 'number',
min: VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN,
max: VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX,
message: `Timeout must be between ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MIN} and ${VALIDATION_LIMITS.REMOTE_TIMEOUT_MAX} hours`
}
];
}
return new Schema(schema);
};
// Generic unique name validator factory
const createUniqueNameValidator = <T extends { name: string }>(
items: T[],
originalName?: string
) => ({
validator( validator(
_rule: InternalRuleItem, _rule: InternalRuleItem,
name: string, name: string,
@@ -285,43 +276,22 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =
) { ) {
if ( if (
name !== '' && name !== '' &&
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) && (originalName === undefined ||
schedule.find((si) => si.name.toLowerCase() === name.toLowerCase()) originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => item.name.toLowerCase() === name.toLowerCase())
) { ) {
callback('Name already in use'); callback(ERROR_MESSAGES.NAME_DUPLICATE);
} else { return;
callback();
} }
callback();
} }
}); });
export const schedulerItemValidation = ( // Generic field name validator (for cases where the name field has different property names)
schedule: ScheduleItem[], const createUniqueFieldNameValidator = <T>(
scheduleItem: ScheduleItem items: T[],
) => getName: (item: T) => string,
new Schema({ originalName?: string
name: [
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: 1,
max: 300,
message: 'Command must be 1-300 characters'
}
]
});
export const uniqueCustomNameValidator = (
entity: EntityItem[],
o_name?: string
) => ({ ) => ({
validator( validator(
_rule: InternalRuleItem, _rule: InternalRuleItem,
@@ -329,58 +299,91 @@ export const uniqueCustomNameValidator = (
callback: (error?: string) => void callback: (error?: string) => void
) { ) {
if ( if (
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) && name !== '' &&
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase()) (originalName === undefined ||
originalName.toLowerCase() !== name.toLowerCase()) &&
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
) { ) {
callback('Name already in use'); callback(ERROR_MESSAGES.NAME_DUPLICATE);
} else { return;
}
callback(); callback();
} }
}
}); });
const NAME_PATTERN_BASE = '[a-zA-Z0-9_]';
const NAME_PATTERN_MESSAGE = `Must be <${VALIDATION_LIMITS.NAME_MAX_LENGTH + 1} characters: alphanumeric or '_'`;
const NAME_PATTERN = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{0,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
const NAME_PATTERN_REQUIRED = {
type: 'string' as const,
pattern: new RegExp(
`^${NAME_PATTERN_BASE}{1,${VALIDATION_LIMITS.NAME_MAX_LENGTH}}$`
),
message: NAME_PATTERN_MESSAGE
};
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
createUniqueNameValidator(schedule, o_name);
export const schedulerItemValidation = (
schedule: ScheduleItem[],
scheduleItem: ScheduleItem
) =>
new Schema({
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
cmd: [
{ required: true, message: 'Command is required' },
{
type: 'string',
min: VALIDATION_LIMITS.COMMAND_MIN,
max: VALIDATION_LIMITS.COMMAND_MAX,
message: `Command must be ${VALIDATION_LIMITS.COMMAND_MIN}-${VALIDATION_LIMITS.COMMAND_MAX} characters`
}
]
});
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
createUniqueNameValidator(entity, o_name);
const hexValidator = {
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (!value || Number.isNaN(Number.parseInt(value, VALIDATION_LIMITS.HEX_BASE))) {
callback(ERROR_MESSAGES.HEX_REQUIRED);
return;
}
callback();
}
};
export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) => export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) =>
new Schema({ new Schema({
name: [ name: [
{ required: true, message: 'Name is required' }, { required: true, message: 'Name is required' },
{ NAME_PATTERN_REQUIRED,
type: 'string', uniqueCustomNameValidator(entity, entityItem.o_name)
pattern: /^[a-zA-Z0-9_]{1,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueCustomNameValidator(entity, entityItem.o_name)]
],
device_id: [
{
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
],
type_id: [
{
validator(
_rule: InternalRuleItem,
value: string,
callback: (error?: string) => void
) {
if (isNaN(parseInt(value, 16))) {
callback('Is required and must be in hex format');
}
callback();
}
}
], ],
device_id: [hexValidator],
type_id: [hexValidator],
offset: [ offset: [
{ required: true, message: 'Offset is required' }, { required: true, message: 'Offset is required' },
{ type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' } {
type: 'number',
min: VALIDATION_LIMITS.OFFSET_MIN,
max: VALIDATION_LIMITS.OFFSET_MAX,
message: `Must be between ${VALIDATION_LIMITS.OFFSET_MIN} and ${VALIDATION_LIMITS.OFFSET_MAX}`
}
], ],
factor: [{ required: true, message: 'is required' }] factor: [{ required: true, message: 'is required' }]
}); });
@@ -388,33 +391,14 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte
export const uniqueTemperatureNameValidator = ( export const uniqueTemperatureNameValidator = (
sensors: TemperatureSensor[], sensors: TemperatureSensor[],
o_name?: string o_name?: string
) => ({ ) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if (
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
n !== '' &&
sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase())
) {
callback('Name already in use');
} else {
callback();
}
}
});
export const temperatureSensorItemValidation = ( export const temperatureSensorItemValidation = (
sensors: TemperatureSensor[], sensors: TemperatureSensor[],
sensor: TemperatureSensor sensor: TemperatureSensor
) => ) =>
new Schema({ new Schema({
n: [ n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)]
{
type: 'string',
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueTemperatureNameValidator(sensors, sensor.o_n)]
]
}); });
export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({ export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
@@ -423,58 +407,49 @@ export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
gpio: number, gpio: number,
callback: (error?: string) => void callback: (error?: string) => void
) { ) {
if (sensors.find((as) => as.g === gpio)) { if (sensors.some((as) => as.g === gpio)) {
callback('GPIO already in use'); callback(ERROR_MESSAGES.GPIO_DUPLICATE);
} else { return;
callback();
} }
callback();
} }
}); });
export const uniqueAnalogNameValidator = ( export const uniqueAnalogNameValidator = (
sensors: AnalogSensor[], sensors: AnalogSensor[],
o_name?: string o_name?: string
) => ({ ) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name);
validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) {
if ( const getPlatformGPIOValidator = (platform: string) => {
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) && switch (platform) {
n !== '' && case 'ESP32S3':
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase()) return GPIO_VALIDATORS3;
) { case 'ESP32S2':
callback('Name already in use'); return GPIO_VALIDATORS2;
} else { case 'ESP32C3':
callback(); return GPIO_VALIDATORC3;
default:
return GPIO_VALIDATOR;
} }
} };
});
export const analogSensorItemValidation = ( export const analogSensorItemValidation = (
sensors: AnalogSensor[], sensors: AnalogSensor[],
sensor: AnalogSensor, sensor: AnalogSensor,
creating: boolean, creating: boolean,
platform: string platform: string
) => ) => {
new Schema({ const gpioValidator = getPlatformGPIOValidator(platform);
n: [
{ return new Schema({
type: 'string', n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)],
pattern: /^[a-zA-Z0-9_]{0,19}$/,
message: "Must be <20 characters: alphanumeric or '_'"
},
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
],
g: [ g: [
{ required: true, message: 'GPIO is required' }, { required: true, message: 'GPIO is required' },
platform === 'ESP32S3' gpioValidator,
? GPIO_VALIDATORS3
: platform === 'ESP32S2'
? GPIO_VALIDATORS2
: platform === 'ESP32C3'
? GPIO_VALIDATORC3
: GPIO_VALIDATOR,
...(creating ? [isGPIOUniqueValidator(sensors)] : []) ...(creating ? [isGPIOUniqueValidator(sensors)] : [])
] ]
}); });
};
export const deviceValueItemValidation = (dv: DeviceValue) => export const deviceValueItemValidation = (dv: DeviceValue) =>
new Schema({ new Schema({
@@ -488,11 +463,12 @@ export const deviceValueItemValidation = (dv: DeviceValue) =>
) { ) {
if ( if (
typeof value === 'number' && typeof value === 'number' &&
dv.m && dv.m !== undefined &&
dv.x && dv.x !== undefined &&
(value < dv.m || value > dv.x) (value < dv.m || value > dv.x)
) { ) {
callback('Value out of range'); callback(ERROR_MESSAGES.VALUE_OUT_OF_RANGE);
return;
} }
callback(); callback();
} }

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -27,6 +27,19 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
// Efficient range function without recursion
const createRange = (start: number, end: number): number[] => {
const result: number[] = [];
for (let i = start; i <= end; i++) {
result.push(i);
}
return result;
};
// Pre-computed ranges for better performance
const CHANNEL_RANGE = createRange(1, 14);
const MAX_CLIENTS_RANGE = createRange(1, 9);
const APSettings = () => { const APSettings = () => {
const { const {
loadData, loadData,
@@ -50,19 +63,24 @@ const APSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
origData, () =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const content = () => { // Memoize AP enabled state
if (!data) { const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} // Memoize validation and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data); await validate(createAPSettingsValidator(data), data);
@@ -70,11 +88,11 @@ const APSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [data, saveData]);
// no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/ const content = () => {
function range(a: number, b: number): number[] { if (!data) {
return a < b ? [a, ...range(a + 1, b)] : [b]; return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
} }
return ( return (
@@ -100,7 +118,7 @@ const APSettings = () => {
{LL.AP_PROVIDE_TEXT_3()} {LL.AP_PROVIDE_TEXT_3()}
</MenuItem> </MenuItem>
</ValidatedTextField> </ValidatedTextField>
{isAPEnabled(data) && ( {apEnabled && (
<> <>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors || {}}
@@ -134,7 +152,7 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
{range(1, 14).map((i) => ( {CHANNEL_RANGE.map((i) => (
<MenuItem key={i} value={i}> <MenuItem key={i} value={i}>
{i} {i}
</MenuItem> </MenuItem>
@@ -162,7 +180,7 @@ const APSettings = () => {
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
> >
{range(1, 9).map((i) => ( {MAX_CLIENTS_RANGE.map((i) => (
<MenuItem key={i} value={i}> <MenuItem key={i} value={i}>
{i} {i}
</MenuItem> </MenuItem>

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -72,7 +72,7 @@ const ApplicationSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
@@ -106,39 +106,49 @@ const ApplicationSettings = () => {
}); });
}); });
const doRestart = async () => { // Memoized input props to prevent recreation on every render
const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}),
[LL]
);
const MinutesInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
}),
[LL]
);
const HoursInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
}),
[LL]
);
const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const updateBoardProfile = async (board_profile: string) => { const updateBoardProfile = useCallback(
async (board_profile: string) => {
await readBoardProfile(board_profile).catch((error: Error) => { await readBoardProfile(board_profile).catch((error: Error) => {
toast.error(error.message); toast.error(error.message);
}); });
}; },
[readBoardProfile]
);
useLayoutTitle(LL.APPLICATION()); useLayoutTitle(LL.APPLICATION());
const SecondsInputProps = { const validateAndSubmit = useCallback(async () => {
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
};
const MinutesInputProps = {
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
};
const HoursInputProps = {
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
};
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createSettingsValidator(data), data); await validate(createSettingsValidator(data), data);
@@ -147,9 +157,10 @@ const ApplicationSettings = () => {
} finally { } finally {
await saveData(); await saveData();
} }
}; }, [data, saveData]);
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => { const changeBoardProfile = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const boardProfile = event.target.value; const boardProfile = event.target.value;
updateFormValue(event); updateFormValue(event);
if (boardProfile === 'CUSTOM') { if (boardProfile === 'CUSTOM') {
@@ -160,12 +171,22 @@ const ApplicationSettings = () => {
} else { } else {
void updateBoardProfile(boardProfile); void updateBoardProfile(boardProfile);
} }
}; },
[data, updateBoardProfile, updateFormValue, updateDataValue]
);
const restart = async () => { const restart = useCallback(async () => {
await validateAndSubmit(); await validateAndSubmit();
await doRestart(); await doRestart();
}; }, [validateAndSubmit, doRestart]);
// Memoize board profile select items to prevent recreation
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
const content = () => {
if (!data || !hardwareData) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return ( return (
<> <>
@@ -474,7 +495,7 @@ const ApplicationSettings = () => {
margin="normal" margin="normal"
select select
> >
{boardProfileSelectItems()} {boardProfileItems}
<Divider /> <Divider />
<MenuItem key={'CUSTOM'} value={'CUSTOM'}> <MenuItem key={'CUSTOM'} value={'CUSTOM'}>
{LL.CUSTOM()}&hellip; {LL.CUSTOM()}&hellip;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -19,6 +19,13 @@ import {
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import { saveFile } from 'utils'; import { saveFile } from 'utils';
interface DownloadButton {
key: string;
type: string;
label: string | number;
isGridButton: boolean;
}
const DownloadUpload = () => { const DownloadUpload = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -44,24 +51,78 @@ const DownloadUpload = () => {
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( try {
(error: Error) => { await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
toast.error(error.message); } catch (error) {
toast.error((error as Error).message);
setRestarting(false);
} }
); }, [sendAPI]);
};
useLayoutTitle(LL.DOWNLOAD_UPLOAD()); useLayoutTitle(LL.DOWNLOAD_UPLOAD());
const content = () => { const downloadButtons: DownloadButton[] = useMemo(
if (!data) { () => [
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; {
key: 'settings',
type: 'settings',
label: LL.SETTINGS_OF(LL.APPLICATION()),
isGridButton: true
},
{
key: 'customizations',
type: 'customizations',
label: LL.CUSTOMIZATIONS(),
isGridButton: true
},
{
key: 'entities',
type: 'entities',
label: LL.CUSTOM_ENTITIES(0),
isGridButton: true
},
{
key: 'schedule',
type: 'schedule',
label: LL.SCHEDULE(0),
isGridButton: true
},
{
key: 'allvalues',
type: 'allvalues',
label: LL.ALLVALUES(),
isGridButton: false
}
],
[LL]
);
const handleDownload = useCallback(
(type: string) => () => {
void sendExportData(type);
},
[sendExportData]
);
if (restarting) {
return <SystemMonitor />;
} }
if (!data) {
return ( return (
<> <SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
const gridButtons = downloadButtons.filter((btn) => btn.isGridButton);
const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton);
return (
<SectionContent>
<Typography sx={{ pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pb: 2 }} variant="h6" color="primary">
{LL.DOWNLOAD(0)} {LL.DOWNLOAD(0)}
</Typography> </Typography>
@@ -69,54 +130,36 @@ const DownloadUpload = () => {
<Typography mb={1} variant="body1" color="warning"> <Typography mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT()}. {LL.DOWNLOAD_SETTINGS_TEXT()}.
</Typography> </Typography>
<Grid container spacing={1}>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('settings')}
>
{LL.SETTINGS_OF(LL.APPLICATION())}
</Button>
<Grid container spacing={2}>
{gridButtons.map((button) => (
<Grid key={button.key}>
<Button <Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => sendExportData('customizations')} onClick={handleDownload(button.type)}
> >
{LL.CUSTOMIZATIONS()} {button.label}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('entities')}
>
{LL.CUSTOM_ENTITIES(0)}
</Button>
<Button
sx={{ ml: 2 }}
startIcon={<DownloadIcon />}
variant="outlined"
color="primary"
onClick={() => sendExportData('schedule')}
>
{LL.SCHEDULE(0)}
</Button> </Button>
</Grid> </Grid>
))}
</Grid>
<Typography mt={2} mb={1} variant="body1" color="warning">
{LL.DOWNLOAD_SETTINGS_TEXT2()}.
</Typography>
{standaloneButton && (
<Button <Button
sx={{ ml: 2, mt: 2 }}
startIcon={<DownloadIcon />} startIcon={<DownloadIcon />}
variant="outlined" variant="outlined"
color="primary" color="primary"
onClick={() => sendExportData('allvalues')} onClick={handleDownload(standaloneButton.type)}
> >
{LL.ALLVALUES()} {standaloneButton.label}
</Button> </Button>
)}
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary"> <Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
{LL.UPLOAD()} {LL.UPLOAD()}
@@ -127,12 +170,7 @@ const DownloadUpload = () => {
</Box> </Box>
<SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} /> <SingleUpload text={LL.UPLOAD_DRAG()} doRestart={doRestart} />
</> </SectionContent>
);
};
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -52,23 +52,28 @@ const MqttSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValueDirty( const updateFormValue = useMemo(
origData, () =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const SecondsInputProps = { const SecondsInputProps = useMemo(
() => ({
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment> endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
}; }),
[LL]
);
const content = () => { const emptyFieldErrors = useMemo(() => ({}), []);
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => { const validateAndSubmit = useCallback(async () => {
if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createMqttSettingsValidator(data), data); await validate(createMqttSettingsValidator(data), data);
@@ -76,9 +81,38 @@ const MqttSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [data, saveData]);
const publishIntervalFields = useMemo(
() => [
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
{
name: 'publish_time_thermostat',
label: LL.MQTT_INT_THERMOSTATS(),
validated: false
},
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
],
[LL]
);
if (!data) {
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />
</SectionContent>
);
}
return ( return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
<> <>
<BlockFormControlLabel <BlockFormControlLabel
control={ control={
@@ -93,7 +127,7 @@ const MqttSettings = () => {
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="host" name="host"
label={LL.ADDRESS_OF(LL.BROKER())} label={LL.ADDRESS_OF(LL.BROKER())}
multiline multiline
@@ -105,7 +139,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="port" name="port"
label="Port" label="Port"
variant="outlined" variant="outlined"
@@ -117,7 +151,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="base" name="base"
label={LL.BASE_TOPIC()} label={LL.BASE_TOPIC()}
variant="outlined" variant="outlined"
@@ -129,7 +163,7 @@ const MqttSettings = () => {
<Grid> <Grid>
<TextField <TextField
name="client_id" name="client_id"
label={LL.ID_OF(LL.CLIENT()) + ' (' + LL.OPTIONAL() + ')'} label={`${LL.ID_OF(LL.CLIENT())} (${LL.OPTIONAL()})`}
variant="outlined" variant="outlined"
value={data.client_id} value={data.client_id}
onChange={updateFormValue} onChange={updateFormValue}
@@ -158,7 +192,7 @@ const MqttSettings = () => {
</Grid> </Grid>
<Grid> <Grid>
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="keep_alive" name="keep_alive"
label="Keep Alive" label="Keep Alive"
slotProps={{ slotProps={{
@@ -352,111 +386,32 @@ const MqttSettings = () => {
{LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto) {LL.MQTT_PUBLISH_INTERVALS()}&nbsp;(0=auto)
</Typography> </Typography>
<Grid container spacing={2} rowSpacing={0}> <Grid container spacing={2} rowSpacing={0}>
<Grid> {publishIntervalFields.map((field) => (
<Grid key={field.name}>
{field.validated ? (
<ValidatedTextField <ValidatedTextField
fieldErrors={fieldErrors || {}} fieldErrors={fieldErrors ?? emptyFieldErrors}
name="publish_time_heartbeat" name={field.name}
label="Heartbeat" label={field.label}
slotProps={{ slotProps={{
input: SecondsInputProps input: SecondsInputProps
}} }}
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_heartbeat)} value={numberValue(
data[field.name as keyof MqttSettingsType] as number
)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
/> />
</Grid> ) : (
<Grid>
<TextField <TextField
name="publish_time_boiler" name={field.name}
label={LL.MQTT_INT_BOILER()} label={field.label}
variant="outlined" variant="outlined"
value={numberValue(data.publish_time_boiler)} value={numberValue(
type="number" data[field.name as keyof MqttSettingsType] as number
onChange={updateFormValue} )}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_thermostat"
label={LL.MQTT_INT_THERMOSTATS()}
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_solar"
label={LL.MQTT_INT_SOLAR()}
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_mixer"
label={LL.MQTT_INT_MIXER()}
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_water"
label={LL.MQTT_INT_WATER()}
variant="outlined"
value={numberValue(data.publish_time_water)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_sensor"
label={LL.SENSORS()}
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
slotProps={{
input: SecondsInputProps
}}
/>
</Grid>
<Grid>
<TextField
name="publish_time_other"
label={LL.DEFAULT(0)}
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number" type="number"
onChange={updateFormValue} onChange={updateFormValue}
margin="normal" margin="normal"
@@ -464,7 +419,9 @@ const MqttSettings = () => {
input: SecondsInputProps input: SecondsInputProps
}} }}
/> />
)}
</Grid> </Grid>
))}
</Grid> </Grid>
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
@@ -491,13 +448,6 @@ const MqttSettings = () => {
</ButtonRow> </ButtonRow>
)} )}
</> </>
);
};
return (
<SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent> </SectionContent>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -39,7 +39,7 @@ import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
import { validate } from 'validators'; import { validate } from 'validators';
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ'; import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ';
const NTPSettings = () => { const NTPSettings = () => {
const { const {
@@ -61,9 +61,19 @@ const NTPSettings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('NTP'); useLayoutTitle('NTP');
// Memoized timezone select items for better performance
const timeZoneItems = useTimeZoneSelectItems();
// Memoized selected timezone value
const selectedTzValue = useMemo(
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
[data?.tz_label, data?.tz_format]
);
const [localTime, setLocalTime] = useState<string>(''); const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false); const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false); const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { send: updateTime } = useRequest( const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time), (local_time: Time) => NTPApi.updateTime(local_time),
@@ -72,93 +82,52 @@ const NTPSettings = () => {
} }
); );
const updateFormValue = updateValueDirty( // Memoize updateFormValue to prevent recreation on every render
origData, const updateFormValue = useMemo(
() =>
updateValueDirty(
origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
),
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
); );
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); // Memoize updateLocalTime handler
const updateLocalTime = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
[]
);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => // Memoize openSetTime handler
setLocalTime(event.target.value); const openSetTime = useCallback(() => {
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date())); setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true); setSettingTime(true);
}; }, []);
const configureTime = async () => { // Memoize configureTime handler
const configureTime = useCallback(async () => {
setProcessing(true); setProcessing(true);
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) }) try {
.then(async () => { await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) });
toast.success(LL.TIME_SET()); toast.success(LL.TIME_SET());
setSettingTime(false); setSettingTime(false);
await loadData(); await loadData();
}) } catch {
.catch(() => {
toast.error(LL.PROBLEM_UPDATING()); toast.error(LL.PROBLEM_UPDATING());
}) } finally {
.finally(() => {
setProcessing(false); setProcessing(false);
});
};
const renderSetTimeDialog = () => (
<Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
} }
}} }, [localTime, updateTime, LL, loadData]);
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => { // Memoize close dialog handler
if (!data) { const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => { // Memoize validate and submit handler
const validateAndSubmit = useCallback(async () => {
if (!data) return;
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data); await validate(NTP_SETTINGS_VALIDATOR, data);
@@ -166,16 +135,26 @@ const NTPSettings = () => {
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [data, saveData]);
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => { // Memoize timezone change handler
const changeTimeZone = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
...settings, ...settings,
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
})); }));
updateFormValue(event); updateFormValue(event);
}; },
[updateFormValue]
);
// Memoize render content to prevent unnecessary re-renders
const renderContent = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return ( return (
<> <>
@@ -205,13 +184,13 @@ const NTPSettings = () => {
label={LL.TIME_ZONE()} label={LL.TIME_ZONE()}
fullWidth fullWidth
variant="outlined" variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)} value={selectedTzValue}
onChange={changeTimeZone} onChange={changeTimeZone}
margin="normal" margin="normal"
select select
> >
<MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem> <MenuItem disabled>{LL.TIME_ZONE()}...</MenuItem>
{timeZoneSelectItems()} {timeZoneItems}
</ValidatedTextField> </ValidatedTextField>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
@@ -230,7 +209,6 @@ const NTPSettings = () => {
</Box> </Box>
)} )}
</Box> </Box>
{renderSetTimeDialog()}
{dirtyFlags && dirtyFlags.length !== 0 && ( {dirtyFlags && dirtyFlags.length !== 0 && (
<ButtonRow> <ButtonRow>
@@ -258,12 +236,66 @@ const NTPSettings = () => {
)} )}
</> </>
); );
}; }, [
data,
errorMessage,
loadData,
updateFormValue,
fieldErrors,
selectedTzValue,
changeTimeZone,
timeZoneItems,
dirtyFlags,
openSetTime,
saving,
validateAndSubmit,
LL
]);
return ( return (
<SectionContent> <SectionContent>
{blocker ? <BlockNavigation blocker={blocker} /> : null} {blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()} {renderContent}
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
slotProps={{
inputLabel: {
shrink: true
}
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseSetTime}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
</SectionContent> </SectionContent>
); );
}; };

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useCallback, useState } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -34,46 +34,25 @@ const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SETTINGS(0)); useLayoutTitle(LL.SETTINGS(0));
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false); const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false immediate: false
}); });
const doFormat = async () => { const doFormat = useCallback(async () => {
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
setConfirmFactoryReset(false); setConfirmFactoryReset(false);
}); });
}; }, [sendAPI]);
const renderFactoryResetDialog = () => ( const handleFactoryResetClose = useCallback(() => {
<Dialog setConfirmFactoryReset(false);
sx={dialogStyle} }, []);
open={confirmFactoryReset}
onClose={() => setConfirmFactoryReset(false)} const handleFactoryResetClick = useCallback(() => {
> setConfirmFactoryReset(true);
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle> }, []);
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
);
return ( return (
<SectionContent> <SectionContent>
@@ -142,7 +121,32 @@ const Settings = () => {
/> />
</List> </List>
{renderFactoryResetDialog()} <Dialog
sx={dialogStyle}
open={confirmFactoryReset}
onClose={handleFactoryResetClose}
>
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleFactoryResetClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={doFormat}
color="error"
>
{LL.FACTORY_RESET()}
</Button>
</DialogActions>
</Dialog>
<Divider /> <Divider />
@@ -156,7 +160,7 @@ const Settings = () => {
<Button <Button
startIcon={<SettingsBackupRestoreIcon />} startIcon={<SettingsBackupRestoreIcon />}
variant="outlined" variant="outlined"
onClick={() => setConfirmFactoryReset(true)} onClick={handleFactoryResetClick}
color="error" color="error"
> >
{LL.FACTORY_RESET()} {LL.FACTORY_RESET()}

View File

@@ -1,8 +1,10 @@
import { useMemo } from 'react';
import { MenuItem } from '@mui/material'; import { MenuItem } from '@mui/material';
type TimeZones = Record<string, string>; type TimeZones = Record<string, string>;
export const TIME_ZONES: TimeZones = { export const TIME_ZONES: Readonly<TimeZones> = {
'Africa/Abidjan': 'GMT0', 'Africa/Abidjan': 'GMT0',
'Africa/Accra': 'GMT0', 'Africa/Accra': 'GMT0',
'Africa/Addis_Ababa': 'EAT-3', 'Africa/Addis_Ababa': 'EAT-3',
@@ -465,14 +467,33 @@ export const TIME_ZONES: TimeZones = {
'Pacific/Wallis': 'UNK-12' 'Pacific/Wallis': 'UNK-12'
}; };
// Pre-compute sorted timezone labels for better performance
export const TIME_ZONE_LABELS = Object.keys(TIME_ZONES).sort();
export function selectedTimeZone(label: string, format: string) { export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
export function timeZoneSelectItems() { // Memoized version for use in components
return Object.keys(TIME_ZONES).map((label) => ( export function useTimeZoneSelectItems() {
return useMemo(
() =>
TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}> <MenuItem key={label} value={label}>
{label} {label}
</MenuItem> </MenuItem>
)); )),
[]
);
}
// Fallback export for backward compatibility - now memoized
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
<MenuItem key={label} value={label}>
{label}
</MenuItem>
));
export function timeZoneSelectItems() {
return precomputedTimeZoneItems;
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { memo, useCallback, useMemo, useState } from 'react';
import { import {
Navigate, Navigate,
Route, Route,
@@ -28,8 +28,7 @@ const Network = () => {
[ [
{ {
path: '/settings/network/settings', path: '/settings/network/settings',
element: <NetworkSettings />, element: <NetworkSettings />
dog: 'woof'
}, },
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> } { path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
], ],
@@ -53,14 +52,17 @@ const Network = () => {
setSelectedNetwork(undefined); setSelectedNetwork(undefined);
}, []); }, []);
return ( const contextValue = useMemo(
<WiFiConnectionContext.Provider () => ({
value={{
...(selectedNetwork && { selectedNetwork }), ...(selectedNetwork && { selectedNetwork }),
selectNetwork, selectNetwork,
deselectNetwork deselectNetwork
}} }),
> [selectedNetwork, selectNetwork, deselectNetwork]
);
return (
<WiFiConnectionContext.Provider value={contextValue}>
<RouterTabs value={routerTab}> <RouterTabs value={routerTab}>
<Tab <Tab
value="/settings/network/settings" value="/settings/network/settings"
@@ -80,4 +82,4 @@ const Network = () => {
); );
}; };
export default Network; export default memo(Network);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react'; import { memo, useCallback, useContext, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -109,14 +109,8 @@ const NetworkSettings = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
useEffect(() => deselectNetwork, [deselectNetwork]); const validateAndSubmit = useCallback(async () => {
if (!data) return;
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data); await validate(createNetworkSettingsValidator(data), data);
@@ -125,21 +119,26 @@ const NetworkSettings = () => {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
deselectNetwork(); deselectNetwork();
}; }, [data, saveData, deselectNetwork]);
const setCancel = async () => { const setCancel = useCallback(async () => {
deselectNetwork(); deselectNetwork();
await loadData(); await loadData();
}; }, [deselectNetwork, loadData]);
const doRestart = async () => { const doRestart = useCallback(async () => {
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => { (error: Error) => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return ( return (
<> <>
@@ -165,7 +164,7 @@ const NetworkSettings = () => {
selectedNetwork.bssid selectedNetwork.bssid
} }
/> />
<IconButton onClick={setCancel}> <IconButton onClick={setCancel} aria-label={LL.CANCEL()}>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
</ListItem> </ListItem>
@@ -405,4 +404,4 @@ const NetworkSettings = () => {
); );
}; };
export default NetworkSettings; export default memo(NetworkSettings);

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { memo, useCallback, useRef, useState } from 'react';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi'; import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
} }
}); });
const renderNetworkScanner = () => { const renderNetworkScanner = useCallback(() => {
if (!networkList) { if (!networkList) {
return <FormLoader errorMessage={errorMessage || ''} />; return <FormLoader errorMessage={errorMessage || ''} />;
} }
return <WiFiNetworkSelector networkList={networkList} />; return <WiFiNetworkSelector networkList={networkList} />;
}; }, [networkList, errorMessage]);
return ( return (
<SectionContent> <SectionContent>
@@ -73,4 +73,4 @@ const WiFiNetworkScanner = () => {
); );
}; };
export default WiFiNetworkScanner; export default memo(WiFiNetworkScanner);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react'; import { memo, useCallback, useContext } from 'react';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen'; import LockOpenIcon from '@mui/icons-material/LockOpen';
@@ -63,7 +63,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
const wifiConnectionContext = useContext(WiFiConnectionContext); const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => ( const renderNetwork = useCallback(
(network: WiFiNetwork) => (
<ListItem <ListItem
key={network.bssid} key={network.bssid}
onClick={() => wifiConnectionContext.selectNetwork(network)} onClick={() => wifiConnectionContext.selectNetwork(network)}
@@ -88,6 +89,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
</ListItem> </ListItem>
),
[wifiConnectionContext, theme]
); );
if (networkList.networks.length === 0) { if (networkList.networks.length === 0) {
@@ -97,4 +100,4 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
return <List>{networkList.networks.map(renderNetwork)}</List>; return <List>{networkList.networks.map(renderNetwork)}</List>;
}; };
export default WiFiNetworkSelector; export default memo(WiFiNetworkSelector);

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { memo, useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import { import {
@@ -40,7 +40,7 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
if (open) { if (open) {
void generateToken(); void generateToken();
} }
}, [open]); }, [open, generateToken]);
return ( return (
<Dialog <Dialog
@@ -86,4 +86,4 @@ const GenerateToken = ({ username, onClose }: GenerateTokenProps) => {
); );
}; };
export default GenerateToken; export default memo(GenerateToken);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import { useBlocker } from 'react-router'; import { useBlocker } from 'react-router';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -55,7 +55,9 @@ const ManageUsers = () => {
const blocker = useBlocker(changed !== 0); const blocker = useBlocker(changed !== 0);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const table_theme = useTheme({ const table_theme = useMemo(
() =>
useTheme({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
`, `,
@@ -93,41 +95,45 @@ const ManageUsers = () => {
text-align: right; text-align: right;
} }
` `
}); }),
[]
);
const content = () => { const noAdminConfigured = useCallback(
if (!data) { () => !data?.users.find((u) => u.admin),
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />; [data]
} );
const noAdminConfigured = () => !data.users.find((u) => u.admin); const removeUser = useCallback(
(toRemove: UserType) => {
const removeUser = (toRemove: UserType) => { if (!data) return;
const users = data.users.filter((u) => u.username !== toRemove.username); const users = data.users.filter((u) => u.username !== toRemove.username);
updateDataValue({ ...data, users }); updateDataValue({ ...data, users });
setChanged(changed + 1); setChanged(changed + 1);
}; },
[data, updateDataValue, changed]
);
const createUser = () => { const createUser = useCallback(() => {
setCreating(true); setCreating(true);
setUser({ setUser({
username: '', username: '',
password: '', password: '',
admin: true admin: true
}); });
}; }, []);
const editUser = (toEdit: UserType) => { const editUser = useCallback((toEdit: UserType) => {
setCreating(false); setCreating(false);
setUser({ ...toEdit }); setUser({ ...toEdit });
}; }, []);
const cancelEditingUser = () => { const cancelEditingUser = useCallback(() => {
setUser(undefined); setUser(undefined);
}; }, []);
const doneEditingUser = () => { const doneEditingUser = useCallback(() => {
if (user) { if (user && data) {
const users = [ const users = [
...data.users.filter( ...data.users.filter(
(u: { username: string }) => u.username !== user.username (u: { username: string }) => u.username !== user.username
@@ -138,26 +144,31 @@ const ManageUsers = () => {
setUser(undefined); setUser(undefined);
setChanged(changed + 1); setChanged(changed + 1);
} }
}; }, [user, data, updateDataValue, changed]);
const closeGenerateToken = () => { const closeGenerateToken = useCallback(() => {
setGeneratingToken(undefined); setGeneratingToken(undefined);
}; }, []);
const generateToken = (username: string) => { const generateTokenForUser = useCallback((username: string) => {
setGeneratingToken(username); setGeneratingToken(username);
}; }, []);
const onSubmit = async () => { const onSubmit = useCallback(async () => {
await saveData(); await saveData();
await authenticatedContext.refresh(); await authenticatedContext.refresh();
setChanged(0); setChanged(0);
}; }, [saveData, authenticatedContext]);
const onCancelSubmit = async () => { const onCancelSubmit = useCallback(async () => {
await loadData(); await loadData();
setChanged(0); setChanged(0);
}; }, [loadData]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
interface UserType2 { interface UserType2 {
id: string; id: string;
@@ -167,10 +178,14 @@ const ManageUsers = () => {
} }
// add id to the type, needed for the table // add id to the type, needed for the table
const user_table = data.users.map((u) => ({ const user_table = useMemo(
() =>
data.users.map((u) => ({
...u, ...u,
id: u.username id: u.username
})) as UserType2[]; })) as UserType2[],
[data.users]
);
return ( return (
<> <>
@@ -196,15 +211,24 @@ const ManageUsers = () => {
<Cell stiff> <Cell stiff>
<IconButton <IconButton
size="small" size="small"
aria-label={LL.GENERATING_TOKEN()}
disabled={!authenticatedContext.me.admin} disabled={!authenticatedContext.me.admin}
onClick={() => generateToken(u.username)} onClick={() => generateTokenForUser(u.username)}
> >
<VpnKeyIcon /> <VpnKeyIcon />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => removeUser(u)}> <IconButton
size="small"
onClick={() => removeUser(u)}
aria-label={LL.REMOVE()}
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
<IconButton size="small" onClick={() => editUser(u)}> <IconButton
size="small"
onClick={() => editUser(u)}
aria-label={LL.EDIT()}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</Cell> </Cell>
@@ -286,4 +310,4 @@ const ManageUsers = () => {
); );
}; };
export default ManageUsers; export default memo(ManageUsers);

View File

@@ -1,3 +1,4 @@
import { memo, useMemo } from 'react';
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router'; import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
import { Tab } from '@mui/material'; import { Tab } from '@mui/material';
@@ -12,12 +13,21 @@ const Security = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle(LL.SECURITY(0)); useLayoutTitle(LL.SECURITY(0));
const matchedRoutes = matchRoutes( const location = useLocation();
const matchedRoutes = useMemo(
() =>
matchRoutes(
[ [
{ path: '/settings/security/settings', element: <ManageUsers />, dog: 'woof' }, {
path: '/settings/security/settings',
element: <ManageUsers />
},
{ path: '/settings/security/users', element: <SecuritySettings /> } { path: '/settings/security/users', element: <SecuritySettings /> }
], ],
useLocation() location
),
[location]
); );
const routerTab = matchedRoutes?.[0]?.route.path || false; const routerTab = matchedRoutes?.[0]?.route.path || false;
@@ -42,4 +52,4 @@ const Security = () => {
); );
}; };
export default Security; export default memo(Security);

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import WarningIcon from '@mui/icons-material/Warning'; import WarningIcon from '@mui/icons-material/Warning';
@@ -44,18 +44,14 @@ const SecuritySettings = () => {
const authenticatedContext = useContext(AuthenticatedContext); const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
); );
const content = () => { const validateAndSubmit = useCallback(async () => {
if (!data) { if (!data) return;
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
const validateAndSubmit = async () => {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data); await validate(SECURITY_SETTINGS_VALIDATOR, data);
@@ -64,7 +60,12 @@ const SecuritySettings = () => {
} catch (error) { } catch (error) {
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
}; }, [data, saveData, authenticatedContext]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
}
return ( return (
<> <>
@@ -115,4 +116,4 @@ const SecuritySettings = () => {
); );
}; };
export default SecuritySettings; export default memo(SecuritySettings);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
@@ -45,7 +45,14 @@ const User: FC<UserFormProps> = ({
}) => { }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const updateFormValue = updateValue(setUser); const updateFormValue = updateValue((updater) => {
setUser((prevState) => {
if (!prevState) return prevState;
return updater(
prevState as unknown as Record<string, unknown>
) as unknown as UserType;
});
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>(); const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user; const open = !!user;
@@ -55,7 +62,7 @@ const User: FC<UserFormProps> = ({
} }
}, [open]); }, [open]);
const validateAndDone = async () => { const validateAndDone = useCallback(async () => {
if (user) { if (user) {
try { try {
setFieldErrors(undefined); setFieldErrors(undefined);
@@ -65,7 +72,7 @@ const User: FC<UserFormProps> = ({
setFieldErrors(error as ValidateFieldsError); setFieldErrors(error as ValidateFieldsError);
} }
} }
}; }, [user, validator, onDoneEditing]);
return ( return (
<Dialog <Dialog
@@ -137,4 +144,4 @@ const User: FC<UserFormProps> = ({
); );
}; };
export default User; export default memo(User);

View File

@@ -34,19 +34,10 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
} }
}; };
const APStatus = () => { const getApStatusText = (
const { data, send: loadData, error } = useRequest(APApi.readAPStatus); status: APNetworkStatus,
LL: ReturnType<typeof useI18nContext>['LL']
useInterval(() => { ) => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle(LL.ACCESS_POINT(0));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) { switch (status) {
case APNetworkStatus.ACTIVE: case APNetworkStatus.ACTIVE:
return LL.ACTIVE(); return LL.ACTIVE();
@@ -57,14 +48,29 @@ const APStatus = () => {
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}; };
const APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle(LL.ACCESS_POINT(0));
useInterval(() => {
void loadData();
});
const content = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -72,19 +78,26 @@ const APStatus = () => {
<SettingsInputAntennaIcon /> <SettingsInputAntennaIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} /> <ListItemText
primary={LL.STATUS_OF('')}
secondary={getApStatusText(data.status, LL)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar>IP</Avatar> <Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} /> <ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar sx={{ bgcolor: 'primary.main' }}>
<DeviceHubIcon /> <DeviceHubIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
@@ -93,21 +106,22 @@ const APStatus = () => {
secondary={data.mac_address} secondary={data.mac_address}
/> />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar sx={{ bgcolor: 'primary.main' }}>
<ComputerIcon /> <ComputerIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} /> <ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
</SectionContent>
); );
};
return <SectionContent>{content()}</SectionContent>;
}; };
export default APStatus; export default APStatus;

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import { import {
Body, Body,
Cell, Cell,
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app'; import { readActivity } from '../../api/app';
import type { Stat } from '../main/types'; import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => { const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity); const { data, send: loadData, error } = useRequest(readActivity);
@@ -28,7 +36,9 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC()); useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({ const stats_theme = tableTheme(
useMemo(
() => ({
Table: ` Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px; --data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`, `,
@@ -64,29 +74,35 @@ const SystemActivity = () => {
text-align: center; text-align: center;
} }
` `
}); }),
[]
)
);
const showName = (id: number) => { const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] = const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES']; id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name](); return LL.STATUS_NAMES[name]();
}; },
[LL]
);
const showQuality = (stat: Stat) => { const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) { if (stat.q === 0 || stat.s + stat.f === 0) {
return; return;
} }
if (stat.q === 100) { if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
} }
if (stat.q >= 95) { if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
} else { } else {
return <div style={{ color: 'red' }}>{stat.q}%</div>; return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
} }
}; }, []);
const content = () => { const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
@@ -121,9 +137,9 @@ const SystemActivity = () => {
)} )}
</Table> </Table>
); );
}; }, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content()}</SectionContent>; return <SectionContent>{content}</SectionContent>;
}; };
export default SystemActivity; export default SystemActivity;

View File

@@ -1,3 +1,5 @@
import { ReactElement } from 'react';
import AppsIcon from '@mui/icons-material/Apps'; import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard'; import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices'; import DevicesIcon from '@mui/icons-material/Devices';
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
import BBQKeesIcon from './bbqkees.svg'; import BBQKeesIcon from './bbqkees.svg';
// Constants
const AVATAR_COLORS = {
DEFAULT: '#5f9a5f',
BBQKEES: '#003289'
} as const;
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
function formatNumber(num: number) { function formatNumber(num: number) {
return new Intl.NumberFormat().format(num); return new Intl.NumberFormat().format(num);
} }
function formatTemperature(temp?: number): string {
if (!temp) return '';
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
return `, T: ${temp} °${unit}`;
}
function formatFlashSpeed(speed: number): string {
return (speed / 1000000).toFixed(0) + ' MHz';
}
function formatCPUCores(cores: number): string {
return cores === 1 ? 'single-core)' : 'dual-core)';
}
// Reusable component for hardware status list items
interface HardwareListItemProps {
icon: ReactElement;
primary: string;
secondary: string;
avatarColor?: string;
customIcon?: ReactElement | undefined;
}
const HardwareListItem = ({
icon,
primary,
secondary,
avatarColor = AVATAR_COLORS.DEFAULT,
customIcon
}: HardwareListItemProps) => (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
{customIcon || icon}
</Avatar>
</ListItemAvatar>
<ListItemText primary={primary} secondary={secondary} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const HardwareStatus = () => { const HardwareStatus = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
void loadData(); void loadData();
}); });
const content = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
} }
return ( return (
<SectionContent>
<List> <List>
<ListItem> <HardwareListItem
<ListItemAvatar> icon={<TapAndPlayIcon />}
{data.model ? ( primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}> secondary={data.model || data.cpu_type}
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
customIcon={
data.model ? (
<img <img
alt="BBQKees" alt="BBQKees"
src={BBQKeesIcon} src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }} style={{ width: 16, verticalAlign: 'middle' }}
/> />
</Avatar> ) : undefined
) : ( }
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<TapAndPlayIcon />
</Avatar>
)}
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/> />
</ListItem> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<DevicesIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK" primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version} secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
/> />
</ListItem> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<DeveloperBoardIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU" primary="CPU"
secondary={ secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores === 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz' +
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
(data.temperature
? ', T: ' +
data.temperature +
' °' +
(data.temperature > 90 ? 'F' : 'C')
: '')
}
/> />
</ListItem> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<MemoryIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()} primary={LL.FREE_MEMORY()}
secondary={ secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/> />
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && ( {data.psram_size !== undefined && data.free_psram !== undefined && (
<> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<AppsIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()} primary={LL.PSRAM()}
secondary={ secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/> />
</ListItem>
</>
)} )}
<Divider variant="inset" component="li" /> <HardwareListItem
<ListItem> icon={<SdStorageIcon />}
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()} primary={LL.FLASH()}
secondary={ secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/> />
</ListItem> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<SdCardAlertIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()} primary={LL.APPSIZE()}
secondary={ secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/> />
</ListItem> <HardwareListItem
<Divider variant="inset" component="li" /> icon={<FolderIcon />}
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()} primary={LL.FILESYSTEM()}
secondary={ secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/> />
</ListItem>
<Divider variant="inset" component="li" />
</List> </List>
</SectionContent>
); );
};
return <SectionContent>{content()}</SectionContent>;
}; };
export default HardwareStatus; export default HardwareStatus;

View File

@@ -1,3 +1,5 @@
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion'; import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report'; import ReportIcon from '@mui/icons-material/Report';
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types'; import { MqttDisconnectReason } from 'types';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
// Disconnect reason lookup table - created once, reused across renders
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
[MqttDisconnectReason.USER_OK]: 'User disconnected',
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
'Unacceptable protocol version',
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
};
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
export const mqttStatusHighlight = ( export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType, { enabled, connected }: MqttStatusType,
theme: Theme theme: Theme
) => { ) => {
if (!enabled) { if (!enabled) return theme.palette.info.main;
return theme.palette.info.main; return connected ? theme.palette.success.main : theme.palette.error.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
}; };
export const mqttPublishHighlight = ( export const mqttPublishHighlight = (
@@ -41,68 +54,22 @@ export const mqttPublishHighlight = (
) => { ) => {
if (mqtt_fails === 0) return theme.palette.success.main; if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main; if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main; return theme.palette.error.main;
}; };
export const mqttQueueHighlight = ( export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
{ mqtt_queued }: MqttStatusType, mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
return theme.palette.warning.main; interface ConnectionStatusProps {
}; data: MqttStatusType;
theme: Theme;
const MqttStatus = () => { }
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
useInterval(() => {
void loadData();
});
// Memoized component to prevent unnecessary re-renders when parent updates
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
useLayoutTitle('MQTT');
const theme = useTheme(); return (
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + ' (' + connect_count + ')';
}
return LL.DISCONNECTED() + ' (' + connect_count + ')';
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const renderConnectionStatus = () => (
<> <>
{!data.connected && ( {!data.connected && (
<> <>
@@ -114,7 +81,7 @@ const MqttStatus = () => {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={LL.DISCONNECT_REASON()} primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)} secondary={getDisconnectReason(data.disconnect_reason)}
/> />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
@@ -147,8 +114,40 @@ const MqttStatus = () => {
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</> </>
); );
});
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle('MQTT');
useInterval(() => {
void loadData();
});
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
</SectionContent>
);
}
return ( return (
<SectionContent>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -156,15 +155,13 @@ const MqttStatus = () => {
<DeviceHubIcon /> <DeviceHubIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} /> <ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()} {data.enabled && <ConnectionStatus data={data} theme={theme} />}
</List> </List>
</SectionContent>
); );
};
return <SectionContent>{content()}</SectionContent>;
}; };
export default MqttStatus; export default MqttStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle'; import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -23,22 +25,11 @@ import { NTPSyncStatus } from 'types';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils'; import { formatDateTime } from 'utils';
const NTPStatus = () => { // Utility functions
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus); const isNtpEnabled = ({ status }: NTPStatusType) =>
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('NTP');
NTPApi.updateTime;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED; status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => { const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) { switch (status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -49,7 +40,17 @@ const NTPStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}; };
const NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('NTP');
const theme = useTheme(); const theme = useTheme();
@@ -66,13 +67,12 @@ const NTPStatus = () => {
} }
}; };
const content = () => { const content = useMemo(() => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
return ( return (
<>
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
@@ -121,11 +121,10 @@ const NTPStatus = () => {
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
</>
); );
}; }, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>; return <SectionContent>{content}</SectionContent>;
}; };
export default NTPStatus; export default NTPStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite'; import GiteIcon from '@mui/icons-material/Gite';
@@ -25,10 +27,17 @@ import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types'; import { NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils'; import { useInterval } from 'utils';
// Utility functions
const isConnected = ({ status }: NetworkStatusType) => const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED || status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED; status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => { const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) { switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
@@ -55,11 +64,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
return theme.palette.success.main; return theme.palette.success.main;
}; };
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => { const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) { if (!dns_ip_1) {
return 'none'; return 'none';
@@ -81,6 +85,33 @@ const IPs = (status: NetworkStatusType) => {
return status.local_ip + ', ' + status.local_ipv6; return status.local_ip + ', ' + status.local_ipv6;
}; };
const getNetworkStatusText = (
status: NetworkConnectionStatus,
reconnectCount: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const NetworkStatus = () => { const NetworkStatus = () => {
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus); const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
@@ -93,51 +124,30 @@ const NetworkStatus = () => {
const theme = useTheme(); const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => { const content = useMemo(() => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi) (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return (
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) { if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return ( return (
<List> <List>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}> <Avatar sx={{ bgcolor: statusColor }}>
{isWiFi(data) && <WifiIcon />} {isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />} {isEthernet(data) && <RouterIcon />}
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} /> <ListItemText primary="Status" secondary={statusText} />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}> <Avatar sx={{ bgcolor: statusColor }}>
<GiteIcon /> <GiteIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
@@ -148,13 +158,13 @@ const NetworkStatus = () => {
<> <>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}> <Avatar sx={{ bgcolor: qualityColor }}>
<SettingsInputAntennaIcon /> <SettingsInputAntennaIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="SSID (RSSI)" primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'} secondary={`${data.ssid} (${data.rssi} dBm)`}
/> />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
@@ -218,9 +228,9 @@ const NetworkStatus = () => {
)} )}
</List> </List>
); );
}; }, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>; return <SectionContent>{content}</SectionContent>;
}; };
export default NetworkStatus; export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -43,6 +43,28 @@ import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor'; import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
duration_sec: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const ms = duration_sec * 1000;
const days = Math.trunc(ms / 86400000);
const hours = Math.trunc(ms / 3600000) % 24;
const minutes = Math.trunc(ms / 60000) % 60;
const seconds = Math.trunc(ms / 1000) % 60;
const parts: string[] = [];
if (days) parts.push(LL.NUM_DAYS({ num: days }));
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
parts.push(LL.NUM_SECONDS({ num: seconds }));
return parts.join(' ');
};
const SystemStatus = () => { const SystemStatus = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -62,7 +84,6 @@ const SystemStatus = () => {
send: loadData, send: loadData,
error error
} = useRequest(readSystemStatus, { } = useRequest(readSystemStatus, {
initialData: [],
async middleware(_, next) { async middleware(_, next) {
if (!restarting) { if (!restarting) {
await next(); await next();
@@ -76,51 +97,25 @@ const SystemStatus = () => {
const theme = useTheme(); const theme = useTheme();
const formatDurationSec = (duration_sec: number) => { // Memoize derived status values to avoid recalculation on every render
const days = Math.trunc((duration_sec * 1000) / 86400000); const busStatus = useMemo(() => {
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24; if (!data) return 'EMS state unknown';
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const busStatus = () => {
if (data) {
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return ( return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES(); return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE: case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED(); return 'EMS ' + LL.DISCONNECTED();
} default:
}
return 'EMS state unknown'; return 'EMS state unknown';
}; }
}, [data?.bus_status, data?.bus_uptime, LL]);
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const busStatusHighlight = () => {
switch (data.bus_status) { switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main; return theme.palette.warning.main;
@@ -131,27 +126,28 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}; }, [data?.bus_status, theme.palette]);
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const ntpStatus = () => {
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED(); return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE: case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0); return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE: case NTPSyncStatus.NTP_ACTIVE:
return ( return data.ntp_time
LL.ACTIVE() + ? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
(data.ntp_time !== undefined : LL.ACTIVE();
? ' (' + formatDateTime(data.ntp_time) + ')'
: '')
);
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}; }, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
const ntpStatusHighlight = () => {
switch (data.ntp_status) { switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED: case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main; return theme.palette.info.main;
@@ -162,9 +158,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.error.main; return theme.palette.error.main;
} }
}; }, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const networkStatusHighlight = () => {
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -179,9 +177,11 @@ const SystemStatus = () => {
default: default:
return theme.palette.warning.main; return theme.palette.warning.main;
} }
}; }, [data?.network_status, theme.palette]);
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const networkStatus = () => {
switch (data.network_status) { switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1); return LL.INACTIVE(1);
@@ -190,24 +190,27 @@ const SystemStatus = () => {
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL: case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available'; return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED: case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)'; return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED: case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)'; return `${LL.CONNECTED(0)} (Ethernet)`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED: case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0); return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST: case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST(); return `${LL.CONNECTED(1)} ${LL.LOST()}`;
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED(); return LL.DISCONNECTED();
default: default:
return LL.UNKNOWN(); return LL.UNKNOWN();
} }
}; }, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) => const activeHighlight = useCallback(
value ? theme.palette.success.main : theme.palette.info.main; (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = async () => { const doRestart = useCallback(async () => {
setConfirmRestart(false); setConfirmRestart(false);
setRestarting(true); setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -215,13 +218,18 @@ const SystemStatus = () => {
toast.error(error.message); toast.error(error.message);
} }
); );
}; }, [sendAPI]);
const renderRestartDialog = () => ( const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog <Dialog
sx={dialogStyle} sx={dialogStyle}
open={confirmRestart} open={confirmRestart}
onClose={() => setConfirmRestart(false)} onClose={handleCloseRestartDialog}
> >
<DialogTitle>{LL.RESTART()}</DialogTitle> <DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent> <DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
@@ -229,7 +237,7 @@ const SystemStatus = () => {
<Button <Button
startIcon={<CancelIcon />} startIcon={<CancelIcon />}
variant="outlined" variant="outlined"
onClick={() => setConfirmRestart(false)} onClick={handleCloseRestartDialog}
color="secondary" color="secondary"
> >
{LL.CANCEL()} {LL.CANCEL()}
@@ -244,9 +252,49 @@ const SystemStatus = () => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
); );
const content = () => { // Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) { if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />; return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
} }
@@ -258,7 +306,7 @@ const SystemStatus = () => {
icon={BuildIcon} icon={BuildIcon}
bgcolor="#72caf9" bgcolor="#72caf9"
label="EMS-ESP Firmware" label="EMS-ESP Firmware"
text={'v' + data.emsesp_version} text={firmwareVersion}
to="version" to="version"
/> />
@@ -268,16 +316,13 @@ const SystemStatus = () => {
<TimerIcon /> <TimerIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText primary={LL.UPTIME()} secondary={uptimeText} />
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
/>
{me.admin && ( {me.admin && (
<Button <Button
startIcon={<PowerSettingsNewIcon />} startIcon={<PowerSettingsNewIcon />}
variant="outlined" variant="outlined"
color="error" color="error"
onClick={() => setConfirmRestart(true)} onClick={handleRestartClick}
> >
{LL.RESTART()} {LL.RESTART()}
</Button> </Button>
@@ -289,29 +334,25 @@ const SystemStatus = () => {
icon={MemoryIcon} icon={MemoryIcon}
bgcolor="#68374d" bgcolor="#68374d"
label={LL.HARDWARE()} label={LL.HARDWARE()}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()} text={freeMemoryText}
to="/status/hardwarestatus" to="/status/hardwarestatus"
/> />
<ListMenuItem <ListMenuItem
disabled={!me.admin} disabled={!me.admin}
icon={DirectionsBusIcon} icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()} bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()} label={LL.DATA_TRAFFIC()}
text={busStatus()} text={busStatus}
to="/status/activity" to="/status/activity"
/> />
<ListMenuItem <ListMenuItem
disabled={!me.admin} disabled={!me.admin}
icon={ icon={networkIcon}
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED bgcolor={networkStatusHighlight}
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
label={LL.NETWORK(1)} label={LL.NETWORK(1)}
text={networkStatus()} text={networkStatus}
to="/status/network" to="/status/network"
/> />
@@ -320,16 +361,16 @@ const SystemStatus = () => {
icon={DeviceHubIcon} icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)} bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT" label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)} text={mqttStatusText}
to="/status/mqtt" to="/status/mqtt"
/> />
<ListMenuItem <ListMenuItem
disabled={!me.admin} disabled={!me.admin}
icon={AccessTimeIcon} icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()} bgcolor={ntpStatusHighlight}
label="NTP" label="NTP"
text={ntpStatus()} text={ntpStatus}
to="/status/ntp" to="/status/ntp"
/> />
@@ -338,7 +379,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon} icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)} bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)} label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)} text={apStatusText}
to="/status/ap" to="/status/ap"
/> />
@@ -352,14 +393,33 @@ const SystemStatus = () => {
/> />
</List> </List>
{renderRestartDialog()} {renderRestartDialog}
</> </>
); );
}; }, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return ( return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
}; };
export default SystemStatus; export default SystemStatus;

View File

@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState
} from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp'; import DownloadIcon from '@mui/icons-material/GetApp';
@@ -31,6 +38,8 @@ import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types'; import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils'; import { updateValueDirty, useRest } from 'utils';
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
const TextColors: Record<LogLevel, string> = { const TextColors: Record<LogLevel, string> = {
[LogLevel.ERROR]: '#ff0000', // red [LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ff0000', // red [LogLevel.WARNING]: '#ff0000', // red
@@ -47,11 +56,6 @@ const LogEntryLine = styled('span')(
}) })
); );
const topOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () =>
document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => { const levelLabel = (level: LogLevel) => {
switch (level) { switch (level) {
case LogLevel.ERROR: case LogLevel.ERROR:
@@ -71,6 +75,39 @@ const levelLabel = (level: LogLevel) => {
} }
}; };
const paddedLevelLabel = (level: LogLevel, compact: boolean) => {
const label = levelLabel(level);
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string, compact: boolean) => {
const label = '[' + name + ']';
return compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number, compact: boolean) => {
const label = id + ':';
return compact ? label : label.padEnd(7, '\xa0');
};
// Memoized log entry component to prevent unnecessary re-renders
const LogEntryItem = memo(
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
return (
<div style={{ font: '13px monospace', whiteSpace: 'nowrap' }}>
<span>{entry.t}</span>
<span>{paddedLevelLabel(entry.l, compact)}&nbsp;</span>
<span>{paddedIDLabel(entry.i, compact)} </span>
<span>{paddedNameLabel(entry.n, compact)} </span>
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
</div>
);
},
(prevProps, nextProps) =>
prevProps.entry.i === nextProps.entry.i &&
prevProps.compact === nextProps.compact
);
const SystemLog = () => { const SystemLog = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
@@ -102,54 +139,89 @@ const SystemLog = () => {
const [readOpen, setReadOpen] = useState(false); const [readOpen, setReadOpen] = useState(false);
const [logEntries, setLogEntries] = useState<LogEntry[]>([]); const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [autoscroll, setAutoscroll] = useState(true); const [autoscroll, setAutoscroll] = useState(true);
const [lastId, setLastId] = useState<number>(-1); const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 });
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/; const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty( const updateFormValue = updateValueDirty(
origData, origData as unknown as Record<string, unknown>,
dirtyFlags, dirtyFlags,
setDirtyFlags, setDirtyFlags,
updateDataValue as (value: unknown) => void updateDataValue as (value: unknown) => void
); );
// Calculate box position after layout
useLayoutEffect(() => {
const logWindow = document.getElementById('log-window');
if (!logWindow) {
return;
}
const updatePosition = () => {
const windowElement = document.getElementById('log-window');
if (!windowElement) {
return;
}
const rect = windowElement.getBoundingClientRect();
setBoxPosition({ top: rect.bottom, left: rect.left });
};
updatePosition();
// Debounce resize events with requestAnimationFrame
let rafId: number;
const handleResize = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(updatePosition);
};
// Update position on window resize
window.addEventListener('resize', handleResize);
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(logWindow);
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver.disconnect();
cancelAnimationFrame(rafId);
};
}, [data]); // Recalculate when data changes (in case layout shifts)
// Memoize message handler to avoid recreating on every render
const handleLogMessage = useCallback((message: { data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
setLogEntries((log) => {
// Skip if this is a duplicate entry (check last entry id)
if (log.length > 0) {
const lastEntry = log[log.length - 1];
if (lastEntry && logentry.i <= lastEntry.i) {
return log;
}
}
const newLog = [...log, logentry];
// Limit log entries to prevent memory issues - only slice when necessary
if (newLog.length > MAX_LOG_ENTRIES) {
return newLog.slice(-MAX_LOG_ENTRIES);
}
return newLog;
});
}, []);
useSSE(fetchLogES, { useSSE(fetchLogES, {
immediate: true, immediate: true,
interceptByGlobalResponded: false interceptByGlobalResponded: false
}) })
.onMessage((message: { data: string }) => { .onMessage(handleLogMessage)
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (lastId < logentry.i) {
setLogEntries((log) => [...log, logentry]);
setLastId(logentry.i);
}
})
.onError(() => { .onError(() => {
toast.error('No connection to Log service'); toast.error('No connection to Log service');
}); });
const paddedLevelLabel = (level: LogLevel) => { const onDownload = useCallback(() => {
const label = levelLabel(level); const result = logEntries
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0'); .map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
}; .join('\n');
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const onDownload = () => {
let result = '';
for (const i of logEntries) {
result +=
i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a'); const a = document.createElement('a');
a.setAttribute( a.setAttribute(
'href', 'href',
@@ -159,24 +231,28 @@ const SystemLog = () => {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
}; }, [logEntries]);
const saveSettings = async () => { const saveSettings = useCallback(async () => {
await saveData(); await saveData();
}; }, [saveData]);
// handle scrolling // handle scrolling - optimized to only scroll when needed
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const logWindowRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (logEntries.length && autoscroll) { if (logEntries.length && autoscroll) {
ref.current?.scrollIntoView({ const container = logWindowRef.current;
behavior: 'smooth', if (container) {
block: 'end' requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
}); });
} }
}, [logEntries.length]); }
}, [logEntries.length, autoscroll]);
const sendReadCommand = () => { const sendReadCommand = useCallback(() => {
if (readValue === '') { if (readValue === '') {
setReadOpen(!readOpen); setReadOpen(!readOpen);
return; return;
@@ -187,7 +263,7 @@ const SystemLog = () => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
} }
}; }, [readValue, readOpen, send]);
const content = () => { const content = () => {
if (!data) { if (!data) {
@@ -279,6 +355,7 @@ const SystemLog = () => {
> >
<IconButton <IconButton
disableRipple disableRipple
aria-label={LL.CANCEL()}
onClick={() => { onClick={() => {
setReadOpen(false); setReadOpen(false);
setReadValue(''); setReadValue('');
@@ -304,7 +381,7 @@ const SystemLog = () => {
) : ( ) : (
<> <>
{data.developer_mode && ( {data.developer_mode && (
<IconButton onClick={sendReadCommand}> <IconButton onClick={sendReadCommand} aria-label={LL.EXECUTE()}>
<PlayArrowIcon color="primary" /> <PlayArrowIcon color="primary" />
</IconButton> </IconButton>
)} )}
@@ -326,27 +403,20 @@ const SystemLog = () => {
</Grid> </Grid>
<Box <Box
ref={logWindowRef}
sx={{ sx={{
backgroundColor: 'black', backgroundColor: 'black',
overflowY: 'scroll', overflowY: 'scroll',
position: 'absolute', position: 'absolute',
right: 18, right: 18,
bottom: 18, bottom: 18,
left: () => leftOffset(), left: boxPosition.left,
top: () => topOffset(), top: boxPosition.top,
p: 1 p: 1
}} }}
> >
{logEntries.map((e) => ( {logEntries.map((e) => (
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}> <LogEntryItem key={e.i} entry={e} compact={data.compact} />
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
))} ))}
<div ref={ref} /> <div ref={ref} />

View File

@@ -1,12 +1,11 @@
import { useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material'; import { Box, Button, Typography } from '@mui/material';
import { callAction } from 'api/app'; import { callAction } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; import { useRequest } from 'alova/client';
import MessageBox from 'components/MessageBox'; import MessageBox from 'components/MessageBox';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
@@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
const SystemMonitor = () => { const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
let count = 0;
const { send: setSystemStatus } = useRequest( const { send: setSystemStatus } = useRequest(
(status: string) => callAction({ action: 'systemStatus', param: status }), (status: string) => callAction({ action: 'systemStatus', param: status }),
{ {
@@ -32,10 +29,12 @@ const SystemMonitor = () => {
const { data, send } = useRequest(readSystemStatus, { const { data, send } = useRequest(readSystemStatus, {
force: true, force: true,
async middleware(_, next) { async middleware(_, next) {
if (count++ >= 1) { // Skip first request to allow AsyncWS to send its response
// skip first request (1 second) to allow AsyncWS to send its response if (!hasInitialized.current) {
await next(); hasInitialized.current = true;
return; // Don't await next() on first call
} }
await next();
} }
}) })
.onSuccess((event) => { .onSuccess((event) => {
@@ -58,33 +57,82 @@ const SystemMonitor = () => {
void send(); void send();
}, 1000); // check every 1 second }, 1000); // check every 1 second
const onCancel = async () => { const { statusMessage, isUploading, progressValue } = useMemo(() => {
setErrorMessage(undefined); const status = data?.status;
await setSystemStatus(
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string let message = '';
); if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) {
document.location.href = '/'; message = LL.WAIT_FIRMWARE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART) {
message = LL.APPLICATION_RESTARTING();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_NORMAL) {
message = LL.RESTARTING_PRE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD) {
message = 'Upload Failed';
} else {
message = LL.RESTARTING_POST();
}
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
}; };
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
}, [setSystemStatus]);
return ( return (
<Dialog fullWidth={true} sx={dialogStyle} open={true}> <Box
<DialogContent dividers> sx={{
<Box m={0} py={0} display="flex" alignItems="center" flexDirection="column"> position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(8px)'
}}
>
<Box
sx={{
width: '30%',
minWidth: '300px',
maxWidth: '500px',
backgroundColor: '#393939',
border: 3,
borderColor: '#565656',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
p: 3
}}
>
<Box display="flex" alignItems="center" flexDirection="column">
<img
src="/app/icon.png"
alt="EMS-ESP"
style={{ width: '40px', height: '40px', marginBottom: '16px' }}
/>
<Typography <Typography
color="secondary" color="secondary"
variant="h6" variant="h6"
fontWeight={400} fontWeight={400}
textAlign="center" textAlign="center"
> >
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING {statusMessage}
? LL.WAIT_FIRMWARE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST()}
</Typography> </Typography>
{errorMessage ? ( {errorMessage ? (
@@ -105,20 +153,16 @@ const SystemMonitor = () => {
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center"> <Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
{LL.PLEASE_WAIT()}&hellip; {LL.PLEASE_WAIT()}&hellip;
</Typography> </Typography>
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && ( {isUploading && (
<Box width="100%" pl={2} pr={2} py={2}> <Box width="100%" pl={2} pr={2} py={2}>
<LinearProgressWithLabel <LinearProgressWithLabel value={progressValue} />
value={Math.round(
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
)}
/>
</Box> </Box>
)} )}
</> </>
)} )}
</Box> </Box>
</DialogContent> </Box>
</Dialog> </Box>
); );
}; };

View File

@@ -567,7 +567,10 @@ const Version = () => {
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
<Typography> <Typography>
{latestVersion?.name} {latestVersion?.name}
<IconButton onClick={() => setShowVersionInfo(1)}> <IconButton
onClick={() => setShowVersionInfo(1)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
{showButtons(false)} {showButtons(false)}
@@ -580,7 +583,10 @@ const Version = () => {
<Grid size={{ xs: 8, md: 10 }}> <Grid size={{ xs: 8, md: 10 }}>
<Typography> <Typography>
{latestDevVersion?.name} {latestDevVersion?.name}
<IconButton onClick={() => setShowVersionInfo(2)}> <IconButton
onClick={() => setShowVersionInfo(2)}
aria-label={LL.FIRMWARE_VERSION_INFO()}
>
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} /> <InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
</IconButton> </IconButton>
{showButtons(true)} {showButtons(true)}

View File

@@ -19,6 +19,4 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
</Box> </Box>
)); ));
ButtonRow.displayName = 'ButtonRow';
export default ButtonRow; export default ButtonRow;

View File

@@ -1,11 +1,11 @@
import type { FC } from 'react'; import { type FC, memo, useMemo } from 'react';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import { Box, Typography, useTheme } from '@mui/material'; import { Box, Typography, useTheme } from '@mui/material';
import type { BoxProps, SvgIconProps, Theme } from '@mui/material'; import type { BoxProps, SvgIconProps } from '@mui/material';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error'; type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
@@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps {
message?: string; message?: string;
} }
const LEVEL_ICONS: { const LEVEL_ICONS: Record<MessageBoxLevel, React.ComponentType<SvgIconProps>> = {
[type in MessageBoxLevel]: React.ComponentType<SvgIconProps>;
} = {
success: CheckCircleOutlineOutlinedIcon, success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon, info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon, warning: ReportProblemOutlinedIcon,
error: ErrorIcon error: ErrorIcon
}; };
const LEVEL_BACKGROUNDS: { const LEVEL_PALETTE_PATHS: Record<MessageBoxLevel, string> = {
[type in MessageBoxLevel]: (theme: Theme) => string; success: 'success.dark',
} = { info: 'info.main',
success: (theme: Theme) => theme.palette.success.dark, warning: 'warning.dark',
info: (theme: Theme) => theme.palette.info.main, error: 'error.dark'
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
}; };
const MessageBox: FC<MessageBoxProps> = ({ const MessageBox: FC<MessageBoxProps> = ({
@@ -40,25 +36,38 @@ const MessageBox: FC<MessageBoxProps> = ({
...rest ...rest
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { Icon, backgroundColor } = useMemo(() => {
const Icon = LEVEL_ICONS[level]; const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme); const palettePath = LEVEL_PALETTE_PATHS[level];
const color = 'white'; const [key, shade] = palettePath.split('.') as [
keyof typeof theme.palette,
string
];
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
const backgroundColor = paletteKey[shade];
return { Icon, backgroundColor };
}, [level, theme]);
return ( return (
<Box <Box
p={2} p={2}
display="flex" display="flex"
alignItems="center" alignItems="center"
borderRadius={1} borderRadius={1}
sx={{ backgroundColor, color, ...sx }} sx={{ backgroundColor, color: 'white', ...sx }}
{...rest} {...rest}
> >
<Icon /> <Icon />
{(message || children) && (
<Typography sx={{ ml: 2 }} variant="body1"> <Typography sx={{ ml: 2 }} variant="body1">
{message ?? ''} {message}
</Typography>
{children} {children}
</Typography>
)}
</Box> </Box>
); );
}; };
export default MessageBox; export default memo(MessageBox);

View File

@@ -1,6 +1,8 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Paper } from '@mui/material'; import { Paper } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import type { RequiredChildrenProps } from 'utils'; import type { RequiredChildrenProps } from 'utils';
@@ -8,16 +10,19 @@ interface SectionContentProps extends RequiredChildrenProps {
id?: string; id?: string;
} }
const SectionContent: FC<SectionContentProps> = (props) => { // Extract styles to avoid recreation on every render
const { children, id } = props; const paperStyles: SxProps<Theme> = {
return ( p: 1.5,
<Paper m: 1.5,
id={id} borderRadius: 3,
sx={{ p: 1.5, m: 1.5, borderRadius: 3, border: '1px solid rgb(65, 65, 65)' }} border: '1px solid rgb(65, 65, 65)'
>
{children}
</Paper>
);
}; };
export default SectionContent; const SectionContent: FC<SectionContentProps> = ({ children, id }) => (
<Paper id={id} sx={paperStyles}>
{children}
</Paper>
);
// Memoize to prevent unnecessary re-renders
export default memo(SectionContent);

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormControlLabel } from '@mui/material'; import { FormControlLabel } from '@mui/material';
@@ -9,4 +10,4 @@ const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
</div> </div>
); );
export default BlockFormControlLabel; export default memo(BlockFormControlLabel);

View File

@@ -1,4 +1,6 @@
import { type ChangeEventHandler, useContext } from 'react'; import { memo, useCallback, useContext, useMemo } from 'react';
import type { ChangeEventHandler } from 'react';
import type { CSSProperties } from 'react';
import { MenuItem, TextField } from '@mui/material'; import { MenuItem, TextField } from '@mui/material';
@@ -17,73 +19,68 @@ import { I18nContext } from 'i18n/i18n-react';
import type { Locales } from 'i18n/i18n-types'; import type { Locales } from 'i18n/i18n-types';
import { loadLocaleAsync } from 'i18n/i18n-util.async'; import { loadLocaleAsync } from 'i18n/i18n-util.async';
const LanguageSelector = () => { // Extract style to constant to prevent recreation
const { setLocale, locale } = useContext(I18nContext); const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({ // Define language options outside component to prevent recreation
target interface LanguageOption {
}) => { key: Locales;
flag: string;
label: string;
}
const LANGUAGE_OPTIONS: LanguageOption[] = [
{ key: 'cz', flag: CZflag, label: 'CZ' },
{ key: 'de', flag: DEflag, label: 'DE' },
{ key: 'en', flag: GBflag, label: 'EN' },
{ key: 'fr', flag: FRflag, label: 'FR' },
{ key: 'it', flag: ITflag, label: 'IT' },
{ key: 'nl', flag: NLflag, label: 'NL' },
{ key: 'no', flag: NOflag, label: 'NO' },
{ key: 'pl', flag: PLflag, label: 'PL' },
{ key: 'sk', flag: SKflag, label: 'SK' },
{ key: 'sv', flag: SVflag, label: 'SV' },
{ key: 'tr', flag: TRflag, label: 'TR' }
];
const LanguageSelector = () => {
const { setLocale, locale, LL } = useContext(I18nContext);
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
async ({ target }) => {
const loc = target.value as Locales; const loc = target.value as Locales;
localStorage.setItem('lang', loc); localStorage.setItem('lang', loc);
await loadLocaleAsync(loc); await loadLocaleAsync(loc);
setLocale(loc); setLocale(loc);
}; },
[setLocale]
);
// Memoize menu items to prevent recreation on every render
const menuItems = useMemo(
() =>
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
<MenuItem key={key} value={key}>
<img src={flag} style={flagStyle} alt={label} />
&nbsp;{label}
</MenuItem>
)),
[]
);
return ( return (
<TextField <TextField
name="locale" name="locale"
variant="outlined" variant="outlined"
aria-label={LL.LANGUAGE()}
value={locale} value={locale}
onChange={onLocaleSelected} onChange={onLocaleSelected}
size="small" size="small"
select select
> >
<MenuItem key="cz" value="cz"> {menuItems}
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;CZ
</MenuItem>
<MenuItem key="de" value="de">
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;DE
</MenuItem>
<MenuItem key="en" value="en">
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;EN
</MenuItem>
<MenuItem key="fr" value="fr">
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;FR
</MenuItem>
<MenuItem key="it" value="it">
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;IT
</MenuItem>
<MenuItem key="nl" value="nl">
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NL
</MenuItem>
<MenuItem key="no" value="no">
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;NO
</MenuItem>
<MenuItem key="pl" value="pl">
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;PL
</MenuItem>
<MenuItem key="sk" value="sk">
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SK
</MenuItem>
<MenuItem key="sv" value="sv">
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;SV
</MenuItem>
<MenuItem key="tr" value="tr">
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
&nbsp;TR
</MenuItem>
</TextField> </TextField>
); );
}; };
export default LanguageSelector; export default memo(LanguageSelector);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { memo, useCallback, useState } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
@@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => { const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false); const [showPassword, setShowPassword] = useState<boolean>(false);
const togglePasswordVisibility = useCallback(() => {
setShowPassword((prev) => !prev);
}, []);
return ( return (
<ValidatedTextField <ValidatedTextField
{...props} {...props}
@@ -21,7 +25,11 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
input: { input: {
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end"> <IconButton
onClick={togglePasswordVisibility}
edge="end"
aria-label="Password visibility"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />} {showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
@@ -32,4 +40,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
); );
}; };
export default ValidatedPasswordField; export default memo(ValidatedPasswordField);

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormHelperText, TextField } from '@mui/material'; import { FormHelperText, TextField } from '@mui/material';
@@ -20,7 +21,7 @@ const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
return ( return (
<> <>
<TextField error={!!errors} {...rest} /> <TextField error={!!errors} {...rest} aria-label="Error" />
{errors?.map((e) => ( {errors?.map((e) => (
<FormHelperText key={e.message}>{e.message}</FormHelperText> <FormHelperText key={e.message}>{e.message}</FormHelperText>
))} ))}
@@ -28,4 +29,4 @@ const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
); );
}; };
export default ValidatedTextField; export default memo(ValidatedTextField);

View File

@@ -13,7 +13,7 @@ import { LayoutContext } from './context';
export const DRAWER_WIDTH = 210; export const DRAWER_WIDTH = 210;
const Layout: FC<RequiredChildrenProps> = memo(({ children }) => { const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME); const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -41,6 +41,8 @@ const Layout: FC<RequiredChildrenProps> = memo(({ children }) => {
</Box> </Box>
</LayoutContext.Provider> </LayoutContext.Provider>
); );
}); };
const Layout = memo(LayoutComponent);
export default Layout; export default Layout;

View File

@@ -1,8 +1,10 @@
import { memo, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import { AppBar, IconButton, Toolbar, Typography } from '@mui/material'; import { AppBar, IconButton, Toolbar, Typography } from '@mui/material';
import type { SxProps, Theme } from '@mui/material/styles';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
@@ -13,30 +15,47 @@ interface LayoutAppBarProps {
onToggleDrawer: () => void; onToggleDrawer: () => void;
} }
const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => { // Extract static styles
const { LL } = useI18nContext(); const appBarStyles: SxProps<Theme> = {
const navigate = useNavigate();
const pathnames = useLocation()
.pathname.split('/')
.filter((x) => x);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` }, width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` }, ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none', boxShadow: 'none',
backgroundColor: '#2e586a' backgroundColor: '#2e586a'
}} };
>
const menuButtonStyles: SxProps<Theme> = {
mr: 2,
display: { md: 'none' }
};
const backButtonStyles: SxProps<Theme> = {
mr: 1,
fontSize: 20,
verticalAlign: 'middle'
};
const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
const { LL } = useI18nContext();
const navigate = useNavigate();
const location = useLocation();
const pathnames = useMemo(
() => location.pathname.split('/').filter((x) => x),
[location.pathname]
);
const handleBackClick = useCallback(() => {
void navigate('/' + pathnames[0]);
}, [navigate, pathnames]);
return (
<AppBar position="fixed" sx={appBarStyles}>
<Toolbar> <Toolbar>
<IconButton <IconButton
color="inherit" color="inherit"
edge="start" edge="start"
onClick={onToggleDrawer} onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }} sx={menuButtonStyles}
> >
<MenuIcon /> <MenuIcon />
</IconButton> </IconButton>
@@ -44,10 +63,10 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
{pathnames.length > 1 && ( {pathnames.length > 1 && (
<> <>
<IconButton <IconButton
sx={{ mr: 1, fontSize: 20, verticalAlign: 'middle' }} sx={backButtonStyles}
color="primary" color="primary"
edge="start" edge="start"
onClick={() => navigate('/' + pathnames[0])} onClick={handleBackClick}
> >
<ArrowBackIcon /> <ArrowBackIcon />
</IconButton> </IconButton>
@@ -70,4 +89,6 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => {
); );
}; };
const LayoutAppBar = memo(LayoutAppBarComponent);
export default LayoutAppBar; export default LayoutAppBar;

View File

@@ -1,3 +1,5 @@
import { memo, useMemo } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { PROJECT_NAME } from 'env'; import { PROJECT_NAME } from 'env';
@@ -21,8 +23,10 @@ interface LayoutDrawerProps {
onClose: () => void; onClose: () => void;
} }
const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => { const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
const drawer = ( // Memoize drawer content to prevent unnecessary re-renders
const drawer = useMemo(
() => (
<> <>
<Toolbar disableGutters> <Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}> <Box display="flex" alignItems="center" px={2}>
@@ -34,6 +38,8 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
<Divider /> <Divider />
<LayoutMenu /> <LayoutMenu />
</> </>
),
[]
); );
return ( return (
@@ -66,4 +72,6 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
); );
}; };
export default LayoutDrawerProps; const LayoutDrawer = memo(LayoutDrawerComponent);
export default LayoutDrawer;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react'; import { memo, useCallback, useContext, useMemo, useState } from 'react';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
@@ -30,24 +30,31 @@ import LayoutMenuItem from 'components/layout/LayoutMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenu = () => { const LayoutMenuComponent = () => {
const { me, signOut } = useContext(AuthenticatedContext); const { me, signOut } = useContext(AuthenticatedContext);
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null); const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
const [menuOpen, setMenuOpen] = useState(true); const [menuOpen, setMenuOpen] = useState(true);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => { const open = useMemo(() => Boolean(anchorEl), [anchorEl]);
setAnchorEl(event.currentTarget); const id = useMemo(() => (anchorEl ? 'app-menu-popover' : undefined), [anchorEl]);
};
const handleClose = () => { const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
}, []);
const handleClose = useCallback(() => {
setAnchorEl(null); setAnchorEl(null);
}; }, []);
const handleSignOut = useCallback(() => {
signOut(true);
}, [signOut]);
const handleMenuToggle = useCallback(() => {
setMenuOpen((prev) => !prev);
}, []);
return ( return (
<> <>
@@ -64,10 +71,8 @@ const LayoutMenu = () => {
> >
<ListItemButton <ListItemButton
alignItems="flex-start" alignItems="flex-start"
onClick={() => setMenuOpen(!menuOpen)} onClick={handleMenuToggle}
sx={{ sx={{
pt: 2.5,
pb: menuOpen ? 0 : 2.5,
'&:hover, &:focus': { '& svg': { opacity: 1 } } '&:hover, &:focus': { '& svg': { opacity: 1 } }
}} }}
> >
@@ -173,7 +178,7 @@ const LayoutMenu = () => {
variant="outlined" variant="outlined"
fullWidth fullWidth
color="primary" color="primary"
onClick={() => signOut(true)} onClick={handleSignOut}
> >
{LL.SIGN_OUT()} {LL.SIGN_OUT()}
</Button> </Button>
@@ -196,4 +201,6 @@ const LayoutMenu = () => {
); );
}; };
const LayoutMenu = memo(LayoutMenuComponent);
export default LayoutMenu; export default LayoutMenu;

View File

@@ -1,7 +1,8 @@
import { memo, useMemo } from 'react';
import { Link, useLocation } from 'react-router'; import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material'; import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps } from '@mui/material'; import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -12,7 +13,7 @@ interface LayoutMenuItemProps {
disabled?: boolean; disabled?: boolean;
} }
const LayoutMenuItem = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
@@ -20,15 +21,11 @@ const LayoutMenuItem = ({
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const selected = routeMatches(to, pathname); const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
return ( // Memoize dynamic styles based on selected state
<ListItemButton const buttonStyles: SxProps<Theme> = useMemo(
component={Link} () => ({
to={to}
disabled={disabled || false}
selected={selected}
sx={{
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.02)' : 'scale(1)', transform: selected ? 'scale(1.02)' : 'scale(1)',
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
@@ -49,30 +46,45 @@ const LayoutMenuItem = ({
borderRadius: '0 2px 2px 0', borderRadius: '0 2px 2px 0',
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
} }
}} }),
> [selected]
<ListItemIcon );
sx={{
const iconStyles: SxProps<Theme> = useMemo(
() => ({
color: selected ? '#90caf9' : '#9e9e9e', color: selected ? '#90caf9' : '#9e9e9e',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
transform: selected ? 'scale(1.1)' : 'scale(1)', transform: selected ? 'scale(1.1)' : 'scale(1)',
transitionProperty: 'color, transform' transitionProperty: 'color, transform'
}} }),
> [selected]
<Icon /> );
</ListItemIcon>
<ListItemText const textStyles: SxProps<Theme> = useMemo(
sx={{ () => ({
color: selected ? '#90caf9' : '#f5f5f5', color: selected ? '#90caf9' : '#f5f5f5',
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
// fontWeight: selected ? '600' : '400',
transitionProperty: 'color, font-weight' transitionProperty: 'color, font-weight'
}} }),
[selected]
);
return (
<ListItemButton
component={Link}
to={to}
disabled={disabled || false}
selected={selected}
sx={buttonStyles}
> >
{label} <ListItemIcon sx={iconStyles}>
</ListItemText> <Icon />
</ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText>
</ListItemButton> </ListItemButton>
); );
}; };
const LayoutMenuItem = memo(LayoutMenuItemComponent);
export default LayoutMenuItem; export default LayoutMenuItem;

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import type { CSSProperties } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
@@ -20,8 +22,15 @@ interface ListMenuItemProps {
disabled?: boolean; disabled?: boolean;
} }
function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) { // Extract styles to prevent recreation
return ( const iconStyles: CSSProperties = {
justifyContent: 'right',
color: 'lightblue',
verticalAlign: 'middle'
};
const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
@@ -30,8 +39,8 @@ function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) {
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={label} secondary={text} /> <ListItemText primary={label} secondary={text} />
</> </>
); )
} );
const LayoutMenuItem = ({ const LayoutMenuItem = ({
icon, icon,
@@ -46,13 +55,7 @@ const LayoutMenuItem = ({
<ListItem <ListItem
disablePadding disablePadding
secondaryAction={ secondaryAction={
<ListItemIcon <ListItemIcon style={iconStyles}>
style={{
justifyContent: 'right',
color: 'lightblue',
verticalAlign: 'middle'
}}
>
<NavigateNextIcon /> <NavigateNextIcon />
</ListItemIcon> </ListItemIcon>
} }
@@ -79,4 +82,4 @@ const LayoutMenuItem = ({
</> </>
); );
export default LayoutMenuItem; export default memo(LayoutMenuItem);

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, Button, CircularProgress } from '@mui/material'; import { Box, Button, CircularProgress } from '@mui/material';
@@ -9,7 +11,7 @@ interface FormLoaderProps {
onRetry?: () => void; onRetry?: () => void;
} }
const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => { const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
if (errorMessage) { if (errorMessage) {
@@ -38,4 +40,6 @@ const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => {
); );
}; };
const FormLoader = memo(FormLoaderComponent);
export default FormLoader; export default FormLoader;

View File

@@ -1,20 +1,22 @@
import { memo } from 'react'; import { memo } from 'react';
import { Box, CircularProgress } from '@mui/material'; import { Box, CircularProgress } from '@mui/material';
import type { SxProps, Theme } from '@mui/material';
const LazyLoader = memo(() => ( // Extract styles to prevent recreation on every render
<Box const containerStyles: SxProps<Theme> = {
display="flex" display: 'flex',
justifyContent="center" justifyContent: 'center',
alignItems="center" alignItems: 'center',
minHeight="200px" minHeight: '200px',
sx={{
backgroundColor: 'background.default', backgroundColor: 'background.default',
borderRadius: 1, borderRadius: 1,
border: '1px solid', border: '1px solid',
borderColor: 'divider' borderColor: 'divider'
}} };
>
const LazyLoader = memo(() => (
<Box sx={containerStyles}>
<CircularProgress size={40} /> <CircularProgress size={40} />
</Box> </Box>
)); ));

View File

@@ -1,10 +1,18 @@
import { memo } from 'react';
import { Box, CircularProgress } from '@mui/material'; import { Box, CircularProgress } from '@mui/material';
import type { Theme } from '@mui/material'; import type { SxProps, Theme } from '@mui/material';
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
height?: number | string; height?: number | string;
} }
// Extract styles to prevent recreation on every render
const circularProgressStyles: SxProps<Theme> = (theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
});
const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => { const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
return ( return (
<Box <Box
@@ -15,15 +23,9 @@ const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => {
padding={2} padding={2}
height={height} height={height}
> >
<CircularProgress <CircularProgress sx={circularProgressStyles} size={100} />
sx={(theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
})}
size={100}
/>
</Box> </Box>
); );
}; };
export default LoadingSpinner; export default memo(LoadingSpinner);

View File

@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react';
import type { Blocker } from 'react-router'; import type { Blocker } from 'react-router';
import { import {
@@ -14,23 +15,23 @@ import { useI18nContext } from 'i18n/i18n-react';
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const handleReset = useCallback(() => {
blocker.reset?.();
}, [blocker]);
const handleProceed = useCallback(() => {
blocker.proceed?.();
}, [blocker]);
return ( return (
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}> <Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
<DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle> <DialogTitle>{LL.BLOCK_NAVIGATE_1()}</DialogTitle>
<DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent> <DialogContent dividers>{LL.BLOCK_NAVIGATE_2()}</DialogContent>
<DialogActions> <DialogActions>
<Button <Button variant="outlined" onClick={handleReset} color="secondary">
variant="outlined"
onClick={() => blocker.reset?.()}
color="secondary"
>
{LL.STAY()} {LL.STAY()}
</Button> </Button>
<Button <Button variant="contained" onClick={handleProceed} color="primary">
variant="contained"
onClick={() => blocker.proceed?.()}
color="primary"
>
{LL.LEAVE()} {LL.LEAVE()}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -38,4 +39,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
); );
}; };
export default BlockNavigation; export default memo(BlockNavigation);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react'; import { memo, useContext } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Navigate } from 'react-router'; import { Navigate } from 'react-router';
@@ -14,4 +14,4 @@ const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
); );
}; };
export default RequireAdmin; export default memo(RequireAdmin);

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect } from 'react'; import { memo, useContext, useEffect } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Navigate, useLocation } from 'react-router'; import { Navigate, useLocation } from 'react-router';
@@ -18,7 +18,7 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
if (!authenticationContext.me) { if (!authenticationContext.me) {
storeLoginRedirect(location); storeLoginRedirect(location);
} }
}); }, [authenticationContext.me, location]);
return authenticationContext.me ? ( return authenticationContext.me ? (
<AuthenticatedContext.Provider <AuthenticatedContext.Provider
@@ -31,4 +31,4 @@ const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
); );
}; };
export default RequireAuthenticated; export default memo(RequireAuthenticated);

View File

@@ -1,4 +1,4 @@
import { useContext } from 'react'; import { memo, useContext } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { Navigate } from 'react-router'; import { Navigate } from 'react-router';
@@ -16,4 +16,4 @@ const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
); );
}; };
export default RequireUnauthenticated; export default memo(RequireUnauthenticated);

View File

@@ -1,3 +1,4 @@
import { memo, useCallback } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
@@ -15,9 +16,12 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const theme = useTheme(); const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm')); const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (_event: unknown, path: string) => { const handleTabChange = useCallback(
(_event: unknown, path: string) => {
void navigate(path); void navigate(path);
}; },
[navigate]
);
return ( return (
<Tabs <Tabs
@@ -30,4 +34,4 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
); );
}; };
export default RouterTabs; export default memo(RouterTabs);

View File

@@ -13,8 +13,14 @@ export const verifyAuthorization = () =>
export const signIn = (request: SignInRequest) => export const signIn = (request: SignInRequest) =>
alovaInstance.Post<SignInResponse>('/rest/signIn', request); alovaInstance.Post<SignInResponse>('/rest/signIn', request);
// Cache storage reference to avoid repeated checks
let cachedStorage: Storage | undefined;
export function getStorage() { export function getStorage() {
return localStorage || sessionStorage; if (!cachedStorage) {
cachedStorage = localStorage || sessionStorage;
}
return cachedStorage;
} }
export function storeLoginRedirect(location?: { pathname: string; search: string }) { export function storeLoginRedirect(location?: { pathname: string; search: string }) {

View File

@@ -187,6 +187,7 @@ const cz: Translation = {
BUFFER_SIZE: 'Maximální velikost vyrovnávací paměti', BUFFER_SIZE: 'Maximální velikost vyrovnávací paměti',
COMPACT: 'Kompaktní', COMPACT: 'Kompaktní',
DOWNLOAD_SETTINGS_TEXT: 'Vytvořte zálohu svého nastavení a konfigurace', DOWNLOAD_SETTINGS_TEXT: 'Vytvořte zálohu svého nastavení a konfigurace',
DOWNLOAD_SETTINGS_TEXT2: 'Exportovat všechna data',
UPLOAD_TEXT: 'Nahrajte nový soubor firmwaru (.bin) nebo záložní soubor (.json)', UPLOAD_TEXT: 'Nahrajte nový soubor firmwaru (.bin) nebo záložní soubor (.json)',
UPLOAD_DROP_TEXT: 'Přetáhněte soubor sem nebo klikněte pro výběr', UPLOAD_DROP_TEXT: 'Přetáhněte soubor sem nebo klikněte pro výběr',
ERROR: 'Neočekávaná chyba, zkuste to prosím znovu', ERROR: 'Neočekávaná chyba, zkuste to prosím znovu',

View File

@@ -187,6 +187,7 @@ const de: Translation = {
BUFFER_SIZE: 'Max. Puffergröße', BUFFER_SIZE: 'Max. Puffergröße',
COMPACT: 'Kompakte Darstellung', COMPACT: 'Kompakte Darstellung',
DOWNLOAD_SETTINGS_TEXT: 'Erstellen Sie eine Sicherung Ihrer Konfigurationen und Einstellungen', DOWNLOAD_SETTINGS_TEXT: 'Erstellen Sie eine Sicherung Ihrer Konfigurationen und Einstellungen',
DOWNLOAD_SETTINGS_TEXT2: 'Exportiere alle Daten',
UPLOAD_TEXT: 'Laden Sie eine neue Firmware-Datei (.bin) oder eine Sicherungsdatei (.json) hoch', UPLOAD_TEXT: 'Laden Sie eine neue Firmware-Datei (.bin) oder eine Sicherungsdatei (.json) hoch',
UPLOAD_DROP_TEXT: 'Legen Sie eine Firmware-Datei (.bin) ab oder klicken Sie hier', UPLOAD_DROP_TEXT: 'Legen Sie eine Firmware-Datei (.bin) ab oder klicken Sie hier',
ERROR: 'Unerwarteter Fehler, bitte versuchen Sie es erneut.', ERROR: 'Unerwarteter Fehler, bitte versuchen Sie es erneut.',

View File

@@ -187,6 +187,7 @@ const en: Translation = {
BUFFER_SIZE: 'Max Buffer Size', BUFFER_SIZE: 'Max Buffer Size',
COMPACT: 'Compact', COMPACT: 'Compact',
DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings', DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings',
DOWNLOAD_SETTINGS_TEXT2: 'Export all data',
UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)', UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here',
ERROR: 'Unexpected Error, please try again', ERROR: 'Unexpected Error, please try again',

View File

@@ -187,6 +187,7 @@ const fr: Translation = {
BUFFER_SIZE: 'Max taille du buffer', BUFFER_SIZE: 'Max taille du buffer',
COMPACT: 'Compact', COMPACT: 'Compact',
DOWNLOAD_SETTINGS_TEXT: 'Créer une sauvegarde de vos paramètres et configurations', DOWNLOAD_SETTINGS_TEXT: 'Créer une sauvegarde de vos paramètres et configurations',
DOWNLOAD_SETTINGS_TEXT2: 'Exporter toutes les données',
UPLOAD_TEXT: 'Télécharger un nouveau fichier firmware (.bin) ou une sauvegarde (.json)', UPLOAD_TEXT: 'Télécharger un nouveau fichier firmware (.bin) ou une sauvegarde (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here',
ERROR: 'Erreur inattendue, veuillez réessayer', ERROR: 'Erreur inattendue, veuillez réessayer',

View File

@@ -187,6 +187,7 @@ const it: Translation = {
BUFFER_SIZE: 'Max Buffer Size', BUFFER_SIZE: 'Max Buffer Size',
COMPACT: 'Compatto', COMPACT: 'Compatto',
DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings', DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings',
DOWNLOAD_SETTINGS_TEXT2: 'Esporta tutti i dati',
UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)', UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)',
UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here',
ERROR: 'Errore Inaspettato, prego tenta ancora', ERROR: 'Errore Inaspettato, prego tenta ancora',

View File

@@ -187,6 +187,7 @@ const nl: Translation = {
BUFFER_SIZE: 'Max buffer grootte', BUFFER_SIZE: 'Max buffer grootte',
COMPACT: 'Compact', COMPACT: 'Compact',
DOWNLOAD_SETTINGS_TEXT: 'Maak een back-up van uw configuratie en instellingen', DOWNLOAD_SETTINGS_TEXT: 'Maak een back-up van uw configuratie en instellingen',
DOWNLOAD_SETTINGS_TEXT2: 'Exporteer alle data',
UPLOAD_TEXT: 'Upload een nieuw firmwarebestand (.bin) of een back-upbestand (.json)', UPLOAD_TEXT: 'Upload een nieuw firmwarebestand (.bin) of een back-upbestand (.json)',
UPLOAD_DROP_TEXT: 'Sleep en firmware .bin bestand hierheen of klik hier', UPLOAD_DROP_TEXT: 'Sleep en firmware .bin bestand hierheen of klik hier',
ERROR: 'Onverwachte fout, probeer opnieuw', ERROR: 'Onverwachte fout, probeer opnieuw',

View File

@@ -187,6 +187,7 @@ const no: Translation = {
BUFFER_SIZE: 'Max Buffer Størrelse', BUFFER_SIZE: 'Max Buffer Størrelse',
COMPACT: 'Komprimere', COMPACT: 'Komprimere',
DOWNLOAD_SETTINGS_TEXT: 'Lag en sikkerhetskopi av dine konfigurasjon og innstillinger', DOWNLOAD_SETTINGS_TEXT: 'Lag en sikkerhetskopi av dine konfigurasjon og innstillinger',
DOWNLOAD_SETTINGS_TEXT2: 'Eksporter alle data',
UPLOAD_TEXT: 'Last opp en ny firmware fil (.bin) eller en sikkerhetskopi fil (.json)', UPLOAD_TEXT: 'Last opp en ny firmware fil (.bin) eller en sikkerhetskopi fil (.json)',
UPLOAD_DROP_TEXT: 'Dropp en firmware fil (.bin) eller klikk her', UPLOAD_DROP_TEXT: 'Dropp en firmware fil (.bin) eller klikk her',
ERROR: 'Ukjent feil, prøv igjen', ERROR: 'Ukjent feil, prøv igjen',

View File

@@ -187,6 +187,7 @@ const pl: BaseTranslation = {
BUFFER_SIZE: 'Maksymalna pojemność bufora (ilość wpisów)', BUFFER_SIZE: 'Maksymalna pojemność bufora (ilość wpisów)',
COMPACT: 'Kompaktowy', COMPACT: 'Kompaktowy',
DOWNLOAD_SETTINGS_TEXT: 'Utwórz kopię swoich ustawień i konfiguracji', DOWNLOAD_SETTINGS_TEXT: 'Utwórz kopię swoich ustawień i konfiguracji',
DOWNLOAD_SETTINGS_TEXT2: 'Eksportuj wszystkie dane',
UPLOAD_TEXT: 'Wgraj nowy plik firmware (.bin) lub kopię ustawień (.json)', UPLOAD_TEXT: 'Wgraj nowy plik firmware (.bin) lub kopię ustawień (.json)',
UPLOAD_DROP_TEXT: 'Upuść plik firmware .bin lub kliknij tutaj', UPLOAD_DROP_TEXT: 'Upuść plik firmware .bin lub kliknij tutaj',
ERROR: 'Nieoczekiwany błąd, spróbuj ponownie!', ERROR: 'Nieoczekiwany błąd, spróbuj ponownie!',

View File

@@ -187,6 +187,7 @@ const sk: Translation = {
BUFFER_SIZE: 'Buffer-max. veľkosť', BUFFER_SIZE: 'Buffer-max. veľkosť',
COMPACT: 'Kompaktné', COMPACT: 'Kompaktné',
DOWNLOAD_SETTINGS_TEXT: 'Vytvorte zálohu svojej konfigurácie a nastavení', DOWNLOAD_SETTINGS_TEXT: 'Vytvorte zálohu svojej konfigurácie a nastavení',
DOWNLOAD_SETTINGS_TEXT2: 'Exportovať všetky dáta',
UPLOAD_TEXT: 'Nahrajte nový súbor firmvéru (.bin) alebo súbor zálohy (.json)', UPLOAD_TEXT: 'Nahrajte nový súbor firmvéru (.bin) alebo súbor zálohy (.json)',
UPLOAD_DROP_TEXT: 'Presuňte súbor .bin firmvéru alebo kliknite sem', UPLOAD_DROP_TEXT: 'Presuňte súbor .bin firmvéru alebo kliknite sem',
ERROR: 'Neočakávaná chyba, prosím skúste to znova', ERROR: 'Neočakávaná chyba, prosím skúste to znova',

View File

@@ -187,6 +187,7 @@ const sv: Translation = {
BUFFER_SIZE: 'Max bufferstorlek', BUFFER_SIZE: 'Max bufferstorlek',
COMPACT: 'Komprimerad', COMPACT: 'Komprimerad',
DOWNLOAD_SETTINGS_TEXT: 'Skapa en säkerhetskopia av din konfiguration och inställningar', DOWNLOAD_SETTINGS_TEXT: 'Skapa en säkerhetskopia av din konfiguration och inställningar',
DOWNLOAD_SETTINGS_TEXT2: 'Exportera alla data',
UPLOAD_TEXT: 'Ladda upp en ny firmwarefil (.bin) eller en säkerhetskopiafil (.json)', UPLOAD_TEXT: 'Ladda upp en ny firmwarefil (.bin) eller en säkerhetskopiafil (.json)',
UPLOAD_DROP_TEXT: 'Droppa en firmware .bin fil eller klicka här', UPLOAD_DROP_TEXT: 'Droppa en firmware .bin fil eller klicka här',
ERROR: 'Okänt fel, var god försök igen', ERROR: 'Okänt fel, var god försök igen',

View File

@@ -187,6 +187,7 @@ const tr: Translation = {
BUFFER_SIZE: 'En fazla bellek boyutu', BUFFER_SIZE: 'En fazla bellek boyutu',
COMPACT: 'Sıkışık', COMPACT: 'Sıkışık',
DOWNLOAD_SETTINGS_TEXT: 'Yapılandırma ve ayarlarınızın yedekleme yapın', DOWNLOAD_SETTINGS_TEXT: 'Yapılandırma ve ayarlarınızın yedekleme yapın',
DOWNLOAD_SETTINGS_TEXT2: 'Tüm verileri dışarı al',
UPLOAD_TEXT: 'Yeni bir firmware dosyası (.bin) veya yedek dosyası (.json) yükle', UPLOAD_TEXT: 'Yeni bir firmware dosyası (.bin) veya yedek dosyası (.json) yükle',
UPLOAD_DROP_TEXT: 'Bir firmware .bin dosyası veya buraya tıklayın', UPLOAD_DROP_TEXT: 'Bir firmware .bin dosyası veya buraya tıklayın',
ERROR: 'Beklenemedik hata, lütfen tekrar deneyin.', ERROR: 'Beklenemedik hata, lütfen tekrar deneyin.',

View File

@@ -4,13 +4,109 @@ import {
Route, Route,
RouterProvider, RouterProvider,
createBrowserRouter, createBrowserRouter,
createRoutesFromElements createRoutesFromElements,
useRouteError
} from 'react-router'; } from 'react-router';
import App from 'App'; import App from 'App';
const errorPageStyles = {
container: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '2rem',
textAlign: 'center' as const,
backgroundColor: '#1e1e1e',
color: '#eee'
},
logo: {
width: '100px',
height: '100px',
marginBottom: '2rem'
},
title: {
fontSize: '2rem',
margin: '0 0 1rem 0',
color: '#90CAF9',
fontWeight: 500 as const
},
status: {
color: '#2196f3',
fontSize: '1.5rem',
fontWeight: 400 as const,
margin: '0 0 1rem 0'
},
message: {
fontFamily: 'monospace, monospace',
fontSize: '1.1rem',
color: '#9e9e9e',
maxWidth: '600px',
margin: '0 0 2rem 0'
},
message2: {
fontSize: '1.1rem',
color: '#2196f3',
maxWidth: '600px',
margin: '0 0 2rem 0'
}
};
interface ErrorWithStatus {
status?: number | string;
statusText?: string;
message?: string;
}
function isErrorWithDetails(error: unknown): error is ErrorWithStatus {
return typeof error === 'object' && error !== null;
}
function getErrorStatus(error: unknown): string {
if (isErrorWithDetails(error) && 'status' in error && error.status != null) {
return String(error.status);
}
return 'Error';
}
function getErrorMessage(error: unknown): string {
if (!isErrorWithDetails(error)) {
return 'Something went wrong';
}
return error.statusText || error.message || 'Something went wrong';
}
// Custom Error Component
function ErrorPage() {
const error = useRouteError();
return (
<div style={errorPageStyles.container}>
<img src="/app/icon.png" alt="EMS-ESP Logo" style={errorPageStyles.logo} />
<h1 style={errorPageStyles.title}>The WebUI is having problems</h1>
<p style={errorPageStyles.message}>
{getErrorStatus(error)}: {getErrorMessage(error)}
</p>
<p style={errorPageStyles.message2}>
Please report on{' '}
<a
href="https://docs.emsesp.org/Support"
target="_blank"
rel="noreferrer"
style={{ color: 'inherit', textDecoration: 'underline' }}
>
https://docs.emsesp.org/Support
</a>
</p>
</div>
);
}
const router = createBrowserRouter( const router = createBrowserRouter(
createRoutesFromElements(<Route path="/*" element={<App />} />) createRoutesFromElements(
<Route path="/*" element={<App />} errorElement={<ErrorPage />} />
)
); );
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@@ -1,8 +1,9 @@
export * from './ap'; export * from './ap';
export * from './features';
export * from './me'; export * from './me';
export * from './mqtt'; export * from './mqtt';
export * from './network';
export * from './ntp'; export * from './ntp';
export * from './security'; export * from './security';
export * from './signin'; export * from './signin';
export * from './system'; export * from './system';
export * from './network';

View File

@@ -54,6 +54,7 @@ export interface NetworkSettingsType {
enableMDNS: boolean; enableMDNS: boolean;
enableCORS: boolean; enableCORS: boolean;
CORSOrigin: string; CORSOrigin: string;
[key: string]: unknown;
} }
export interface WiFiNetworkList { export interface WiFiNetworkList {

View File

@@ -1,60 +1,67 @@
export const numberValue = (value?: number) => { /**
if (value !== undefined) { * Converts a number value to a string for input fields.
return isNaN(value) ? '' : value.toString(); * Returns empty string for undefined or NaN values.
} */
return ''; export const numberValue = (value?: number): string =>
}; value === undefined || isNaN(value) ? '' : String(value);
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => { /**
switch (event.target.type) { * Extracts the appropriate value from an input event based on input type.
case 'number': */
return event.target.valueAsNumber; export const extractEventValue = (
case 'checkbox': event: React.ChangeEvent<HTMLInputElement>
return event.target.checked; ): string | number | boolean => {
default: const { type, valueAsNumber, checked, value } = event.target;
return event.target.value;
} if (type === 'number') return valueAsNumber;
if (type === 'checkbox') return checked;
return value;
}; };
type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void; type UpdateEntity<S> = (state: (prevState: Readonly<S>) => S) => void;
/**
* Creates an event handler that updates an entity's state based on input changes.
*/
export const updateValue = export const updateValue =
<S>(updateEntity: UpdateEntity<S>) => <S extends Record<string, unknown>>(updateEntity: UpdateEntity<S>) =>
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>): void => {
const { name } = event.target;
const value = extractEventValue(event);
updateEntity((prevState) => ({ updateEntity((prevState) => ({
...prevState, ...prevState,
[event.target.name]: extractEventValue(event) [name]: value
})); }));
}; };
/**
* Creates an event handler that tracks dirty flags for modified fields.
* Optimized to minimize state updates and unnecessary array operations.
*/
export const updateValueDirty = export const updateValueDirty =
( <T extends Record<string, unknown>>(
origData: unknown, origData: T,
dirtyFlags: string[], dirtyFlags: string[],
setDirtyFlags: React.Dispatch<React.SetStateAction<string[]>>, setDirtyFlags: React.Dispatch<React.SetStateAction<string[]>>,
updateDataValue: (value: unknown) => void updateDataValue: (updater: (prevState: T) => T) => void
) => ) =>
(event: React.ChangeEvent<HTMLInputElement>) => { (event: React.ChangeEvent<HTMLInputElement>): void => {
const updated_value = extractEventValue(event); const { name } = event.target;
const name = event.target.name; const updatedValue = extractEventValue(event);
updateDataValue((prevState: unknown) => ({ updateDataValue((prevState) => ({
...(prevState as Record<string, unknown>), ...prevState,
[name]: updated_value [name]: updatedValue
})); }));
const arr: string[] = dirtyFlags; const isDirty = origData[name] !== updatedValue;
const wasDirty = dirtyFlags.includes(name);
if ((origData as Record<string, unknown>)[name] !== updated_value) { // Only update dirty flags if the state changed
if (!arr.includes(name)) { if (isDirty !== wasDirty) {
arr.push(name); setDirtyFlags(
isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name)
);
} }
} else {
const startIndex = arr.indexOf(name);
if (startIndex !== -1) {
arr.splice(startIndex, 1);
}
}
setDirtyFlags(arr);
}; };

View File

@@ -1,11 +1,28 @@
export const saveFile = (json: unknown, filename: string, extension: string) => { export const saveFile = (
json: unknown,
filename: string,
extension: string
): void => {
try {
const blob = new Blob([JSON.stringify(json, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a'); const anchor = document.createElement('a');
anchor.href = URL.createObjectURL( anchor.href = url;
new Blob([JSON.stringify(json, null, 2)], { anchor.download = `emsesp_${filename}${extension}`;
type: 'text/plain'
}) // Trigger download
); document.body.appendChild(anchor);
anchor.download = 'emsesp_' + filename + extension;
anchor.click(); anchor.click();
URL.revokeObjectURL(anchor.href); document.body.removeChild(anchor);
// Delay revocation to ensure download starts properly
setTimeout(() => {
URL.revokeObjectURL(url);
}, 100);
} catch (error) {
console.error('Failed to save file:', error);
throw new Error(`Unable to save file: ${filename}${extension}`);
}
}; };

View File

@@ -1,8 +1,10 @@
// Cache for formatters to avoid recreation // Cache for formatters to avoid recreation (with size limits to prevent memory leaks)
const MAX_CACHE_SIZE = 50;
const formatterCache = new Map<string, Intl.DateTimeFormat>(); const formatterCache = new Map<string, Intl.DateTimeFormat>();
const rtfCache = new Map<string, Intl.RelativeTimeFormat>(); const rtfCache = new Map<string, Intl.RelativeTimeFormat>();
// Pre-computed time divisions for relative time formatting // Pre-computed constants
const MS_TO_MINUTES = 60000; // 60 * 1000
const TIME_DIVISIONS = [ const TIME_DIVISIONS = [
{ amount: 60, name: 'seconds' as const }, { amount: 60, name: 'seconds' as const },
{ amount: 60, name: 'minutes' as const }, { amount: 60, name: 'minutes' as const },
@@ -13,30 +15,79 @@ const TIME_DIVISIONS = [
{ amount: Number.POSITIVE_INFINITY, name: 'years' as const } { amount: Number.POSITIVE_INFINITY, name: 'years' as const }
] as const; ] as const;
// Cached navigator languages to avoid repeated array spreads
let cachedLanguages: readonly string[] | null = null;
/** /**
* Get or create a cached DateTimeFormat instance * Get navigator languages with caching
*/
function getNavigatorLanguages(): readonly string[] {
if (!cachedLanguages) {
cachedLanguages = window.navigator.languages;
}
return cachedLanguages;
}
/**
* Create a fast cache key from DateTimeFormat options
*/
function createFormatterKey(options: Intl.DateTimeFormatOptions): string {
// Build key from most common properties for better performance than JSON.stringify
return `${options.day}-${options.month}-${options.year}-${options.hour}-${options.minute}-${options.second}-${options.hour12}`;
}
/**
* Get or create a cached DateTimeFormat instance with LRU-like cache management
*/ */
function getDateTimeFormatter( function getDateTimeFormatter(
options: Intl.DateTimeFormatOptions options: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat { ): Intl.DateTimeFormat {
const key = JSON.stringify(options); const key = createFormatterKey(options);
if (!formatterCache.has(key)) {
formatterCache.set( if (formatterCache.has(key)) {
key, // Move to end for LRU behavior
new Intl.DateTimeFormat([...window.navigator.languages], options) const formatter = formatterCache.get(key)!;
); formatterCache.delete(key);
formatterCache.set(key, formatter);
return formatter;
} }
return formatterCache.get(key)!;
// Limit cache size
if (formatterCache.size >= MAX_CACHE_SIZE) {
const firstKey = formatterCache.keys().next().value;
if (firstKey) {
formatterCache.delete(firstKey);
}
}
const formatter = new Intl.DateTimeFormat(getNavigatorLanguages(), options);
formatterCache.set(key, formatter);
return formatter;
} }
/** /**
* Get or create a cached RelativeTimeFormat instance * Get or create a cached RelativeTimeFormat instance with cache size management
*/ */
function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat { function getRelativeTimeFormatter(locale: string): Intl.RelativeTimeFormat {
if (!rtfCache.has(locale)) { if (rtfCache.has(locale)) {
rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })); // Move to end for LRU behavior
const formatter = rtfCache.get(locale)!;
rtfCache.delete(locale);
rtfCache.set(locale, formatter);
return formatter;
} }
return rtfCache.get(locale)!;
// Limit cache size
if (rtfCache.size >= MAX_CACHE_SIZE) {
const firstKey = rtfCache.keys().next().value;
if (firstKey) {
rtfCache.delete(firstKey);
}
}
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
rtfCache.set(locale, formatter);
return formatter;
} }
/** /**
@@ -49,7 +100,7 @@ function formatTimeAgo(locale: string, date: Date): string {
const rtf = getRelativeTimeFormatter(locale); const rtf = getRelativeTimeFormatter(locale);
// Use for...of for better performance and readability // Find the appropriate time division
for (const division of TIME_DIVISIONS) { for (const division of TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) { if (Math.abs(duration) < division.amount) {
return rtf.format(Math.round(duration), division.name); return rtf.format(Math.round(duration), division.name);
@@ -57,7 +108,8 @@ function formatTimeAgo(locale: string, date: Date): string {
duration /= division.amount; duration /= division.amount;
} }
return rtf.format(0, 'seconds'); // This should never be reached due to POSITIVE_INFINITY in divisions
return rtf.format(Math.round(duration), 'years');
} }
/** /**
@@ -102,8 +154,8 @@ export const formatLocalDateTime = (date: Date): string => {
return 'Invalid date'; return 'Invalid date';
} }
// Calculate local time offset in milliseconds // Calculate local time offset using pre-computed constant
const offsetMs = date.getTimezoneOffset() * 60000; const offsetMs = date.getTimezoneOffset() * MS_TO_MINUTES;
const localTime = date.getTime() - offsetMs; const localTime = date.getTime() - offsetMs;
// Convert to ISO string and remove timezone info // Convert to ISO string and remove timezone info

Some files were not shown because too many files have changed in this diff Show More