mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 07:49:52 +03:00
optimizations
This commit is contained in:
@@ -13,9 +13,9 @@
|
|||||||
"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": "node progmem-generator.js",
|
||||||
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
"format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^7.3.4",
|
"@mui/icons-material": "^7.3.4",
|
||||||
"@mui/material": "^7.3.4",
|
"@mui/material": "^7.3.4",
|
||||||
|
"@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",
|
||||||
@@ -46,10 +47,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^7.28.5",
|
||||||
"@eslint/js": "^9.38.0",
|
"@eslint/js": "^9.38.0",
|
||||||
"@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": "^5.2.2",
|
||||||
"@types/node": "^24.9.1",
|
"@types/node": "^24.9.2",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.2",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
@@ -63,5 +63,5 @@
|
|||||||
"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.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8"
|
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
|
||||||
}
|
}
|
||||||
|
|||||||
78
interface/pnpm-lock.yaml
generated
78
interface/pnpm-lock.yaml
generated
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@mui/material':
|
'@mui/material':
|
||||||
specifier: ^7.3.4
|
specifier: ^7.3.4
|
||||||
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.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)
|
||||||
|
'@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)
|
||||||
@@ -75,18 +78,15 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.38.0
|
specifier: ^9.38.0
|
||||||
version: 9.38.0
|
version: 9.38.0
|
||||||
'@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.9.1)(terser@5.44.0))
|
version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))
|
||||||
'@trivago/prettier-plugin-sort-imports':
|
'@trivago/prettier-plugin-sort-imports':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2(prettier@3.6.2)
|
version: 5.2.2(prettier@3.6.2)
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.9.1
|
specifier: ^24.9.2
|
||||||
version: 24.9.1
|
version: 24.9.2
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.2.2
|
specifier: ^19.2.2
|
||||||
version: 19.2.2
|
version: 19.2.2
|
||||||
@@ -116,13 +116,13 @@ importers:
|
|||||||
version: 8.46.2(eslint@9.38.0)(typescript@5.9.3)
|
version: 8.46.2(eslint@9.38.0)(typescript@5.9.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^7.1.12
|
specifier: ^7.1.12
|
||||||
version: 7.1.12(@types/node@24.9.1)(terser@5.44.0)
|
version: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
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.9.1)(terser@5.44.0))
|
version: 0.6.1(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))
|
||||||
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.9.1)(terser@5.44.0))
|
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -851,8 +851,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
|
resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==}
|
||||||
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
|
deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@24.9.1':
|
'@types/node@24.9.2':
|
||||||
resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==}
|
resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==}
|
||||||
|
|
||||||
'@types/parse-json@4.0.2':
|
'@types/parse-json@4.0.2':
|
||||||
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
|
||||||
@@ -1324,8 +1324,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.240:
|
electron-to-chromium@1.5.241:
|
||||||
resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==}
|
resolution: {integrity: sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==}
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
@@ -2650,8 +2650,8 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
set-cookie-parser@2.7.1:
|
set-cookie-parser@2.7.2:
|
||||||
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
@@ -3575,18 +3575,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.9.1)(terser@5.44.0))':
|
'@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))':
|
||||||
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.9.1)(terser@5.44.0))
|
'@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))
|
||||||
'@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.9.1)(terser@5.44.0)
|
vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))
|
vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- preact
|
- preact
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -3599,7 +3599,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.9.1)(terser@5.44.0))':
|
'@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))':
|
||||||
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 +3607,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.9.1)(terser@5.44.0)
|
vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -3712,7 +3712,7 @@ snapshots:
|
|||||||
'@types/glob@7.2.0':
|
'@types/glob@7.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/minimatch': 6.0.0
|
'@types/minimatch': 6.0.0
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
|
|
||||||
'@types/imagemin-gifsicle@7.0.4':
|
'@types/imagemin-gifsicle@7.0.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3741,19 +3741,19 @@ snapshots:
|
|||||||
|
|
||||||
'@types/imagemin@7.0.1':
|
'@types/imagemin@7.0.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/keyv@3.1.4':
|
'@types/keyv@3.1.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
|
|
||||||
'@types/minimatch@6.0.0':
|
'@types/minimatch@6.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
minimatch: 10.0.3
|
minimatch: 10.0.3
|
||||||
|
|
||||||
'@types/node@24.9.1':
|
'@types/node@24.9.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.16.0
|
undici-types: 7.16.0
|
||||||
|
|
||||||
@@ -3775,11 +3775,11 @@ snapshots:
|
|||||||
|
|
||||||
'@types/responselike@1.0.3':
|
'@types/responselike@1.0.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
|
|
||||||
'@types/svgo@2.6.4':
|
'@types/svgo@2.6.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0)(typescript@5.9.3))(eslint@9.38.0)(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0)(typescript@5.9.3))(eslint@9.38.0)(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3995,7 +3995,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.8.20
|
baseline-browser-mapping: 2.8.20
|
||||||
caniuse-lite: 1.0.30001751
|
caniuse-lite: 1.0.30001751
|
||||||
electron-to-chromium: 1.5.240
|
electron-to-chromium: 1.5.241
|
||||||
node-releases: 2.0.26
|
node-releases: 2.0.26
|
||||||
update-browserslist-db: 1.1.4(browserslist@4.27.0)
|
update-browserslist-db: 1.1.4(browserslist@4.27.0)
|
||||||
|
|
||||||
@@ -4340,7 +4340,7 @@ snapshots:
|
|||||||
|
|
||||||
duplexer3@0.1.5: {}
|
duplexer3@0.1.5: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.240: {}
|
electron-to-chromium@1.5.241: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
@@ -5501,7 +5501,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
cookie: 1.0.2
|
cookie: 1.0.2
|
||||||
react: 19.2.0
|
react: 19.2.0
|
||||||
set-cookie-parser: 2.7.1
|
set-cookie-parser: 2.7.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
react-dom: 19.2.0(react@19.2.0)
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
@@ -5653,7 +5653,7 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.3: {}
|
semver@7.7.3: {}
|
||||||
|
|
||||||
set-cookie-parser@2.7.1: {}
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -5935,7 +5935,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.9.1)(terser@5.44.0)):
|
vite-plugin-imagemin@0.6.1(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)):
|
||||||
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 +5960,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.9.1)(terser@5.44.0)
|
vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)):
|
vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)):
|
||||||
dependencies:
|
dependencies:
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
@@ -5972,20 +5972,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.9.1)(terser@5.44.0)
|
vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
|
|
||||||
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)):
|
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)):
|
||||||
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.9.1)(terser@5.44.0)
|
vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
vite@7.1.12(@types/node@24.9.1)(terser@5.44.0):
|
vite@7.1.12(@types/node@24.9.2)(terser@5.44.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.11
|
esbuild: 0.25.11
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -5994,7 +5994,7 @@ snapshots:
|
|||||||
rollup: 4.52.5
|
rollup: 4.52.5
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.9.1
|
'@types/node': 24.9.2
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
terser: 5.44.0
|
terser: 5.44.0
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -60,8 +73,7 @@ const CustomEntitiesDialog = ({
|
|||||||
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
|
|
||||||
setEditItem({
|
setEditItem({
|
||||||
...selectedItem,
|
...selectedItem,
|
||||||
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
device_id: selectedItem.device_id.toString(16).toUpperCase(),
|
||||||
@@ -83,36 +95,51 @@ const CustomEntitiesDialog = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = 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 = 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 = 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 +147,6 @@ const CustomEntitiesDialog = ({
|
|||||||
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()}
|
{creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {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 +211,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 +295,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 +331,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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -64,6 +64,15 @@ import type { APIcall, Device, DeviceEntity } from './types';
|
|||||||
|
|
||||||
export const APIURL = window.location.origin + '/api/';
|
export const APIURL = window.location.origin + '/api/';
|
||||||
|
|
||||||
|
// Helper function to create masked entity ID - extracted to avoid duplication
|
||||||
|
const createMaskedEntityId = (de: DeviceEntity): string =>
|
||||||
|
de.m.toString(16).padStart(2, '0') +
|
||||||
|
de.id +
|
||||||
|
(de.cn || de.mi || de.ma ? '|' : '') +
|
||||||
|
(de.cn ? de.cn : '') +
|
||||||
|
(de.mi ? '>' + de.mi : '') +
|
||||||
|
(de.ma ? '<' + de.ma : '');
|
||||||
|
|
||||||
const Customizations = () => {
|
const Customizations = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
@@ -153,7 +162,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 +227,9 @@ const Customizations = () => {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
function hasEntityChanged(de: DeviceEntity) {
|
||||||
return (
|
return (
|
||||||
@@ -229,19 +242,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]);
|
||||||
|
|
||||||
@@ -316,9 +318,12 @@ 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]
|
||||||
|
);
|
||||||
|
|
||||||
const maskDisabled = (set: boolean) => {
|
const maskDisabled = (set: boolean) => {
|
||||||
setDeviceEntities(
|
setDeviceEntities(
|
||||||
@@ -388,15 +393,7 @@ const Customizations = () => {
|
|||||||
if (devices && deviceEntities && selectedDevice !== -1) {
|
if (devices && deviceEntities && selectedDevice !== -1) {
|
||||||
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;
|
||||||
@@ -512,9 +509,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">
|
||||||
@@ -612,13 +612,13 @@ const Customizations = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Typography variant="subtitle2" color="grey">
|
<Typography variant="subtitle2" color="grey">
|
||||||
{LL.SHOWING()} {shown_data.length}/{deviceEntities.length}
|
{LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}
|
||||||
{LL.ENTITIES(deviceEntities.length)}
|
{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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
@@ -30,6 +30,20 @@ interface SettingsCustomizationsDialogProps {
|
|||||||
selectedItem: DeviceEntity;
|
selectedItem: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LabelValueProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabelValue = ({ label, value }: LabelValueProps) => (
|
||||||
|
<Grid container direction="row">
|
||||||
|
<Typography variant="body2" color="warning.main">
|
||||||
|
{label}:
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{value}</Typography>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
|
||||||
const CustomizationsDialog = ({
|
const CustomizationsDialog = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -42,10 +56,13 @@ const CustomizationsDialog = ({
|
|||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(setEditItem);
|
||||||
|
|
||||||
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 +71,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({ ...editItem, m: updatedItem.m });
|
||||||
};
|
},
|
||||||
|
[editItem]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</DialogTitle>
|
<DialogTitle>{LL.EDIT() + ' ' + LL.ENTITY()}</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())}:
|
label={LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}
|
||||||
</Typography>
|
value={editItem.n}
|
||||||
<Typography variant="body2">{editItem.id}</Typography>
|
/>
|
||||||
</Grid>
|
<LabelValue
|
||||||
|
label={LL.WRITEABLE()}
|
||||||
<Grid container direction="row">
|
value={
|
||||||
<Typography variant="body2" color="warning.main">
|
editItem.w ? (
|
||||||
{LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">{editItem.n}</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid container direction="row">
|
|
||||||
<Typography variant="body2" color="warning.main">
|
|
||||||
{LL.WRITEABLE()}:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
{editItem.w ? (
|
|
||||||
<DoneIcon color="success" sx={{ fontSize: 16 }} />
|
<DoneIcon color="success" sx={{ fontSize: 16 }} />
|
||||||
) : (
|
) : (
|
||||||
<CloseIcon color="error" 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 +159,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 />}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { AiOutlineAlert, AiOutlineControl, AiOutlineGateway } from 'react-icons/
|
|||||||
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 +18,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,13 +40,17 @@ 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 = ({ type_id }: DeviceIconProps) => {
|
||||||
const Icon = deviceIconLookup[type_id];
|
const Icon = deviceIconLookup[type_id];
|
||||||
return Icon ? <Icon /> : null;
|
return Icon ? <Icon /> : null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: []
|
||||||
|
|||||||
@@ -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} → {dv.x}
|
{dv.m} → {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,7 +136,6 @@ const DevicesDialog = ({
|
|||||||
{editItem.l ? (
|
{editItem.l ? (
|
||||||
<TextField
|
<TextField
|
||||||
name="v"
|
name="v"
|
||||||
// label={LL.VALUE(0)}
|
|
||||||
value={editItem.v}
|
value={editItem.v}
|
||||||
disabled={!writeable}
|
disabled={!writeable}
|
||||||
sx={{ width: '30ch' }}
|
sx={{ width: '30ch' }}
|
||||||
@@ -137,7 +152,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 +176,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 +185,9 @@ const DevicesDialog = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{writeable && (
|
{writeable && helperText && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<FormHelperText>{showHelperText(editItem)}</FormHelperText>
|
<FormHelperText>{helperText}</FormHelperText>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -191,7 +206,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 +217,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 +232,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,91 +9,110 @@ interface EntityMaskToggleProps {
|
|||||||
de: DeviceEntity;
|
de: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Available mask values
|
||||||
|
const MASK_VALUES = [
|
||||||
|
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||||
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||||
|
DeviceEntityMask.DV_READONLY, // 4
|
||||||
|
DeviceEntityMask.DV_FAVORITE, // 8
|
||||||
|
DeviceEntityMask.DV_DELETED // 128
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an array of mask strings to a bitmask number
|
||||||
|
*/
|
||||||
|
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 EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
const handleChange = (_event: unknown, mask: string[]) => {
|
||||||
let new_mask = 0;
|
// Convert selected masks to a number
|
||||||
for (const entry of newMask) {
|
const newMask = getMaskNumber(mask);
|
||||||
new_mask |= Number(entry);
|
|
||||||
|
// Apply business logic for mask interactions
|
||||||
|
// If entity has no name and is set to readonly, also exclude from web
|
||||||
|
if (de.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||||
|
de.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
|
} else {
|
||||||
|
de.m = newMask;
|
||||||
}
|
}
|
||||||
return new_mask;
|
|
||||||
|
// If excluded from web, cannot be favorite
|
||||||
|
if (hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||||
|
de.m = de.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate(de);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMaskString = (m: number) => {
|
// Check if favorite button should be disabled
|
||||||
const new_masks: string[] = [];
|
const isFavoriteDisabled =
|
||||||
if ((m & 1) === 1) {
|
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
||||||
new_masks.push('1');
|
de.n === undefined;
|
||||||
}
|
|
||||||
if ((m & 2) === 2) {
|
// Check if readonly button should be disabled
|
||||||
new_masks.push('2');
|
const isReadonlyDisabled =
|
||||||
}
|
!de.w ||
|
||||||
if ((m & 4) === 4) {
|
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE);
|
||||||
new_masks.push('4');
|
|
||||||
}
|
// Check if api/mqtt exclude button should be disabled
|
||||||
if ((m & 8) === 8) {
|
const isApiMqttExcludeDisabled =
|
||||||
new_masks.push('8');
|
de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED);
|
||||||
}
|
|
||||||
if ((m & 128) === 128) {
|
// Check if web exclude button should be disabled
|
||||||
new_masks.push('128');
|
const isWebExcludeDisabled =
|
||||||
}
|
de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED);
|
||||||
return new_masks;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={getMaskString(de.m)}
|
value={getMaskString(de.m)}
|
||||||
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"
|
type="favorite"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
|
||||||
(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"
|
type="readonly"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
|
||||||
(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"
|
type="api_mqtt_exclude"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
|
||||||
(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"
|
type="web_exclude"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
|
||||||
(de.m & DeviceEntityMask.DV_WEB_EXCLUDE) ===
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="128">
|
<ToggleButton value="128">
|
||||||
<OptionIcon
|
<OptionIcon
|
||||||
type="deleted"
|
type="deleted"
|
||||||
isSet={
|
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||||
(de.m & DeviceEntityMask.DV_DELETED) === DeviceEntityMask.DV_DELETED
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|||||||
@@ -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,83 @@ const Help = () => {
|
|||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleDownloadSystemInfo = useCallback(() => {
|
||||||
|
void sendAPI({ device: 'system', cmd: 'info', id: 0 });
|
||||||
|
}, [sendAPI]);
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 && (
|
{me.admin && (
|
||||||
<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 +189,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 +205,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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -31,6 +31,20 @@ 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';
|
||||||
|
|
||||||
|
function hasModulesChanged(mi: ModuleItem): boolean {
|
||||||
|
return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorStatus = (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 +70,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 +112,45 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
||||||
@@ -152,9 +170,9 @@ const Modules = () => {
|
|||||||
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 +187,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">
|
||||||
@@ -252,12 +263,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}
|
||||||
|
|||||||
@@ -39,20 +39,13 @@ const ModulesDialog = ({
|
|||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(setEditItem);
|
||||||
|
|
||||||
|
// 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 = () => {
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = () => {
|
|
||||||
onSave(editItem);
|
|
||||||
};
|
|
||||||
|
|
||||||
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>{LL.EDIT() + ' ' + editItem.key}</DialogTitle>
|
||||||
@@ -85,7 +78,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 +86,7 @@ const ModulesDialog = ({
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<DoneIcon />}
|
startIcon={<DoneIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={() => onSave(editItem)}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{LL.UPDATE()}
|
{LL.UPDATE()}
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ const OPTION_ICONS: {
|
|||||||
|
|
||||||
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
|
const OptionIcon = ({ type, isSet }: { type: OptionType; isSet: boolean }) => {
|
||||||
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
|
const Icon = OPTION_ICONS[type][isSet ? 0 : 1];
|
||||||
return isSet ? (
|
return (
|
||||||
<Icon color="primary" sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
<Icon
|
||||||
) : (
|
{...(isSet && { color: 'primary' })}
|
||||||
<Icon sx={{ fontSize: 16, verticalAlign: 'middle' }} />
|
sx={{ fontSize: 16, verticalAlign: 'middle' }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ const Scheduler = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function hasScheduleChanged(si: ScheduleItem) {
|
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||||
return (
|
return (
|
||||||
si.id !== si.o_id ||
|
si.id !== si.o_id ||
|
||||||
(si.name || '') !== (si.o_name || '') ||
|
(si.name || '') !== (si.o_name || '') ||
|
||||||
@@ -72,13 +72,13 @@ const Scheduler = () => {
|
|||||||
si.cmd !== si.o_cmd ||
|
si.cmd !== si.o_cmd ||
|
||||||
si.value !== si.o_value
|
si.value !== si.o_value
|
||||||
);
|
);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
if (numChanges === 0) {
|
if (numChanges === 0) {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}
|
}
|
||||||
});
|
}, 30000);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const formatter = new Intl.DateTimeFormat(locale, {
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
@@ -92,7 +92,9 @@ const Scheduler = () => {
|
|||||||
setDow(days.map((date) => formatter.format(date)));
|
setDow(days.map((date) => formatter.format(date)));
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
const schedule_theme = useTheme({
|
const schedule_theme = useTheme(
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
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 +132,12 @@ const Scheduler = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const saveSchedule = async () => {
|
const saveSchedule = useCallback(async () => {
|
||||||
await updateSchedule({
|
await updateSchedule({
|
||||||
schedule: schedule
|
schedule: schedule
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
@@ -156,7 +161,7 @@ const Scheduler = () => {
|
|||||||
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,17 +172,18 @@ 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
|
||||||
@@ -193,9 +199,11 @@ const Scheduler = () => {
|
|||||||
|
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[creating, hasScheduleChanged]
|
||||||
|
);
|
||||||
|
|
||||||
const addScheduleItem = () => {
|
const addScheduleItem = useCallback(() => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedScheduleItem({
|
setSelectedScheduleItem({
|
||||||
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100),
|
||||||
@@ -208,16 +216,18 @@ const Scheduler = () => {
|
|||||||
name: ''
|
name: ''
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const renderSchedule = () => {
|
const filteredAndSortedSchedule = useMemo(
|
||||||
if (!schedule) {
|
() =>
|
||||||
return (
|
schedule
|
||||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
|
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||||
|
[schedule]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const dayBox = (si: ScheduleItem, flag: number) => (
|
const dayBox = useCallback(
|
||||||
|
(si: ScheduleItem, flag: number) => (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
@@ -229,9 +239,12 @@ const Scheduler = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
<Divider orientation="vertical" flexItem />
|
<Divider orientation="vertical" flexItem />
|
||||||
</>
|
</>
|
||||||
|
),
|
||||||
|
[dow]
|
||||||
);
|
);
|
||||||
|
|
||||||
const scheduleType = (si: ScheduleItem) => (
|
const scheduleType = useCallback(
|
||||||
|
(si: ScheduleItem) => (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: 11 }} color="primary">
|
<Typography sx={{ fontSize: 11 }} color="primary">
|
||||||
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
|
{si.flags === ScheduleFlag.SCHEDULE_IMMEDIATE ? (
|
||||||
@@ -247,15 +260,20 @@ const Scheduler = () => {
|
|||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderSchedule = () => {
|
||||||
|
if (!schedule) {
|
||||||
|
return (
|
||||||
|
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -53,7 +53,6 @@ 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 = updateValue(setEditItem);
|
||||||
@@ -74,84 +73,135 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
}, [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) & 127;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getFlagDOWstring = useCallback((f: number) => {
|
||||||
|
const flagValues = [
|
||||||
|
ScheduleFlag.SCHEDULE_SUN,
|
||||||
|
ScheduleFlag.SCHEDULE_MON,
|
||||||
|
ScheduleFlag.SCHEDULE_TUE,
|
||||||
|
ScheduleFlag.SCHEDULE_WED,
|
||||||
|
ScheduleFlag.SCHEDULE_THU,
|
||||||
|
ScheduleFlag.SCHEDULE_FRI,
|
||||||
|
ScheduleFlag.SCHEDULE_SAT
|
||||||
|
];
|
||||||
|
return flagValues
|
||||||
|
.filter((flag) => (f & flag) === flag)
|
||||||
|
.map((flag) => String(flag));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Day of week display component
|
||||||
|
const DayOfWeekButton = useMemo(
|
||||||
|
() => (flag: number) => {
|
||||||
|
const dayIndex = Math.log2(flag);
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: 10 }}
|
||||||
|
color={(editItem.flags & flag) === flag ? '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({ ...editItem, time: '', flags: newFlags });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[editItem]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDOWChange = useCallback(
|
||||||
|
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||||
|
const newFlags = getFlagDOWnumber(flags);
|
||||||
|
setEditItem({ ...editItem, flags: newFlags });
|
||||||
|
},
|
||||||
|
[editItem, getFlagDOWnumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize derived values
|
||||||
|
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
|
||||||
|
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
|
||||||
|
const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE;
|
||||||
|
const needsTimeField = isDaySchedule || isTimerSchedule;
|
||||||
|
|
||||||
|
const dowFlags = useMemo(
|
||||||
|
() => getFlagDOWstring(editItem.flags),
|
||||||
|
[editItem.flags, getFlagDOWstring]
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFieldValue = useMemo(() => {
|
||||||
|
if (needsTimeField) {
|
||||||
|
return editItem.time === '' ? '00:00' : editItem.time;
|
||||||
|
}
|
||||||
|
return editItem.time === '00:00' ? '' : 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]);
|
||||||
|
|
||||||
|
// Day of week configuration
|
||||||
|
const dayFlags = [
|
||||||
|
{ 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 }
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
@@ -166,30 +216,12 @@ 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: 10 }}
|
||||||
color={scheduleType === ScheduleFlag.SCHEDULE_DAY ? 'primary' : 'grey'}
|
color={isDaySchedule ? 'primary' : 'grey'}
|
||||||
>
|
>
|
||||||
{LL.SCHEDULE(0)}
|
{LL.SCHEDULE(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -197,9 +229,7 @@ const SchedulerDialog = ({
|
|||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_TIMER}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: 10 }}
|
||||||
color={
|
color={isTimerSchedule ? 'primary' : 'grey'}
|
||||||
scheduleType === ScheduleFlag.SCHEDULE_TIMER ? 'primary' : 'grey'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{LL.TIMER(0)}
|
{LL.TIMER(0)}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -227,49 +257,29 @@ const SchedulerDialog = ({
|
|||||||
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||||
<Typography
|
<Typography
|
||||||
sx={{ fontSize: 10 }}
|
sx={{ fontSize: 10 }}
|
||||||
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">
|
{dayFlags.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 +294,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 +315,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 +385,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"
|
||||||
|
|||||||
@@ -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,7 @@ import {
|
|||||||
temperatureSensorItemValidation
|
temperatureSensorItemValidation
|
||||||
} from './validators';
|
} from './validators';
|
||||||
|
|
||||||
const Sensors = () => {
|
const common_theme = {
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
const [selectedTemperatureSensor, setSelectedTemperatureSensor] =
|
|
||||||
useState<TemperatureSensor>();
|
|
||||||
const [selectedAnalogSensor, setSelectedAnalogSensor] = useState<AnalogSensor>();
|
|
||||||
const [temperatureDialogOpen, setTemperatureDialogOpen] = useState<boolean>(false);
|
|
||||||
const [analogDialogOpen, setAnalogDialogOpen] = useState<boolean>(false);
|
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const { data: sensorData, send: fetchSensorData } = useRequest(
|
|
||||||
() => readSensorData(),
|
|
||||||
{
|
|
||||||
initialData: {
|
|
||||||
ts: [],
|
|
||||||
as: [],
|
|
||||||
analog_enabled: false,
|
|
||||||
platform: 'ESP32'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { send: sendTemperatureSensor } = useRequest(
|
|
||||||
(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 +82,64 @@ 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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||||
|
void fetchSensorData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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 +147,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 },
|
||||||
@@ -245,7 +189,8 @@ const Sensors = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = (duration_min: number) => {
|
const formatDurationMin = useCallback(
|
||||||
|
(duration_min: number) => {
|
||||||
const days = Math.trunc((duration_min * 60000) / 86400000);
|
const days = Math.trunc((duration_min * 60000) / 86400000);
|
||||||
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
const hours = Math.trunc((duration_min * 60000) / 3600000) % 24;
|
||||||
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
const minutes = Math.trunc((duration_min * 60000) / 60000) % 60;
|
||||||
@@ -261,9 +206,12 @@ const Sensors = () => {
|
|||||||
formatted += LL.NUM_MINUTES({ num: minutes });
|
formatted += LL.NUM_MINUTES({ num: minutes });
|
||||||
}
|
}
|
||||||
return formatted;
|
return formatted;
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
function formatValue(value: unknown, uom: DeviceValueUOM) {
|
const formatValue = useCallback(
|
||||||
|
(value: unknown, uom: DeviceValueUOM) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -292,22 +240,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,23 +274,28 @@ 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() * (Math.floor(200) - 100) + 100),
|
||||||
@@ -351,9 +310,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 +335,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}
|
||||||
@@ -423,7 +386,9 @@ const Sensors = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
style={{ fontSize: '14px', justifyContent: 'flex-end' }}
|
style={{ fontSize: '14px', justifyContent: 'flex-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>
|
||||||
@@ -449,14 +414,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={{ fontSize: '14px', justifyContent: 'flex-start' }}
|
||||||
|
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||||
|
onClick={() =>
|
||||||
|
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.NAME(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell stiff>
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
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 +509,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}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -50,6 +50,42 @@ const SensorsAnalogDialog = ({
|
|||||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = updateValue(setEditItem);
|
||||||
|
|
||||||
|
// Helper functions to check sensor type conditions
|
||||||
|
const isCounterOrRate =
|
||||||
|
editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE;
|
||||||
|
const isFreqType =
|
||||||
|
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
||||||
|
const isPWM =
|
||||||
|
editItem.t === AnalogType.PWM_0 ||
|
||||||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
|
editItem.t === AnalogType.PWM_2;
|
||||||
|
const isDigitalOutGPIO =
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
|
(editItem.g === 25 || editItem.g === 26);
|
||||||
|
const isDigitalOutNonGPIO =
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -57,16 +93,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,12 +110,12 @@ 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;
|
editItem.d = true;
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
};
|
}, [editItem, onSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
@@ -128,16 +164,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 +177,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 +252,7 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && (
|
{isCounterOrRate && (
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
name="f"
|
name="f"
|
||||||
@@ -242,8 +268,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 +284,7 @@ const SensorsAnalogDialog = ({
|
|||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
{editItem.t === AnalogType.DIGITAL_OUT &&
|
{isDigitalOutNonGPIO && (
|
||||||
editItem.g !== 25 &&
|
|
||||||
editItem.g !== 26 && (
|
|
||||||
<>
|
<>
|
||||||
<Grid>
|
<Grid>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -309,9 +332,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
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, 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,16 +52,16 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [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);
|
||||||
@@ -69,7 +69,7 @@ const SensorsTemperatureDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors(error as ValidateFieldsError);
|
setFieldErrors(error as ValidateFieldsError);
|
||||||
}
|
}
|
||||||
};
|
}, [validator, editItem, onSave]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ 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 {
|
||||||
|
|||||||
@@ -11,223 +11,148 @@ import type {
|
|||||||
TemperatureSensor
|
TemperatureSensor
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const GPIO_VALIDATOR = {
|
// 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('Must be an valid GPIO port');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const range of invalidRanges) {
|
||||||
|
if (typeof range === 'number') {
|
||||||
|
if (value === range) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [start, end] = range;
|
||||||
|
if (value >= start && value <= end) {
|
||||||
|
callback('Must be an valid GPIO port');
|
||||||
|
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', '');
|
||||||
|
acc[field] = [
|
||||||
|
{ required: true, message: `${fieldName.toUpperCase()} 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', min: 0, max: 65535, message: 'Invalid Port' }
|
||||||
],
|
];
|
||||||
syslog_mark_interval: [
|
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: 0, max: 10, message: 'Must be between 0 and 10' }
|
||||||
]
|
];
|
||||||
}),
|
}
|
||||||
...(settings.modbus_enabled && {
|
|
||||||
modbus_max_clients: [
|
// 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', min: 0, max: 50, message: 'Invalid number' }
|
||||||
],
|
];
|
||||||
modbus_port: [
|
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', min: 0, max: 65535, message: 'Invalid Port' }
|
||||||
],
|
];
|
||||||
modbus_timeout: [
|
schema.modbus_timeout = [
|
||||||
{ required: true, message: 'Timeout is required' },
|
{ required: true, message: 'Timeout is required' },
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -235,49 +160,61 @@ export const createSettingsValidator = (settings: Settings) =>
|
|||||||
max: 20000,
|
max: 20000,
|
||||||
message: 'Must be between 100 and 20000'
|
message: 'Must be between 100 and 20000'
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
}),
|
}
|
||||||
...(settings.shower_timer && {
|
|
||||||
shower_min_duration: [
|
// Shower timer validations
|
||||||
|
if (settings.shower_timer) {
|
||||||
|
schema.shower_min_duration = [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
min: 10,
|
min: 10,
|
||||||
max: 360,
|
max: 360,
|
||||||
message: 'Time must be between 10 and 360 seconds'
|
message: 'Time must be between 10 and 360 seconds'
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
}),
|
}
|
||||||
...(settings.shower_alert && {
|
|
||||||
shower_alert_trigger: [
|
// Shower alert validations
|
||||||
|
if (settings.shower_alert) {
|
||||||
|
schema.shower_alert_trigger = [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 20,
|
max: 20,
|
||||||
message: 'Time must be between 1 and 20 minutes'
|
message: 'Time must be between 1 and 20 minutes'
|
||||||
}
|
}
|
||||||
],
|
];
|
||||||
shower_alert_coldshot: [
|
schema.shower_alert_coldshot = [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 10,
|
max: 10,
|
||||||
message: 'Time must be between 1 and 10 seconds'
|
message: 'Time must be between 1 and 10 seconds'
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
}),
|
}
|
||||||
...(settings.remote_timeout_en && {
|
|
||||||
remote_timeout: [
|
// Remote timeout validations
|
||||||
|
if (settings.remote_timeout_en) {
|
||||||
|
schema.remote_timeout = [
|
||||||
{
|
{
|
||||||
type: 'number',
|
type: 'number',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 240,
|
max: 240,
|
||||||
message: 'Timeout must be between 1 and 240 hours'
|
message: 'Timeout must be between 1 and 240 hours'
|
||||||
}
|
}
|
||||||
]
|
];
|
||||||
})
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({
|
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,8 +222,9 @@ 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('Name already in use');
|
||||||
} else {
|
} else {
|
||||||
@@ -295,19 +233,51 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Generic field name validator (for cases where the name field has different property names)
|
||||||
|
const createUniqueFieldNameValidator = <T>(
|
||||||
|
items: T[],
|
||||||
|
getName: (item: T) => string,
|
||||||
|
originalName?: string
|
||||||
|
) => ({
|
||||||
|
validator(
|
||||||
|
_rule: InternalRuleItem,
|
||||||
|
name: string,
|
||||||
|
callback: (error?: string) => void
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
name !== '' &&
|
||||||
|
(originalName === undefined ||
|
||||||
|
originalName.toLowerCase() !== name.toLowerCase()) &&
|
||||||
|
items.find((item) => getName(item).toLowerCase() === name.toLowerCase())
|
||||||
|
) {
|
||||||
|
callback('Name already in use');
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const NAME_PATTERN = {
|
||||||
|
type: 'string' as const,
|
||||||
|
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
||||||
|
message: "Must be <20 characters: alphanumeric or '_'"
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAME_PATTERN_REQUIRED = {
|
||||||
|
type: 'string' as const,
|
||||||
|
pattern: /^[a-zA-Z0-9_]{1,19}$/,
|
||||||
|
message: "Must be <20 characters: alphanumeric or '_'"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) =>
|
||||||
|
createUniqueNameValidator(schedule, o_name);
|
||||||
|
|
||||||
export const schedulerItemValidation = (
|
export const schedulerItemValidation = (
|
||||||
schedule: ScheduleItem[],
|
schedule: ScheduleItem[],
|
||||||
scheduleItem: ScheduleItem
|
scheduleItem: ScheduleItem
|
||||||
) =>
|
) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
name: [
|
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
|
||||||
message: "Must be <20 characters: alphanumeric or '_'"
|
|
||||||
},
|
|
||||||
...[uniqueNameValidator(schedule, scheduleItem.o_name)]
|
|
||||||
],
|
|
||||||
cmd: [
|
cmd: [
|
||||||
{ required: true, message: 'Command is required' },
|
{ required: true, message: 'Command is required' },
|
||||||
{
|
{
|
||||||
@@ -319,65 +289,32 @@ export const schedulerItemValidation = (
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
export const uniqueCustomNameValidator = (
|
export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) =>
|
||||||
entity: EntityItem[],
|
createUniqueNameValidator(entity, o_name);
|
||||||
o_name?: string
|
|
||||||
) => ({
|
const hexValidator = {
|
||||||
validator(
|
validator(
|
||||||
_rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
name: string,
|
value: string,
|
||||||
callback: (error?: string) => void
|
callback: (error?: string) => void
|
||||||
) {
|
) {
|
||||||
if (
|
if (!value || isNaN(parseInt(value, 16))) {
|
||||||
(o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) &&
|
callback('Is required and must be in hex format');
|
||||||
entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase())
|
|
||||||
) {
|
|
||||||
callback('Name already in use');
|
|
||||||
} else {
|
} else {
|
||||||
callback();
|
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: 0, max: 255, message: 'Must be between 0 and 255' }
|
||||||
@@ -388,33 +325,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[]) => ({
|
||||||
@@ -434,47 +352,32 @@ export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({
|
|||||||
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 (
|
|
||||||
(o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) &&
|
|
||||||
n !== '' &&
|
|
||||||
sensors.find((as) => as.n.toLowerCase() === n.toLowerCase())
|
|
||||||
) {
|
|
||||||
callback('Name already in use');
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 =
|
||||||
n: [
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
pattern: /^[a-zA-Z0-9_]{0,19}$/,
|
|
||||||
message: "Must be <20 characters: alphanumeric or '_'"
|
|
||||||
},
|
|
||||||
...[uniqueAnalogNameValidator(sensors, sensor.o_n)]
|
|
||||||
],
|
|
||||||
g: [
|
|
||||||
{ required: true, message: 'GPIO is required' },
|
|
||||||
platform === 'ESP32S3'
|
platform === 'ESP32S3'
|
||||||
? GPIO_VALIDATORS3
|
? GPIO_VALIDATORS3
|
||||||
: platform === 'ESP32S2'
|
: platform === 'ESP32S2'
|
||||||
? GPIO_VALIDATORS2
|
? GPIO_VALIDATORS2
|
||||||
: platform === 'ESP32C3'
|
: platform === 'ESP32C3'
|
||||||
? GPIO_VALIDATORC3
|
? GPIO_VALIDATORC3
|
||||||
: GPIO_VALIDATOR,
|
: GPIO_VALIDATOR;
|
||||||
|
|
||||||
|
return new Schema({
|
||||||
|
n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)],
|
||||||
|
g: [
|
||||||
|
{ required: true, message: 'GPIO is required' },
|
||||||
|
gpioValidator,
|
||||||
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
|
...(creating ? [isGPIOUniqueValidator(sensors)] : [])
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
export const deviceValueItemValidation = (dv: DeviceValue) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
@@ -488,14 +391,15 @@ 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('Value out of range');
|
||||||
}
|
} else {
|
||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()}…
|
{LL.CUSTOM()}…
|
||||||
|
|||||||
@@ -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,82 @@ 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 (
|
||||||
|
<SectionContent>
|
||||||
|
<SystemMonitor />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +134,33 @@ 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>
|
||||||
|
|
||||||
|
{standaloneButton && (
|
||||||
<Button
|
<Button
|
||||||
sx={{ ml: 2, mt: 2 }}
|
sx={{ 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 +171,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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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()} (0=auto)
|
{LL.MQTT_PUBLISH_INTERVALS()} (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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
@@ -405,4 +404,4 @@ const NetworkSettings = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkSettings;
|
export default memo(NetworkSettings);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
@@ -197,7 +212,7 @@ const ManageUsers = () => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
disabled={!authenticatedContext.me.admin}
|
disabled={!authenticatedContext.me.admin}
|
||||||
onClick={() => generateToken(u.username)}
|
onClick={() => generateTokenForUser(u.username)}
|
||||||
>
|
>
|
||||||
<VpnKeyIcon />
|
<VpnKeyIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -286,4 +301,4 @@ const ManageUsers = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ManageUsers;
|
export default memo(ManageUsers);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
@@ -76,51 +98,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 +127,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 +159,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 +178,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 +191,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 +219,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 +238,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 +253,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 +307,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 +317,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 +335,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 +362,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 +380,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 +394,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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, 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 +31,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 +49,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 +68,36 @@ const levelLabel = (level: LogLevel) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memoized log entry component to prevent unnecessary re-renders
|
||||||
|
const LogEntryItem = memo(
|
||||||
|
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
|
||||||
|
const paddedLevelLabel = (level: LogLevel) => {
|
||||||
|
const label = levelLabel(level);
|
||||||
|
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
|
||||||
|
};
|
||||||
|
|
||||||
|
const paddedNameLabel = (name: string) => {
|
||||||
|
const label = '[' + name + ']';
|
||||||
|
return compact ? label : label.padEnd(12, '\xa0');
|
||||||
|
};
|
||||||
|
|
||||||
|
const paddedIDLabel = (id: number) => {
|
||||||
|
const label = id + ':';
|
||||||
|
return compact ? label : label.padEnd(7, '\xa0');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
|
||||||
|
<span>{entry.t}</span>
|
||||||
|
<span>{paddedLevelLabel(entry.l)} </span>
|
||||||
|
<span>{paddedIDLabel(entry.i)} </span>
|
||||||
|
<span>{paddedNameLabel(entry.n)} </span>
|
||||||
|
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const SystemLog = () => {
|
const SystemLog = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
@@ -107,7 +134,7 @@ const SystemLog = () => {
|
|||||||
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
|
||||||
@@ -121,7 +148,13 @@ const SystemLog = () => {
|
|||||||
const rawData = message.data;
|
const rawData = message.data;
|
||||||
const logentry = JSON.parse(rawData) as LogEntry;
|
const logentry = JSON.parse(rawData) as LogEntry;
|
||||||
if (lastId < logentry.i) {
|
if (lastId < logentry.i) {
|
||||||
setLogEntries((log) => [...log, logentry]);
|
setLogEntries((log) => {
|
||||||
|
const newLog = [...log, logentry];
|
||||||
|
// Limit log entries to prevent memory issues
|
||||||
|
return newLog.length > MAX_LOG_ENTRIES
|
||||||
|
? newLog.slice(-MAX_LOG_ENTRIES)
|
||||||
|
: newLog;
|
||||||
|
});
|
||||||
setLastId(logentry.i);
|
setLastId(logentry.i);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -129,27 +162,11 @@ const SystemLog = () => {
|
|||||||
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,11 +176,11 @@ 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
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -174,9 +191,9 @@ const SystemLog = () => {
|
|||||||
block: 'end'
|
block: 'end'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [logEntries.length]);
|
}, [logEntries.length, autoscroll]);
|
||||||
|
|
||||||
const sendReadCommand = () => {
|
const sendReadCommand = useCallback(() => {
|
||||||
if (readValue === '') {
|
if (readValue === '') {
|
||||||
setReadOpen(!readOpen);
|
setReadOpen(!readOpen);
|
||||||
return;
|
return;
|
||||||
@@ -187,7 +204,17 @@ const SystemLog = () => {
|
|||||||
setReadOpen(false);
|
setReadOpen(false);
|
||||||
setReadValue('');
|
setReadValue('');
|
||||||
}
|
}
|
||||||
};
|
}, [readValue, readOpen, send]);
|
||||||
|
|
||||||
|
// Memoize box positioning to avoid recalculating on every render
|
||||||
|
const boxPosition = useMemo(() => {
|
||||||
|
const logWindow = document.getElementById('log-window');
|
||||||
|
if (!logWindow) {
|
||||||
|
return { top: 0, left: 0 };
|
||||||
|
}
|
||||||
|
const rect = logWindow.getBoundingClientRect();
|
||||||
|
return { top: rect.bottom, left: rect.left };
|
||||||
|
}, [data]); // Recalculate only when data changes (settings may affect layout)
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -332,21 +359,13 @@ const SystemLog = () => {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
right: 18,
|
right: 18,
|
||||||
bottom: 18,
|
bottom: 18,
|
||||||
left: () => leftOffset(),
|
left: boxPosition.left,
|
||||||
top: () => topOffset(),
|
top: boxPosition.top - 110,
|
||||||
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)} </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} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
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, Dialog, DialogContent, Typography } from '@mui/material';
|
||||||
@@ -17,11 +17,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 +30,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,13 +58,41 @@ 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}>
|
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
|
||||||
@@ -76,15 +104,7 @@ const SystemMonitor = () => {
|
|||||||
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,13 +125,9 @@ const SystemMonitor = () => {
|
|||||||
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
|
||||||
{LL.PLEASE_WAIT()}…
|
{LL.PLEASE_WAIT()}…
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,6 +19,4 @@ const ButtonRow = memo<BoxProps>(({ children, ...rest }) => (
|
|||||||
</Box>
|
</Box>
|
||||||
));
|
));
|
||||||
|
|
||||||
ButtonRow.displayName = 'ButtonRow';
|
|
||||||
|
|
||||||
export default ButtonRow;
|
export default ButtonRow;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,17 +19,54 @@ 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';
|
||||||
|
|
||||||
|
// Extract style to constant to prevent recreation
|
||||||
|
const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' };
|
||||||
|
|
||||||
|
// Define language options outside component to prevent recreation
|
||||||
|
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 LanguageSelector = () => {
|
||||||
const { setLocale, locale } = useContext(I18nContext);
|
const { setLocale, locale } = useContext(I18nContext);
|
||||||
|
|
||||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
target
|
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} />
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -38,52 +77,9 @@ const LanguageSelector = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
<MenuItem key="cz" value="cz">
|
{menuItems}
|
||||||
<img src={CZflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
CZ
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="de" value="de">
|
|
||||||
<img src={DEflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
DE
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="en" value="en">
|
|
||||||
<img src={GBflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
EN
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="fr" value="fr">
|
|
||||||
<img src={FRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
FR
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="it" value="it">
|
|
||||||
<img src={ITflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
IT
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="nl" value="nl">
|
|
||||||
<img src={NLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
NL
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="no" value="no">
|
|
||||||
<img src={NOflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
NO
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="pl" value="pl">
|
|
||||||
<img src={PLflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
PL
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="sk" value="sk">
|
|
||||||
<img src={SKflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
SK
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="sv" value="sv">
|
|
||||||
<img src={SVflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
SV
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem key="tr" value="tr">
|
|
||||||
<img src={TRflag} style={{ width: 16, verticalAlign: 'middle' }} />
|
|
||||||
TR
|
|
||||||
</MenuItem>
|
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LanguageSelector;
|
export default memo(LanguageSelector);
|
||||||
|
|||||||
@@ -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,7 @@ 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">
|
||||||
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
@@ -32,4 +36,4 @@ const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ValidatedPasswordField;
|
export default memo(ValidatedPasswordField);
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -28,4 +29,4 @@ const ValidatedTextField: FC<ValidatedTextFieldProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ValidatedTextField;
|
export default memo(ValidatedTextField);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,7 +71,7 @@ const LayoutMenu = () => {
|
|||||||
>
|
>
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
alignItems="flex-start"
|
alignItems="flex-start"
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={handleMenuToggle}
|
||||||
sx={{
|
sx={{
|
||||||
pt: 2.5,
|
pt: 2.5,
|
||||||
pb: menuOpen ? 0 : 2.5,
|
pb: menuOpen ? 0 : 2.5,
|
||||||
@@ -173,7 +180,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 +203,6 @@ const LayoutMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LayoutMenu = memo(LayoutMenuComponent);
|
||||||
|
|
||||||
export default LayoutMenu;
|
export default LayoutMenu;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,24 +2,43 @@ import { useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
const DEFAULT_DELAY = 3000;
|
const DEFAULT_DELAY = 3000;
|
||||||
|
|
||||||
// adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
|
/**
|
||||||
export const useInterval = (callback: () => void, delay: number = DEFAULT_DELAY) => {
|
* Custom hook for setting up an interval with proper cleanup
|
||||||
const intervalRef = useRef<number | null>(null);
|
* Adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/
|
||||||
const savedCallback = useRef<() => void>(callback);
|
*
|
||||||
|
* @param callback - Function to be called at each interval
|
||||||
|
* @param delay - Delay in milliseconds (default: 3000ms)
|
||||||
|
* @param immediate - If true, executes callback immediately on mount (default: false)
|
||||||
|
* @returns Reference to the interval ID
|
||||||
|
*/
|
||||||
|
export const useInterval = (
|
||||||
|
callback: () => void,
|
||||||
|
delay: number = DEFAULT_DELAY,
|
||||||
|
immediate = false
|
||||||
|
) => {
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const savedCallback = useRef(callback);
|
||||||
|
|
||||||
|
// Remember the latest callback without resetting the interval
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
savedCallback.current = callback;
|
savedCallback.current = callback;
|
||||||
}, [callback]);
|
}, [callback]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => savedCallback.current();
|
const tick = () => savedCallback.current();
|
||||||
intervalRef.current = window.setInterval(tick, delay);
|
|
||||||
|
// Execute immediately if requested
|
||||||
|
if (immediate) {
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(tick, delay);
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current !== null) {
|
if (intervalRef.current) {
|
||||||
window.clearInterval(intervalRef.current);
|
clearInterval(intervalRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [delay]);
|
}, [delay, immediate]);
|
||||||
|
|
||||||
return intervalRef;
|
return intervalRef;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,23 +4,38 @@ export const usePersistState = <T>(
|
|||||||
initial_value: T,
|
initial_value: T,
|
||||||
id: string
|
id: string
|
||||||
): [T, (new_state: T) => void] => {
|
): [T, (new_state: T) => void] => {
|
||||||
// Set initial value
|
// Set initial value - only computed once on mount
|
||||||
const _initial_value = useMemo(() => {
|
const _initial_value = useMemo(() => {
|
||||||
const local_storage_value_str = localStorage.getItem('state:' + id);
|
try {
|
||||||
|
const local_storage_value_str = localStorage.getItem(`state:${id}`);
|
||||||
// If there is a value stored in localStorage, use that
|
// If there is a value stored in localStorage, use that
|
||||||
if (local_storage_value_str) {
|
if (local_storage_value_str) {
|
||||||
return JSON.parse(local_storage_value_str) as T;
|
return JSON.parse(local_storage_value_str) as T;
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If parsing fails, fall back to initial_value
|
||||||
|
console.warn(
|
||||||
|
`Failed to parse localStorage value for key "state:${id}"`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
// Otherwise use initial_value that was passed to the function
|
// Otherwise use initial_value that was passed to the function
|
||||||
return initial_value;
|
return initial_value;
|
||||||
}, []);
|
}, [id]); // initial_value intentionally omitted - only read on first mount
|
||||||
|
|
||||||
const [state, setState] = useState(_initial_value);
|
const [state, setState] = useState(_initial_value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state_str = JSON.stringify(state); // Stringified state
|
try {
|
||||||
localStorage.setItem('state:' + id, state_str); // Set stringified state as item in localStorage
|
const state_str = JSON.stringify(state);
|
||||||
}, [state]);
|
localStorage.setItem(`state:${id}`, state_str);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to save state to localStorage for key "state:${id}"`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [state, id]);
|
||||||
|
|
||||||
return [state, setState];
|
return [state, setState];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -11,10 +11,12 @@ export interface RestRequestOptions<D> {
|
|||||||
update: (value: D) => Method<AlovaGenerics>;
|
update: (value: D) => Method<AlovaGenerics>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REBOOT_ERROR_MESSAGE = 'Reboot required';
|
||||||
|
|
||||||
export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
const [restartNeeded, setRestartNeeded] = useState<boolean>(false);
|
const [restartNeeded, setRestartNeeded] = useState(false);
|
||||||
const [origData, setOrigData] = useState<D>();
|
const [origData, setOrigData] = useState<D>();
|
||||||
const [dirtyFlags, setDirtyFlags] = useState<string[]>([]);
|
const [dirtyFlags, setDirtyFlags] = useState<string[]>([]);
|
||||||
const blocker = useBlocker(dirtyFlags.length !== 0);
|
const blocker = useBlocker(dirtyFlags.length !== 0);
|
||||||
@@ -35,47 +37,50 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
|||||||
setDirtyFlags([]);
|
setDirtyFlags([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize updateDataValue to prevent unnecessary re-renders
|
|
||||||
const updateDataValue = useCallback(
|
const updateDataValue = useCallback(
|
||||||
(new_data: D) => {
|
(new_data: D) => updateData({ data: new_data }),
|
||||||
updateData({ data: new_data });
|
|
||||||
},
|
|
||||||
[updateData]
|
[updateData]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize loadData to prevent unnecessary re-renders
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setDirtyFlags([]);
|
setDirtyFlags([]);
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
await readData().catch((error: Error) => {
|
try {
|
||||||
toast.error(error.message);
|
await readData();
|
||||||
setErrorMessage(error.message);
|
} catch (error) {
|
||||||
});
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(message);
|
||||||
|
setErrorMessage(message);
|
||||||
|
}
|
||||||
}, [readData]);
|
}, [readData]);
|
||||||
|
|
||||||
// Memoize saveData to prevent unnecessary re-renders
|
|
||||||
const saveData = useCallback(async () => {
|
const saveData = useCallback(async () => {
|
||||||
if (!data) {
|
if (!data) return;
|
||||||
return;
|
|
||||||
}
|
// Reset states before saving
|
||||||
setRestartNeeded(false);
|
setRestartNeeded(false);
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
setDirtyFlags([]);
|
setDirtyFlags([]);
|
||||||
setOrigData(data as D);
|
setOrigData(data as D);
|
||||||
await writeData(data as D).catch((error: Error) => {
|
|
||||||
if (error.message === 'Reboot required') {
|
try {
|
||||||
|
await writeData(data as D);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message === REBOOT_ERROR_MESSAGE) {
|
||||||
setRestartNeeded(true);
|
setRestartNeeded(true);
|
||||||
} else {
|
} else {
|
||||||
toast.error(error.message);
|
toast.error(message);
|
||||||
setErrorMessage(error.message);
|
setErrorMessage(message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}, [data, writeData]);
|
}, [data, writeData]);
|
||||||
|
|
||||||
return {
|
return useMemo(
|
||||||
|
() => ({
|
||||||
loadData,
|
loadData,
|
||||||
saveData,
|
saveData,
|
||||||
saving: saving as boolean,
|
saving: !!saving,
|
||||||
updateDataValue,
|
updateDataValue,
|
||||||
data: data as D,
|
data: data as D,
|
||||||
origData: origData as D,
|
origData: origData as D,
|
||||||
@@ -85,5 +90,18 @@ export const useRest = <D>({ read, update }: RestRequestOptions<D>) => {
|
|||||||
blocker,
|
blocker,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
restartNeeded
|
restartNeeded
|
||||||
} as const;
|
}),
|
||||||
|
[
|
||||||
|
loadData,
|
||||||
|
saveData,
|
||||||
|
saving,
|
||||||
|
updateDataValue,
|
||||||
|
data,
|
||||||
|
origData,
|
||||||
|
dirtyFlags,
|
||||||
|
blocker,
|
||||||
|
errorMessage,
|
||||||
|
restartNeeded
|
||||||
|
]
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,42 @@ import type { APSettingsType } from 'types';
|
|||||||
|
|
||||||
import { IP_ADDRESS_VALIDATOR } from './shared';
|
import { IP_ADDRESS_VALIDATOR } from './shared';
|
||||||
|
|
||||||
|
// Reusable validation rules
|
||||||
|
const IP_FIELD_RULE = (fieldName: string) => [
|
||||||
|
{ required: true, message: `${fieldName} is required` },
|
||||||
|
IP_ADDRESS_VALIDATOR
|
||||||
|
];
|
||||||
|
|
||||||
|
const SSID_RULES = [
|
||||||
|
{ required: true, message: 'Please provide an SSID' },
|
||||||
|
{ type: 'string' as const, max: 32, message: 'SSID must be 32 characters or less' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const PASSWORD_RULES = [
|
||||||
|
{ required: true, message: 'Please provide an access point password' },
|
||||||
|
{
|
||||||
|
type: 'string' as const,
|
||||||
|
min: 8,
|
||||||
|
max: 64,
|
||||||
|
message: 'Password must be 8-64 characters'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const CHANNEL_RULES = [
|
||||||
|
{ required: true, message: 'Please provide a network channel' },
|
||||||
|
{ type: 'number' as const, message: 'Channel must be between 1 and 14' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_CLIENTS_RULES = [
|
||||||
|
{ required: true, message: 'Please specify a value for max clients' },
|
||||||
|
{
|
||||||
|
type: 'number' as const,
|
||||||
|
min: 1,
|
||||||
|
max: 9,
|
||||||
|
message: 'Max clients must be between 1 and 9'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const createAPSettingsValidator = (apSettings: APSettingsType) =>
|
export const createAPSettingsValidator = (apSettings: APSettingsType) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
provision_mode: {
|
provision_mode: {
|
||||||
@@ -11,47 +47,12 @@ export const createAPSettingsValidator = (apSettings: APSettingsType) =>
|
|||||||
message: 'Please provide a provision mode'
|
message: 'Please provide a provision mode'
|
||||||
},
|
},
|
||||||
...(isAPEnabled(apSettings) && {
|
...(isAPEnabled(apSettings) && {
|
||||||
ssid: [
|
ssid: SSID_RULES,
|
||||||
{ required: true, message: 'Please provide an SSID' },
|
password: PASSWORD_RULES,
|
||||||
{
|
channel: CHANNEL_RULES,
|
||||||
type: 'string',
|
max_clients: MAX_CLIENTS_RULES,
|
||||||
max: 32,
|
local_ip: IP_FIELD_RULE('Local IP address'),
|
||||||
message: 'SSID must be 32 characters or less'
|
gateway_ip: IP_FIELD_RULE('Gateway IP address'),
|
||||||
}
|
subnet_mask: IP_FIELD_RULE('Subnet mask')
|
||||||
],
|
|
||||||
password: [
|
|
||||||
{ required: true, message: 'Please provide an access point password' },
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
min: 8,
|
|
||||||
max: 64,
|
|
||||||
message: 'Password must be 8-64 characters'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
channel: [
|
|
||||||
{ required: true, message: 'Please provide a network channel' },
|
|
||||||
{ type: 'number', message: 'Channel must be between 1 and 14' }
|
|
||||||
],
|
|
||||||
max_clients: [
|
|
||||||
{ required: true, message: 'Please specify a value for max clients' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: 1,
|
|
||||||
max: 9,
|
|
||||||
message: 'Max clients must be between 1 and 9'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
local_ip: [
|
|
||||||
{ required: true, message: 'Local IP address is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
],
|
|
||||||
gateway_ip: [
|
|
||||||
{ required: true, message: 'Gateway IP address is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
],
|
|
||||||
subnet_mask: [
|
|
||||||
{ required: true, message: 'Subnet mask is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,35 +3,57 @@ import type { MqttSettingsType } from 'types';
|
|||||||
|
|
||||||
import { IP_OR_HOSTNAME_VALIDATOR } from './shared';
|
import { IP_OR_HOSTNAME_VALIDATOR } from './shared';
|
||||||
|
|
||||||
|
// Constants for validation ranges
|
||||||
|
const PORT_MIN = 0;
|
||||||
|
const PORT_MAX = 65535;
|
||||||
|
const KEEP_ALIVE_MIN = 1;
|
||||||
|
const KEEP_ALIVE_MAX = 86400;
|
||||||
|
const HEARTBEAT_MIN = 10;
|
||||||
|
const HEARTBEAT_MAX = 86400;
|
||||||
|
|
||||||
|
// Reusable validator rules
|
||||||
|
const REQUIRED_HOST_VALIDATOR = [
|
||||||
|
{ required: true, message: 'Host is required' },
|
||||||
|
IP_OR_HOSTNAME_VALIDATOR
|
||||||
|
];
|
||||||
|
|
||||||
|
const REQUIRED_BASE_VALIDATOR = [{ required: true, message: 'Base is required' }];
|
||||||
|
|
||||||
|
const PORT_VALIDATOR = [
|
||||||
|
{ required: true, message: 'Port is required' },
|
||||||
|
{
|
||||||
|
type: 'number' as const,
|
||||||
|
min: PORT_MIN,
|
||||||
|
max: PORT_MAX,
|
||||||
|
message: `Port must be between ${PORT_MIN} and ${PORT_MAX}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const createNumberValidator = (fieldName: string, min: number, max: number) => [
|
||||||
|
{ required: true, message: `${fieldName} is required` },
|
||||||
|
{
|
||||||
|
type: 'number' as const,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
message: `${fieldName} must be between ${min} and ${max}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
export const createMqttSettingsValidator = (mqttSettings: MqttSettingsType) =>
|
export const createMqttSettingsValidator = (mqttSettings: MqttSettingsType) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
...(mqttSettings.enabled && {
|
...(mqttSettings.enabled && {
|
||||||
host: [
|
host: REQUIRED_HOST_VALIDATOR,
|
||||||
{ required: true, message: 'Host is required' },
|
base: REQUIRED_BASE_VALIDATOR,
|
||||||
IP_OR_HOSTNAME_VALIDATOR
|
port: PORT_VALIDATOR,
|
||||||
],
|
keep_alive: createNumberValidator(
|
||||||
base: { required: true, message: 'Base is required' },
|
'Keep alive',
|
||||||
port: [
|
KEEP_ALIVE_MIN,
|
||||||
{ required: true, message: 'Port is required' },
|
KEEP_ALIVE_MAX
|
||||||
{ type: 'number', min: 0, max: 65535, message: 'Invalid Port' }
|
),
|
||||||
],
|
publish_time_heartbeat: createNumberValidator(
|
||||||
keep_alive: [
|
'Heartbeat',
|
||||||
{ required: true, message: 'Keep alive is required' },
|
HEARTBEAT_MIN,
|
||||||
{
|
HEARTBEAT_MAX
|
||||||
type: 'number',
|
)
|
||||||
min: 1,
|
|
||||||
max: 86400,
|
|
||||||
message: 'Keep alive must be between 1 and 86400'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
publish_time_heartbeat: [
|
|
||||||
{ required: true, message: 'Heartbeat is required' },
|
|
||||||
{
|
|
||||||
type: 'number',
|
|
||||||
min: 10,
|
|
||||||
max: 86400,
|
|
||||||
message: 'Heartbeat must be between 10 and 86400'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,23 @@ import type { NetworkSettingsType } from 'types';
|
|||||||
|
|
||||||
import { HOSTNAME_VALIDATOR, IP_ADDRESS_VALIDATOR } from './shared';
|
import { HOSTNAME_VALIDATOR, IP_ADDRESS_VALIDATOR } from './shared';
|
||||||
|
|
||||||
|
// Reusable validator rules
|
||||||
|
const REQUIRED_IP_VALIDATOR = (fieldName: string) => [
|
||||||
|
{ required: true, message: `${fieldName} is required` },
|
||||||
|
IP_ADDRESS_VALIDATOR
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPTIONAL_IP_VALIDATOR = [IP_ADDRESS_VALIDATOR];
|
||||||
|
|
||||||
|
// Helper to create static IP validation rules
|
||||||
|
const createStaticIpRules = () => ({
|
||||||
|
local_ip: REQUIRED_IP_VALIDATOR('Local IP'),
|
||||||
|
gateway_ip: REQUIRED_IP_VALIDATOR('Gateway IP'),
|
||||||
|
subnet_mask: REQUIRED_IP_VALIDATOR('Subnet mask'),
|
||||||
|
dns_ip_1: OPTIONAL_IP_VALIDATOR,
|
||||||
|
dns_ip_2: OPTIONAL_IP_VALIDATOR
|
||||||
|
});
|
||||||
|
|
||||||
export const createNetworkSettingsValidator = (
|
export const createNetworkSettingsValidator = (
|
||||||
networkSettings: NetworkSettingsType
|
networkSettings: NetworkSettingsType
|
||||||
) =>
|
) =>
|
||||||
@@ -17,29 +34,16 @@ export const createNetworkSettingsValidator = (
|
|||||||
message: 'BSSID must be 17 characters or empty'
|
message: 'BSSID must be 17 characters or empty'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
password: {
|
password: [
|
||||||
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
max: 64,
|
max: 64,
|
||||||
message: 'Password must be 64 characters or less'
|
message: 'Password must be 64 characters or less'
|
||||||
},
|
}
|
||||||
|
],
|
||||||
hostname: [
|
hostname: [
|
||||||
{ required: true, message: 'Hostname is required' },
|
{ required: true, message: 'Hostname is required' },
|
||||||
HOSTNAME_VALIDATOR
|
HOSTNAME_VALIDATOR
|
||||||
],
|
],
|
||||||
...(networkSettings.static_ip_config && {
|
...(networkSettings.static_ip_config && createStaticIpRules())
|
||||||
local_ip: [
|
|
||||||
{ required: true, message: 'Local IP is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
],
|
|
||||||
gateway_ip: [
|
|
||||||
{ required: true, message: 'Gateway IP is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
],
|
|
||||||
subnet_mask: [
|
|
||||||
{ required: true, message: 'Subnet mask is required' },
|
|
||||||
IP_ADDRESS_VALIDATOR
|
|
||||||
],
|
|
||||||
dns_ip_1: IP_ADDRESS_VALIDATOR,
|
|
||||||
dns_ip_2: IP_ADDRESS_VALIDATOR
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,5 @@ export const NTP_SETTINGS_VALIDATOR = new Schema({
|
|||||||
{ required: true, message: 'Server is required' },
|
{ required: true, message: 'Server is required' },
|
||||||
IP_OR_HOSTNAME_VALIDATOR
|
IP_OR_HOSTNAME_VALIDATOR
|
||||||
],
|
],
|
||||||
tz_label: {
|
tz_label: [{ required: true, message: 'Time zone is required' }]
|
||||||
required: true,
|
|
||||||
message: 'Time zone is required'
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,25 +2,34 @@ import Schema from 'async-validator';
|
|||||||
import type { InternalRuleItem } from 'async-validator';
|
import type { InternalRuleItem } from 'async-validator';
|
||||||
import type { UserType } from 'types';
|
import type { UserType } from 'types';
|
||||||
|
|
||||||
|
const USERNAME_PATTERN = /^[a-zA-Z0-9_\\.]{1,24}$/;
|
||||||
|
const JWT_SECRET_MAX_LENGTH = 64;
|
||||||
|
const PASSWORD_MAX_LENGTH = 64;
|
||||||
|
|
||||||
export const SECURITY_SETTINGS_VALIDATOR = new Schema({
|
export const SECURITY_SETTINGS_VALIDATOR = new Schema({
|
||||||
jwt_secret: [
|
jwt_secret: [
|
||||||
{ required: true, message: 'JWT secret is required' },
|
{ required: true, message: 'JWT secret is required' },
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 64,
|
max: JWT_SECRET_MAX_LENGTH,
|
||||||
message: 'JWT secret must be between 1 and 64 characters'
|
message: `JWT secret must be between 1 and ${JWT_SECRET_MAX_LENGTH} characters`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a validator to ensure username uniqueness
|
||||||
|
* @param users - Array of existing users to check against
|
||||||
|
* @returns Validator rule for unique username
|
||||||
|
*/
|
||||||
export const createUniqueUsernameValidator = (users: UserType[]) => ({
|
export const createUniqueUsernameValidator = (users: UserType[]) => ({
|
||||||
validator(
|
validator(
|
||||||
rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
username: string,
|
username: string,
|
||||||
callback: (error?: string) => void
|
callback: (error?: string) => void
|
||||||
) {
|
) {
|
||||||
if (username && users.find((u) => u.username === username)) {
|
if (username && users.some((u) => u.username === username)) {
|
||||||
callback('Username already in use');
|
callback('Username already in use');
|
||||||
} else {
|
} else {
|
||||||
callback();
|
callback();
|
||||||
@@ -28,13 +37,19 @@ export const createUniqueUsernameValidator = (users: UserType[]) => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a validator schema for user creation/editing
|
||||||
|
* @param users - Array of existing users for uniqueness check
|
||||||
|
* @param creating - Whether this is for creating a new user (enables uniqueness check)
|
||||||
|
* @returns Schema validator for user data
|
||||||
|
*/
|
||||||
export const createUserValidator = (users: UserType[], creating: boolean) =>
|
export const createUserValidator = (users: UserType[], creating: boolean) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
username: [
|
username: [
|
||||||
{ required: true, message: 'Username is required' },
|
{ required: true, message: 'Username is required' },
|
||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
pattern: /^[a-zA-Z0-9_\\.]{1,24}$/,
|
pattern: USERNAME_PATTERN,
|
||||||
message: "Must be 1-24 characters: alphanumeric, '_' or '.'"
|
message: "Must be 1-24 characters: alphanumeric, '_' or '.'"
|
||||||
},
|
},
|
||||||
...(creating ? [createUniqueUsernameValidator(users)] : [])
|
...(creating ? [createUniqueUsernameValidator(users)] : [])
|
||||||
@@ -44,8 +59,8 @@ export const createUserValidator = (users: UserType[], creating: boolean) =>
|
|||||||
{
|
{
|
||||||
type: 'string',
|
type: 'string',
|
||||||
min: 1,
|
min: 1,
|
||||||
max: 64,
|
max: PASSWORD_MAX_LENGTH,
|
||||||
message: 'Password must be 1-64 characters'
|
message: `Password must be 1-${PASSWORD_MAX_LENGTH} characters`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,66 +7,54 @@ export const validate = <T extends object>(
|
|||||||
options?: ValidateOption
|
options?: ValidateOption
|
||||||
): Promise<T> =>
|
): Promise<T> =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
void validator.validate(source, options || {}, (errors, fieldErrors) => {
|
void validator.validate(source, options ?? {}, (errors, fieldErrors) => {
|
||||||
if (errors) {
|
errors ? reject(fieldErrors as Error) : resolve(source as T);
|
||||||
reject(fieldErrors as Error);
|
|
||||||
} else {
|
|
||||||
resolve(source as T);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// updated to support both IPv4 and IPv6
|
// IPv4 pattern: matches 0.0.0.0 to 255.255.255.255
|
||||||
const IP_ADDRESS_REGEXP =
|
const IPV4_PATTERN =
|
||||||
/((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9a-f]{1,4}:){7}([0-9a-f]{1,4}|:))|(([0-9a-f]{1,4}:){6}(:[0-9a-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){5}(((:[0-9a-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9a-f]{1,4}:){4}(((:[0-9a-f]{1,4}){1,3})|((:[0-9a-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){3}(((:[0-9a-f]{1,4}){1,4})|((:[0-9a-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){2}(((:[0-9a-f]{1,4}){1,5})|((:[0-9a-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9a-f]{1,4}:){1}(((:[0-9a-f]{1,4}){1,6})|((:[0-9a-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9a-f]{1,4}){1,7})|((:[0-9a-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/;
|
/^((25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
|
||||||
|
|
||||||
const isValidIpAddress = (value: string) => IP_ADDRESS_REGEXP.test(value);
|
// IPv6 pattern: matches full and compressed IPv6 addresses (including IPv4-mapped)
|
||||||
|
const IPV6_PATTERN =
|
||||||
|
/^(([0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,7}:|([0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|([0-9a-f]{1,4}:){1,5}(:[0-9a-f]{1,4}){1,2}|([0-9a-f]{1,4}:){1,4}(:[0-9a-f]{1,4}){1,3}|([0-9a-f]{1,4}:){1,3}(:[0-9a-f]{1,4}){1,4}|([0-9a-f]{1,4}:){1,2}(:[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:((:[0-9a-f]{1,4}){1,6})|:((:[0-9a-f]{1,4}){1,7}|:)|::)$/i;
|
||||||
|
|
||||||
export const IP_ADDRESS_VALIDATOR = {
|
// Hostname pattern: RFC 1123 compliant (max 200 chars)
|
||||||
|
const HOSTNAME_PATTERN =
|
||||||
|
/^(?=.{1,200}$)(([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])\.)*([a-z0-9]|[a-z0-9][a-z0-9-]*[a-z0-9])$/i;
|
||||||
|
|
||||||
|
const isValidIpAddress = (value: string): boolean =>
|
||||||
|
IPV4_PATTERN.test(value.trim()) || IPV6_PATTERN.test(value.trim());
|
||||||
|
|
||||||
|
const isValidHostname = (value: string): boolean =>
|
||||||
|
HOSTNAME_PATTERN.test(value.trim());
|
||||||
|
|
||||||
|
// Factory function to create validators with consistent structure
|
||||||
|
const createValidator = (
|
||||||
|
validatorFn: (value: string) => boolean,
|
||||||
|
errorMessage: string
|
||||||
|
) => ({
|
||||||
validator(
|
validator(
|
||||||
rule: InternalRuleItem,
|
_rule: InternalRuleItem,
|
||||||
value: string,
|
value: string,
|
||||||
callback: (error?: string) => void
|
callback: (error?: string) => void
|
||||||
) {
|
) {
|
||||||
if (value && !isValidIpAddress(value)) {
|
callback(value && !validatorFn(value) ? errorMessage : undefined);
|
||||||
callback('Must be an IP address');
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const HOSTNAME_LENGTH_REGEXP = /^.{0,200}$/;
|
export const IP_ADDRESS_VALIDATOR = createValidator(
|
||||||
const HOSTNAME_PATTERN_REGEXP =
|
isValidIpAddress,
|
||||||
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/;
|
'Must be an IP address'
|
||||||
|
);
|
||||||
|
|
||||||
const isValidHostname = (value: string) =>
|
export const HOSTNAME_VALIDATOR = createValidator(
|
||||||
HOSTNAME_LENGTH_REGEXP.test(value) && HOSTNAME_PATTERN_REGEXP.test(value);
|
isValidHostname,
|
||||||
|
'Must be a valid hostname'
|
||||||
|
);
|
||||||
|
|
||||||
export const HOSTNAME_VALIDATOR = {
|
export const IP_OR_HOSTNAME_VALIDATOR = createValidator(
|
||||||
validator(
|
(value) => isValidIpAddress(value) || isValidHostname(value),
|
||||||
rule: InternalRuleItem,
|
'Must be a valid IP address or hostname'
|
||||||
value: string,
|
);
|
||||||
callback: (error?: string) => void
|
|
||||||
) {
|
|
||||||
if (value && !isValidHostname(value)) {
|
|
||||||
callback('Must be a valid hostname');
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IP_OR_HOSTNAME_VALIDATOR = {
|
|
||||||
validator(
|
|
||||||
rule: InternalRuleItem,
|
|
||||||
value: string,
|
|
||||||
callback: (error?: string) => void
|
|
||||||
) {
|
|
||||||
if (value && !(isValidIpAddress(value) || isValidHostname(value))) {
|
|
||||||
callback('Must be a valid IP address or hostname');
|
|
||||||
} else {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -95,12 +95,19 @@ export default defineConfig(
|
|||||||
preact({
|
preact({
|
||||||
// Keep dev tools enabled for development
|
// Keep dev tools enabled for development
|
||||||
devToolsEnabled: true,
|
devToolsEnabled: true,
|
||||||
prefreshEnabled: true
|
prefreshEnabled: false
|
||||||
}),
|
}),
|
||||||
viteTsconfigPaths(),
|
viteTsconfigPaths(),
|
||||||
bundleSizeReporter(), // Add bundle size reporting
|
bundleSizeReporter(), // Add bundle size reporting
|
||||||
mockServer()
|
mockServer()
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
|
'react/jsx-runtime': 'preact/jsx-runtime'
|
||||||
|
}
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
open: true,
|
open: true,
|
||||||
port: mode == 'production' ? 4173 : 3000,
|
port: mode == 'production' ? 4173 : 3000,
|
||||||
@@ -135,6 +142,13 @@ export default defineConfig(
|
|||||||
viteTsconfigPaths(),
|
viteTsconfigPaths(),
|
||||||
bundleSizeReporter() // Add bundle size reporting
|
bundleSizeReporter() // Add bundle size reporting
|
||||||
],
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
|
'react/jsx-runtime': 'preact/jsx-runtime'
|
||||||
|
}
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
chunkSizeWarningLimit: 512,
|
chunkSizeWarningLimit: 512,
|
||||||
@@ -226,6 +240,14 @@ export default defineConfig(
|
|||||||
bundleSizeReporter() // Add bundle size reporting
|
bundleSizeReporter() // Add bundle size reporting
|
||||||
],
|
],
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
|
'react/jsx-runtime': 'preact/jsx-runtime'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
// Target modern browsers for smaller bundles
|
// Target modern browsers for smaller bundles
|
||||||
target: 'es2020',
|
target: 'es2020',
|
||||||
|
|||||||
Reference in New Issue
Block a user