diff --git a/CHANGELOG_LATEST.md b/CHANGELOG_LATEST.md index 6c9a6373c..e58094e3d 100644 --- a/CHANGELOG_LATEST.md +++ b/CHANGELOG_LATEST.md @@ -57,3 +57,4 @@ For more details go to [docs.emsesp.org](https://docs.emsesp.org/). - updated core libraries like AsyncTCP, AsyncWebServer and Modbus - remove command `scan deep` - ignore repeated `forceheatingoff` commands [#2641](https://github.com/emsesp/EMS-ESP32/discussions/2641) +- optimized web for performance diff --git a/interface/package.json b/interface/package.json index 5d78beecd..5ddce35a0 100644 --- a/interface/package.json +++ b/interface/package.json @@ -13,11 +13,11 @@ "build": "vite build", "preview": "vite preview", "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", - "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", - "webUI": "node progmem-generator.js", + "webUI": "vite build && node progmem-generator.js", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", "lint": "eslint . --fix", "standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\"" @@ -26,11 +26,13 @@ "@alova/adapter-xhr": "2.2.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@mui/icons-material": "^7.3.4", - "@mui/material": "^7.3.4", + "@mui/icons-material": "^7.3.5", + "@mui/material": "^7.3.5", + "@preact/compat": "^18.3.1", "@table-library/react-table-library": "4.1.15", "alova": "3.3.4", "async-validator": "^4.2.5", + "etag": "^1.8.1", "formidable": "^3.5.4", "jwt-decode": "^4.0.0", "magic-string": "^0.30.21", @@ -46,23 +48,24 @@ }, "devDependencies": { "@babel/core": "^7.28.5", - "@eslint/js": "^9.39.0", + "@eslint/js": "^9.39.1", "@preact/compat": "^18.3.1", "@preact/preset-vite": "^2.10.2", - "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@trivago/prettier-plugin-sort-imports": "^6.0.0", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", + "axe-core": "^4.11.0", "concurrently": "^9.2.1", - "eslint": "^9.39.0", + "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", "prettier": "^3.6.2", "rollup-plugin-visualizer": "^6.0.5", - "terser": "^5.44.0", - "typescript-eslint": "^8.46.2", - "vite": "^7.1.12", + "terser": "^5.44.1", + "typescript-eslint": "^8.46.3", + "vite": "^7.2.0", "vite-plugin-imagemin": "^0.6.1", "vite-tsconfig-paths": "^5.1.4" }, - "packageManager": "pnpm@10.20.0" + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 0af8f970c..c177c8343 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -18,11 +18,14 @@ importers: specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@mui/icons-material': - specifier: ^7.3.4 - version: 7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + specifier: ^7.3.5 + version: 7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@mui/material': - 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) + specifier: ^7.3.5 + version: 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@preact/compat': + specifier: ^18.3.1 + version: 18.3.1(preact@10.27.2) '@table-library/react-table-library': 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) @@ -32,6 +35,9 @@ importers: async-validator: specifier: ^4.2.5 version: 4.2.5 + etag: + specifier: ^1.8.1 + version: 1.8.1 formidable: specifier: ^3.5.4 version: 3.5.4 @@ -73,17 +79,14 @@ importers: specifier: ^7.28.5 version: 7.28.5 '@eslint/js': - specifier: ^9.39.0 - version: 9.39.0 - '@preact/compat': - specifier: ^18.3.1 - version: 18.3.1(preact@10.27.2) + specifier: ^9.39.1 + version: 9.39.1 '@preact/preset-vite': specifier: ^2.10.2 - version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) + version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)) '@trivago/prettier-plugin-sort-imports': - specifier: ^5.2.2 - version: 5.2.2(prettier@3.6.2) + specifier: ^6.0.0 + version: 6.0.0(prettier@3.6.2) '@types/node': specifier: ^24.10.0 version: 24.10.0 @@ -93,15 +96,18 @@ importers: '@types/react-dom': specifier: ^19.2.2 version: 19.2.2(@types/react@19.2.2) + axe-core: + specifier: ^4.11.0 + version: 4.11.0 concurrently: specifier: ^9.2.1 version: 9.2.1 eslint: - specifier: ^9.39.0 - version: 9.39.0 + specifier: ^9.39.1 + version: 9.39.1 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@9.39.0) + version: 10.1.8(eslint@9.39.1) prettier: specifier: ^3.6.2 version: 3.6.2 @@ -109,20 +115,20 @@ importers: specifier: ^6.0.5 version: 6.0.5(rollup@4.52.5) terser: - specifier: ^5.44.0 - version: 5.44.0 + specifier: ^5.44.1 + version: 5.44.1 typescript-eslint: - specifier: ^8.46.2 - version: 8.46.2(eslint@9.39.0)(typescript@5.9.3) + specifier: ^8.46.3 + version: 8.46.3(eslint@9.39.1)(typescript@5.9.3) vite: - specifier: ^7.1.12 - version: 7.1.12(@types/node@24.10.0)(terser@5.44.0) + specifier: ^7.2.0 + version: 7.2.0(@types/node@24.10.0)(terser@5.44.1) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) + version: 0.6.1(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) + version: 5.1.4(typescript@5.9.3)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)) packages: @@ -473,8 +479,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.0': - resolution: {integrity: sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==} + '@eslint/js@9.39.1': + resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -528,27 +534,27 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mui/core-downloads-tracker@7.3.4': - resolution: {integrity: sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==} + '@mui/core-downloads-tracker@7.3.5': + resolution: {integrity: sha512-kOLwlcDPnVz2QMhiBv0OQ8le8hTCqKM9cRXlfVPL91l3RGeOsxrIhNRsUt3Xb8wb+pTVUolW+JXKym93vRKxCw==} - '@mui/icons-material@7.3.4': - resolution: {integrity: sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==} + '@mui/icons-material@7.3.5': + resolution: {integrity: sha512-LciL1GLMZ+VlzyHAALSVAR22t8IST4LCXmljcUSx2NOutgO2XnxdIp8ilFbeNf9wpo0iUFbAuoQcB7h+HHIf3A==} engines: {node: '>=14.0.0'} peerDependencies: - '@mui/material': ^7.3.4 + '@mui/material': ^7.3.5 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/material@7.3.4': - resolution: {integrity: sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==} + '@mui/material@7.3.5': + resolution: {integrity: sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 - '@mui/material-pigment-css': ^7.3.3 + '@mui/material-pigment-css': ^7.3.5 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -562,8 +568,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@7.3.3': - resolution: {integrity: sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==} + '@mui/private-theming@7.3.5': + resolution: {integrity: sha512-cTx584W2qrLonwhZLbEN7P5pAUu0nZblg8cLBlTrZQ4sIiw8Fbvg7GvuphQaSHxPxrCpa7FDwJKtXdbl2TSmrA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -572,8 +578,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@7.3.3': - resolution: {integrity: sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==} + '@mui/styled-engine@7.3.5': + resolution: {integrity: sha512-zbsZ0uYYPndFCCPp2+V3RLcAN6+fv4C8pdwRx6OS3BwDkRCN8WBehqks7hWyF3vj1kdQLIWrpdv/5Y0jHRxYXQ==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -585,8 +591,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@7.3.3': - resolution: {integrity: sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==} + '@mui/system@7.3.5': + resolution: {integrity: sha512-yPaf5+gY3v80HNkJcPi6WT+r9ebeM4eJzrREXPxMt7pNTV/1eahyODO4fbH3Qvd8irNxDFYn5RQ3idHW55rA6g==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -601,16 +607,16 @@ packages: '@types/react': optional: true - '@mui/types@7.4.7': - resolution: {integrity: sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==} + '@mui/types@7.4.8': + resolution: {integrity: sha512-ZNXLBjkPV6ftLCmmRCafak3XmSn8YV0tKE/ZOhzKys7TZXUiE0mZxlH8zKDo6j6TTUaDnuij68gIG+0Ucm7Xhw==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true - '@mui/utils@7.3.3': - resolution: {integrity: sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==} + '@mui/utils@7.3.5': + resolution: {integrity: sha512-jisvFsEC3sgjUjcPnR4mYfhzjCDIudttSGSbe1o/IXFNu0kZuR+7vqQI0jg8qtcVZBHWrwTfvAZj9MNMumcq1g==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -794,17 +800,20 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@trivago/prettier-plugin-sort-imports@5.2.2': - resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} - engines: {node: '>18.12'} + '@trivago/prettier-plugin-sort-imports@6.0.0': + resolution: {integrity: sha512-Xarx55ow0R8oC7ViL5fPmDsg1EBa1dVhyZFVbFXNtPPJyW2w9bJADIla8YFSaNG9N06XfcklA9O9vmw4noNxkQ==} + engines: {node: '>= 20'} peerDependencies: '@vue/compiler-sfc': 3.x prettier: 2.x - 3.x + prettier-plugin-ember-template-tag: '>= 2.0.0' prettier-plugin-svelte: 3.x svelte: 4.x || 5.x peerDependenciesMeta: '@vue/compiler-sfc': optional: true + prettier-plugin-ember-template-tag: + optional: true prettier-plugin-svelte: optional: true svelte: @@ -879,63 +888,63 @@ packages: '@types/svgo@2.6.4': resolution: {integrity: sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==} - '@typescript-eslint/eslint-plugin@8.46.2': - resolution: {integrity: sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==} + '@typescript-eslint/eslint-plugin@8.46.3': + resolution: {integrity: sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.46.2 + '@typescript-eslint/parser': ^8.46.3 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.46.2': - resolution: {integrity: sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==} + '@typescript-eslint/parser@8.46.3': + resolution: {integrity: sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.46.2': - resolution: {integrity: sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==} + '@typescript-eslint/project-service@8.46.3': + resolution: {integrity: sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.46.2': - resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==} + '@typescript-eslint/scope-manager@8.46.3': + resolution: {integrity: sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.46.2': - resolution: {integrity: sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==} + '@typescript-eslint/tsconfig-utils@8.46.3': + resolution: {integrity: sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.46.2': - resolution: {integrity: sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==} + '@typescript-eslint/type-utils@8.46.3': + resolution: {integrity: sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.46.2': - resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==} + '@typescript-eslint/types@8.46.3': + resolution: {integrity: sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.46.2': - resolution: {integrity: sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==} + '@typescript-eslint/typescript-estree@8.46.3': + resolution: {integrity: sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.2': - resolution: {integrity: sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==} + '@typescript-eslint/utils@8.46.3': + resolution: {integrity: sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.46.2': - resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==} + '@typescript-eslint/visitor-keys@8.46.3': + resolution: {integrity: sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} acorn-jsx@5.3.2: @@ -999,6 +1008,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.11.0: + resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + engines: {node: '>=4'} + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -1014,8 +1027,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.8.23: - resolution: {integrity: sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==} + baseline-browser-mapping@2.8.24: + resolution: {integrity: sha512-uUhTRDPXamakPyghwrUcjaGvvBqGrWvBHReoiULMIpOJVM9IYzQh83Xk2Onx5HlGI2o10NNCzcs9TG/S3TkwrQ==} hasBin: true bin-build@3.0.0: @@ -1324,8 +1337,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.244: - resolution: {integrity: sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==} + electron-to-chromium@1.5.245: + resolution: {integrity: sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1515,8 +1528,8 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.0: - resolution: {integrity: sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==} + eslint@9.39.1: + resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -1548,6 +1561,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + exec-buffer@3.2.0: resolution: {integrity: sha512-wsiD+2Tp6BWHoVv3B+5Dcx6E7u5zky+hUwOHjuH2hKSLR3dvRmX8fk8UD8uqQixHs4Wk6eDmiegVrMPjKj7wpA==} engines: {node: '>=4'} @@ -2105,12 +2122,12 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - logalot@2.1.0: resolution: {integrity: sha512-Ah4CgdSRfeCJagxQhcVNMi9BfGYyEKLa6d7OA6xSbld/Hg3Cf2QiOa1mDpmG7Ve8LOH6DN3mdttzjQAvWTyVkw==} engines: {node: '>=0.10.0'} @@ -2351,6 +2368,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + parse-json@2.2.0: resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==} engines: {node: '>=0.10.0'} @@ -2359,6 +2379,9 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + path-exists@2.1.0: resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==} engines: {node: '>=0.10.0'} @@ -2828,8 +2851,8 @@ packages: resolution: {integrity: sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==} engines: {node: '>=4'} - terser@5.44.0: - resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + terser@5.44.1: + resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} engines: {node: '>=10'} hasBin: true @@ -2904,8 +2927,8 @@ packages: peerDependencies: typescript: '>=3.5.1' - typescript-eslint@8.46.2: - resolution: {integrity: sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==} + typescript-eslint@8.46.3: + resolution: {integrity: sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2976,8 +2999,8 @@ packages: vite: optional: true - vite@7.1.12: - resolution: {integrity: sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==} + vite@7.2.0: + resolution: {integrity: sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3377,9 +3400,9 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.0)': + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1)': dependencies: - eslint: 9.39.0 + eslint: 9.39.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3414,7 +3437,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.39.0': {} + '@eslint/js@9.39.1': {} '@eslint/object-schema@2.1.7': {} @@ -3464,23 +3487,23 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mui/core-downloads-tracker@7.3.4': {} + '@mui/core-downloads-tracker@7.3.5': {} - '@mui/icons-material@7.3.4(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': + '@mui/icons-material@7.3.5(@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mui/material': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 optionalDependencies: '@types/react': 19.2.2 - '@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@mui/material@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/core-downloads-tracker': 7.3.4 - '@mui/system': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@mui/types': 7.4.7(@types/react@19.2.2) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/core-downloads-tracker': 7.3.5 + '@mui/system': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) + '@mui/types': 7.4.8(@types/react@19.2.2) + '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@19.2.2) clsx: 2.1.1 @@ -3495,16 +3518,16 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@types/react': 19.2.2 - '@mui/private-theming@7.3.3(@types/react@19.2.2)(react@19.2.0)': + '@mui/private-theming@7.3.5(@types/react@19.2.2)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0) prop-types: 15.8.1 react: 19.2.0 optionalDependencies: '@types/react': 19.2.2 - '@mui/styled-engine@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)': + '@mui/styled-engine@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 '@emotion/cache': 11.14.0 @@ -3517,13 +3540,13 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.2)(react@19.2.0) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) - '@mui/system@7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': + '@mui/system@7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/private-theming': 7.3.3(@types/react@19.2.2)(react@19.2.0) - '@mui/styled-engine': 7.3.3(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) - '@mui/types': 7.4.7(@types/react@19.2.2) - '@mui/utils': 7.3.3(@types/react@19.2.2)(react@19.2.0) + '@mui/private-theming': 7.3.5(@types/react@19.2.2)(react@19.2.0) + '@mui/styled-engine': 7.3.5(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) + '@mui/types': 7.4.8(@types/react@19.2.2) + '@mui/utils': 7.3.5(@types/react@19.2.2)(react@19.2.0) clsx: 2.1.1 csstype: 3.1.3 prop-types: 15.8.1 @@ -3533,16 +3556,16 @@ snapshots: '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.0))(@types/react@19.2.2)(react@19.2.0) '@types/react': 19.2.2 - '@mui/types@7.4.7(@types/react@19.2.2)': + '@mui/types@7.4.8(@types/react@19.2.2)': dependencies: '@babel/runtime': 7.28.4 optionalDependencies: '@types/react': 19.2.2 - '@mui/utils@7.3.3(@types/react@19.2.2)(react@19.2.0)': + '@mui/utils@7.3.5(@types/react@19.2.2)(react@19.2.0)': dependencies: '@babel/runtime': 7.28.4 - '@mui/types': 7.4.7(@types/react@19.2.2) + '@mui/types': 7.4.8(@types/react@19.2.2) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 @@ -3575,18 +3598,18 @@ snapshots: dependencies: preact: 10.27.2 - '@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0))': + '@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))': dependencies: '@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) - '@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) + '@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)) '@rollup/pluginutils': 4.2.1 babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.5) debug: 4.4.3 picocolors: 1.1.1 - vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) - vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)) + vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1) + vite-prerender-plugin: 0.5.12(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)) transitivePeerDependencies: - preact - supports-color @@ -3599,7 +3622,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0))': + '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1))': dependencies: '@babel/core': 7.28.5 '@prefresh/babel-plugin': 0.5.2 @@ -3607,7 +3630,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.27.2 - vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) + vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1) transitivePeerDependencies: - supports-color @@ -3693,14 +3716,16 @@ snapshots: react-virtualized-auto-sizer: 1.0.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-window: 1.8.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)': + '@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.6.2)': dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 javascript-natural-sort: 0.7.1 - lodash: 4.17.21 + lodash-es: 4.17.21 + minimatch: 9.0.5 + parse-imports-exports: 0.2.4 prettier: 3.6.2 transitivePeerDependencies: - supports-color @@ -3781,15 +3806,15 @@ snapshots: dependencies: '@types/node': 24.10.0 - '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.46.2(eslint@9.39.0)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/type-utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.2 - eslint: 9.39.0 + '@typescript-eslint/parser': 8.46.3(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.46.3 + '@typescript-eslint/type-utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.3 + eslint: 9.39.1 graphemer: 1.4.0 ignore: 7.0.5 natural-compare: 1.4.0 @@ -3798,56 +3823,56 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.46.2 + '@typescript-eslint/scope-manager': 8.46.3 + '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.46.3 debug: 4.4.3 - eslint: 9.39.0 + eslint: 9.39.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.2(typescript@5.9.3)': + '@typescript-eslint/project-service@8.46.3(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) - '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) + '@typescript-eslint/types': 8.46.3 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.2': + '@typescript-eslint/scope-manager@8.46.3': dependencies: - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/visitor-keys': 8.46.2 + '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/visitor-keys': 8.46.3 - '@typescript-eslint/tsconfig-utils@8.46.2(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.46.3(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.2(eslint@9.39.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.46.3(eslint@9.39.1)(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) + '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.0 + eslint: 9.39.1 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.2': {} + '@typescript-eslint/types@8.46.3': {} - '@typescript-eslint/typescript-estree@8.46.2(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.46.3(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.46.2(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3) - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/visitor-keys': 8.46.2 + '@typescript-eslint/project-service': 8.46.3(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.46.3(typescript@5.9.3) + '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/visitor-keys': 8.46.3 debug: 4.4.3 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -3858,20 +3883,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.46.2(eslint@9.39.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.46.3(eslint@9.39.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0) - '@typescript-eslint/scope-manager': 8.46.2 - '@typescript-eslint/types': 8.46.2 - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - eslint: 9.39.0 + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) + '@typescript-eslint/scope-manager': 8.46.3 + '@typescript-eslint/types': 8.46.3 + '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) + eslint: 9.39.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.2': + '@typescript-eslint/visitor-keys@8.46.3': dependencies: - '@typescript-eslint/types': 8.46.2 + '@typescript-eslint/types': 8.46.3 eslint-visitor-keys: 4.2.1 acorn-jsx@5.3.2(acorn@8.15.0): @@ -3922,6 +3947,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.11.0: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.28.4 @@ -3936,7 +3963,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.8.23: {} + baseline-browser-mapping@2.8.24: {} bin-build@3.0.0: dependencies: @@ -3993,9 +4020,9 @@ snapshots: browserslist@4.27.0: dependencies: - baseline-browser-mapping: 2.8.23 + baseline-browser-mapping: 2.8.24 caniuse-lite: 1.0.30001753 - electron-to-chromium: 1.5.244 + electron-to-chromium: 1.5.245 node-releases: 2.0.27 update-browserslist-db: 1.1.4(browserslist@4.27.0) @@ -4340,7 +4367,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.244: {} + electron-to-chromium@1.5.245: {} emoji-regex@8.0.0: {} @@ -4483,9 +4510,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@9.39.0): + eslint-config-prettier@10.1.8(eslint@9.39.1): dependencies: - eslint: 9.39.0 + eslint: 9.39.1 eslint-scope@8.4.0: dependencies: @@ -4496,15 +4523,15 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.0: + eslint@9.39.1: dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.0) + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.39.0 + '@eslint/js': 9.39.1 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 @@ -4555,6 +4582,8 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + exec-buffer@3.2.0: dependencies: execa: 0.7.0 @@ -5138,9 +5167,9 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.merge@4.6.2: {} + lodash-es@4.17.21: {} - lodash@4.17.21: {} + lodash.merge@4.6.2: {} logalot@2.1.0: dependencies: @@ -5376,6 +5405,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + parse-json@2.2.0: dependencies: error-ex: 1.3.4 @@ -5387,6 +5420,8 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-statements@1.0.11: {} + path-exists@2.1.0: dependencies: pinkie-promise: 2.0.1 @@ -5820,7 +5855,7 @@ snapshots: temp-dir: 1.0.0 uuid: 3.4.0 - terser@5.44.0: + terser@5.44.1: dependencies: '@jridgewell/source-map': 0.3.11 acorn: 8.15.0 @@ -5884,13 +5919,13 @@ snapshots: dependencies: typescript: 5.9.3 - typescript-eslint@8.46.2(eslint@9.39.0)(typescript@5.9.3): + typescript-eslint@8.46.3(eslint@9.39.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.39.0)(typescript@5.9.3))(eslint@9.39.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.46.2(eslint@9.39.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.46.2(typescript@5.9.3) - '@typescript-eslint/utils': 8.46.2(eslint@9.39.0)(typescript@5.9.3) - eslint: 9.39.0 + '@typescript-eslint/eslint-plugin': 8.46.3(@typescript-eslint/parser@8.46.3(eslint@9.39.1)(typescript@5.9.3))(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.46.3(eslint@9.39.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.46.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.46.3(eslint@9.39.1)(typescript@5.9.3) + eslint: 9.39.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5935,7 +5970,7 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-plugin-imagemin@0.6.1(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): + vite-plugin-imagemin@0.6.1(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -5960,11 +5995,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) + vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): + vite-prerender-plugin@0.5.12(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -5972,20 +6007,20 @@ snapshots: simple-code-frame: 1.3.0 source-map: 0.7.6 stack-trace: 1.0.0-pre2 - vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) + vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.10.0)(terser@5.44.0)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.0(@types/node@24.10.0)(terser@5.44.1)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.12(@types/node@24.10.0)(terser@5.44.0) + vite: 7.2.0(@types/node@24.10.0)(terser@5.44.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.12(@types/node@24.10.0)(terser@5.44.0): + vite@7.2.0(@types/node@24.10.0)(terser@5.44.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -5996,7 +6031,7 @@ snapshots: optionalDependencies: '@types/node': 24.10.0 fsevents: 2.3.3 - terser: 5.44.0 + terser: 5.44.1 which-typed-array@1.1.19: dependencies: diff --git a/interface/progmem-generator.js b/interface/progmem-generator.js index 52c304b1d..30fc00094 100644 --- a/interface/progmem-generator.js +++ b/interface/progmem-generator.js @@ -1,10 +1,9 @@ -import crypto from 'crypto'; +import etag from 'etag'; import { createWriteStream, existsSync, readFileSync, readdirSync, - statSync, unlinkSync } from 'fs'; import mime from 'mime-types'; @@ -36,7 +35,7 @@ const generateWWWClass = class WWWData { ${INDENT}public: ${INDENT.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) { -${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, "${f.hash}");`).join('\n')} +${fileInfo.map((f) => `${INDENT.repeat(3)}handler("${f.uri}", "${f.mimeType}", ${f.variable}, ${f.size}, ${f.hash});`).join('\n')} ${INDENT.repeat(2)}} }; `; @@ -71,7 +70,8 @@ const writeFile = (relativeFilePath, buffer) => { writeStream.write(`const uint8_t ${variable}[] = {`); const zipBuffer = zlib.gzipSync(buffer, { level: 9 }); - const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); + // const hash = crypto.createHash('sha256').update(zipBuffer).digest('hex'); + const hash = etag(zipBuffer); // use smaller md5 instead of sha256 zipBuffer.forEach((b) => { if (!(size % bytesPerLine)) { diff --git a/interface/src/App.tsx b/interface/src/App.tsx index 496f2e3eb..6bd614984 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -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 AppRouting from 'AppRouting'; @@ -23,6 +23,26 @@ const AVAILABLE_LOCALES = [ 'cz' ] 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 [wasLoaded, setWasLoaded] = useState(false); const [locale, setLocale] = useState('en'); @@ -41,36 +61,13 @@ const App = memo(() => { void 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; return ( - + ); diff --git a/interface/src/AppRouting.tsx b/interface/src/AppRouting.tsx index cf570a169..91c8f0945 100644 --- a/interface/src/AppRouting.tsx +++ b/interface/src/AppRouting.tsx @@ -1,60 +1,80 @@ -import { useContext, useEffect } from 'react'; +import { type FC, Suspense, lazy, memo, useContext, useEffect, useRef } from 'react'; import { Navigate, Route, Routes } from 'react-router'; import { toast } from 'react-toastify'; -import AuthenticatedRouting from 'AuthenticatedRouting'; -import SignIn from 'SignIn'; -import { RequireAuthenticated, RequireUnauthenticated } from 'components'; +import { + LoadingSpinner, + RequireAuthenticated, + RequireUnauthenticated +} from 'components'; import { Authentication, AuthenticationContext } from 'contexts/authentication'; 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 { - message: string; - signOut?: boolean; + readonly message: string; + readonly signOut?: boolean; } -const RootRedirect = ({ message, signOut }: SecurityRedirectProps) => { - const authenticationContext = useContext(AuthenticationContext); - useEffect(() => { - signOut && authenticationContext.signOut(false); - toast.success(message); - }, [message, signOut, authenticationContext]); - return ; -}; +const RootRedirect: FC = memo( + ({ message, signOut = false }) => { + const { signOut: contextSignOut } = useContext(AuthenticationContext); + const hasShownToast = useRef(false); -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 ; + } +); + +const AppRouting: FC = memo(() => { const { LL } = useI18nContext(); return ( - - } - /> - } - /> - - - - } - /> - - - - } - /> - + }> + + } + /> + } + /> + + + + } + /> + + + + } + /> + + ); -}; +}); export default AppRouting; diff --git a/interface/src/AuthenticatedRouting.tsx b/interface/src/AuthenticatedRouting.tsx index df7e9cd5c..8188f6282 100644 --- a/interface/src/AuthenticatedRouting.tsx +++ b/interface/src/AuthenticatedRouting.tsx @@ -1,77 +1,86 @@ -import { useContext } from 'react'; +import { Suspense, lazy, memo, useContext } from 'react'; import { Navigate, Route, Routes } from 'react-router'; -import CustomEntities from 'app/main/CustomEntities'; -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 { Layout, LoadingSpinner } from 'components'; 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); return ( - - } /> - } /> - } /> - } /> + }> + + } /> + } /> + } /> + } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {me.admin && ( - <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {me.admin && ( + <> + } /> + } + /> + } /> + } /> + } /> + } /> + } /> - } /> - } /> + } /> + } /> - } /> - } /> - } /> - - )} + } /> + } /> + } /> + + )} - } /> - + } /> + + ); -}; +}); export default AuthenticatedRouting; diff --git a/interface/src/SignIn.tsx b/interface/src/SignIn.tsx index 5d15d4fa5..fc6d73c6a 100644 --- a/interface/src/SignIn.tsx +++ b/interface/src/SignIn.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { memo, useCallback, useContext, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import ForwardIcon from '@mui/icons-material/Forward'; @@ -19,7 +19,7 @@ import type { SignInRequest } from 'types'; import { onEnterCallback, updateValue } from 'utils'; import { SIGN_IN_REQUEST_VALIDATOR, validate } from 'validators'; -const SignIn = () => { +const SignIn = memo(() => { const authenticationContext = useContext(AuthenticationContext); 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) => { if (event.message === 'Unauthorized') { toast.warning(LL.INVALID_LOGIN()); @@ -53,9 +62,9 @@ const SignIn = () => { } setProcessing(false); }); - }; + }, [callSignIn, signInRequest, LL]); - const validateAndSignIn = async () => { + const validateAndSignIn = useCallback(async () => { setProcessing(true); SIGN_IN_REQUEST_VALIDATOR.messages({ required: LL.IS_REQUIRED('%s') @@ -67,9 +76,10 @@ const SignIn = () => { setFieldErrors(error as ValidateFieldsError); setProcessing(false); } - }; + }, [signInRequest, signIn, LL]); - const submitOnEnter = onEnterCallback(signIn); + // Memoize callback to prevent recreation on every render + const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]); return ( { ); -}; +}); export default SignIn; diff --git a/interface/src/api/app.ts b/interface/src/api/app.ts index 58aa67689..668e61904 100644 --- a/interface/src/api/app.ts +++ b/interface/src/api/app.ts @@ -20,19 +20,18 @@ import type { WriteTemperatureSensor } from '../app/main/types'; +const MSGPACK_CONFIG = { responseType: 'arraybuffer' as const }; + // Dashboard export const readDashboard = () => - alovaInstance.Get('/rest/dashboardData', { - responseType: 'arraybuffer' // uses msgpack - }); + alovaInstance.Get('/rest/dashboardData', MSGPACK_CONFIG); // Devices -export const readCoreData = () => alovaInstance.Get(`/rest/coreData`); +export const readCoreData = () => alovaInstance.Get('/rest/coreData'); export const readDeviceData = (id: number) => alovaInstance.Get('/rest/deviceData', { - // alovaInstance.Get(`/rest/deviceData/${id}`, { params: { id }, - responseType: 'arraybuffer' // uses msgpack + ...MSGPACK_CONFIG }); export const writeDeviceValue = (data: { id: number; c: string; v: unknown }) => alovaInstance.Post('/rest/writeDeviceValue', data); @@ -66,13 +65,13 @@ export const callAction = (action: Action) => // SettingsCustomization export const readDeviceEntities = (id: number) => - // alovaInstance.Get(`/rest/deviceEntities/${id}`, { - alovaInstance.Get(`/rest/deviceEntities`, { + alovaInstance.Get('/rest/deviceEntities', { params: { id }, - responseType: 'arraybuffer', + ...MSGPACK_CONFIG, // @ts-expect-error - exactOptionalPropertyTypes compatibility issue transform(data) { - return (data as DeviceEntity[]).map((de: DeviceEntity) => ({ + const entities = data as DeviceEntity[]; + return entities.map((de) => ({ ...de, o_m: de.m, o_cn: de.cn, @@ -95,7 +94,8 @@ export const readSchedule = () => alovaInstance.Get('/rest/schedule', { // @ts-expect-error - exactOptionalPropertyTypes compatibility issue transform(data) { - return (data as Schedule).schedule.map((si: ScheduleItem) => ({ + const schedule = (data as Schedule).schedule; + return schedule.map((si) => ({ ...si, o_id: si.id, o_active: si.active, @@ -115,7 +115,8 @@ export const writeSchedule = (data: Schedule) => export const readModules = () => alovaInstance.Get('/rest/modules', { transform(data) { - return (data as Modules).modules.map((mi: ModuleItem) => ({ + const modules = (data as Modules).modules; + return modules.map((mi) => ({ ...mi, o_enabled: mi.enabled, o_license: mi.license @@ -133,7 +134,8 @@ export const readCustomEntities = () => alovaInstance.Get('/rest/customEntities', { // @ts-expect-error - exactOptionalPropertyTypes compatibility issue transform(data) { - return (data as Entities).entities.map((ei: EntityItem) => ({ + const entities = (data as Entities).entities; + return entities.map((ei) => ({ ...ei, o_id: ei.id, o_ram: ei.ram, diff --git a/interface/src/api/endpoints.ts b/interface/src/api/endpoints.ts index 99940bb14..95d462689 100644 --- a/interface/src/api/endpoints.ts +++ b/interface/src/api/endpoints.ts @@ -4,55 +4,57 @@ import ReactHook from 'alova/react'; 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({ statesHook: ReactHook, - // timeout: 3000, // 3 seconds before throwing a timeout error, default is 0 = none cacheFor: null, // disable cache - // cacheFor: { - // GET: { - // mode: 'memory', - // expire: 60 * 10 * 1000 // 60 seconds in cache - // } - // }, requestAdapter: xhrRequestAdapter(), beforeRequest(method) { - if (localStorage.getItem(ACCESS_TOKEN)) { - method.config.headers.Authorization = - 'Bearer ' + localStorage.getItem(ACCESS_TOKEN); + const token = getAccessToken(); + if (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: { - onSuccess: async (response: AlovaXHRResponse) => { - // 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); - // } + onSuccess: handleResponse } }); diff --git a/interface/src/api/network.ts b/interface/src/api/network.ts index 7f4ff203d..076772377 100644 --- a/interface/src/api/network.ts +++ b/interface/src/api/network.ts @@ -2,12 +2,14 @@ import type { NetworkSettingsType, NetworkStatusType, WiFiNetworkList } from 'ty import { alovaInstance } from './endpoints'; +const LIST_NETWORKS_TIMEOUT = 20000; // 20 seconds + export const readNetworkStatus = () => alovaInstance.Get('/rest/networkStatus'); export const scanNetworks = () => alovaInstance.Get('/rest/scanNetworks'); export const listNetworks = () => alovaInstance.Get('/rest/listNetworks', { - timeout: 20000 // 20 seconds + timeout: LIST_NETWORKS_TIMEOUT }); export const readNetworkSettings = () => alovaInstance.Get('/rest/networkSettings'); diff --git a/interface/src/api/ntp.ts b/interface/src/api/ntp.ts index 443d078b1..7af3566ba 100644 --- a/interface/src/api/ntp.ts +++ b/interface/src/api/ntp.ts @@ -6,7 +6,7 @@ export const readNTPStatus = () => alovaInstance.Get('/rest/ntpStatus'); export const readNTPSettings = () => - alovaInstance.Get('/rest/ntpSettings', {}); + alovaInstance.Get('/rest/ntpSettings'); export const updateNTPSettings = (data: NTPSettingsType) => alovaInstance.Post('/rest/ntpSettings', data); diff --git a/interface/src/api/system.ts b/interface/src/api/system.ts index 492661212..1b9d1a37a 100644 --- a/interface/src/api/system.ts +++ b/interface/src/api/system.ts @@ -8,7 +8,7 @@ export const readSystemStatus = () => // SystemLog export const readLogSettings = () => - alovaInstance.Get(`/rest/logSettings`); + alovaInstance.Get('/rest/logSettings'); export const updateLogSettings = (data: LogSettings) => alovaInstance.Post('/rest/logSettings', data); 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) => { const formData = new FormData(); formData.append('file', file); return alovaInstance.Post('/rest/uploadFile', formData, { - timeout: 60000 // override timeout for uploading firmware - 1 minute + timeout: UPLOAD_TIMEOUT }); }; diff --git a/interface/src/api/unpack.ts b/interface/src/api/unpack.ts index 0b40f7aa0..aa6a9cab3 100644 --- a/interface/src/api/unpack.ts +++ b/interface/src/api/unpack.ts @@ -54,7 +54,7 @@ export class Unpackr { } Object.assign(this, options); } - unpack(source, options?: any) { + unpack(source, options?: { start?: number; end?: number; lazy?: boolean }) { if (src) { return saveState(() => { clearSource(); @@ -184,7 +184,7 @@ export class Unpackr { function getPosition() { return position; } -function checkedRead(options: any) { +function checkedRead(options?: { lazy?: boolean }) { try { if (!currentUnpackr.trusted && !sequentialMode) { const sharedLength = currentStructures.sharedLength || 0; diff --git a/interface/src/app/main/CustomEntities.tsx b/interface/src/app/main/CustomEntities.tsx index 9c36ad394..a928d8181 100644 --- a/interface/src/app/main/CustomEntities.tsx +++ b/interface/src/app/main/CustomEntities.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -35,6 +35,10 @@ import { DeviceValueTypeNames, DeviceValueUOM_s } from './types'; import type { Entities, EntityItem } from './types'; import { entityItemValidation } from './validators'; +const MIN_ID = -100; +const MAX_ID = 100; +const ICON_SIZE = 12; + const CustomEntities = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -53,18 +57,20 @@ const CustomEntities = () => { initialData: [] }); - useInterval(() => { + const intervalCallback = useCallback(() => { if (!dialogOpen && !numChanges) { void fetchEntities(); } - }); + }, [dialogOpen, numChanges, fetchEntities]); + + useInterval(intervalCallback); const { send: writeEntities } = useRequest( (data: Entities) => writeCustomEntities(data), { immediate: false } ); - function hasEntityChanged(ei: EntityItem) { + const hasEntityChanged = useCallback((ei: EntityItem) => { return ( ei.id !== ei.o_id || ei.ram !== ei.o_ram || @@ -80,19 +86,21 @@ const CustomEntities = () => { ei.deleted !== ei.o_deleted || (ei.value || '') !== (ei.o_value || '') ); - } + }, []); - const entity_theme = useTheme({ - Table: ` + const entity_theme = useMemo( + () => + useTheme({ + Table: ` --data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px; `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(1) { padding: 8px; } @@ -112,7 +120,7 @@ const CustomEntities = () => { text-align: center; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -121,7 +129,7 @@ const CustomEntities = () => { height: 36px; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -132,9 +140,11 @@ const CustomEntities = () => { background-color: #177ac9; } ` - }); + }), + [] + ); - const saveEntities = async () => { + const saveEntities = useCallback(async () => { await writeEntities({ entities: entities .filter((ei: EntityItem) => !ei.deleted) @@ -163,7 +173,7 @@ const CustomEntities = () => { await fetchEntities(); setNumChanges(0); }); - }; + }, [entities, writeEntities, LL, fetchEntities]); const editEntityItem = useCallback((ei: EntityItem) => { setCreating(false); @@ -171,36 +181,39 @@ const CustomEntities = () => { setDialogOpen(true); }, []); - const onDialogClose = () => { + const onDialogClose = useCallback(() => { setDialogOpen(false); - }; + }, []); - const onDialogCancel = async () => { + const onDialogCancel = useCallback(async () => { await fetchEntities().then(() => { setNumChanges(0); }); - }; + }, [fetchEntities]); - const onDialogSave = (updatedItem: EntityItem) => { - setDialogOpen(false); - void updateState(readCustomEntities(), (data: EntityItem[]) => { - const new_data = creating - ? [ - ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), - updatedItem - ] - : data.map((ei) => - ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei - ); - setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); - return new_data; - }); - }; + const onDialogSave = useCallback( + (updatedItem: EntityItem) => { + setDialogOpen(false); + void updateState(readCustomEntities(), (data: EntityItem[]) => { + const new_data = creating + ? [ + ...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id), + updatedItem + ] + : data.map((ei) => + ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei + ); + setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length); + return new_data; + }); + }, + [creating, hasEntityChanged] + ); - const onDialogDup = (item: EntityItem) => { + const onDialogDup = useCallback((item: EntityItem) => { setCreating(true); setSelectedEntityItem({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), + id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), name: item.name + '_', ram: item.ram, device_id: item.device_id, @@ -215,12 +228,12 @@ const CustomEntities = () => { value: item.value }); setDialogOpen(true); - }; + }, []); - const addEntityItem = () => { + const addEntityItem = useCallback(() => { setCreating(true); setSelectedEntityItem({ - id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), + id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID), name: '', ram: 0, device_id: '0', @@ -235,22 +248,30 @@ const CustomEntities = () => { value: '' }); setDialogOpen(true); - }; + }, []); - function formatValue(value: unknown, uom: number) { + const formatValue = useCallback((value: unknown, uom: number) => { return value === undefined ? '' : typeof value === 'number' ? new Intl.NumberFormat().format(value) + - (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]) - : (value as string) + (uom === 0 ? '' : ' ' + DeviceValueUOM_s[uom]); - } + (uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`) + : `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`; + }, []); - function showHex(value: number, digit: number) { - return '0x' + value.toString(16).toUpperCase().padStart(digit, '0'); - } + const showHex = useCallback((value: number, digit: number) => { + return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`; + }, []); - const renderEntity = () => { + const filteredAndSortedEntities = useMemo( + () => + entities + ?.filter((ei: EntityItem) => !ei.deleted) + .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [], + [entities] + ); + + const renderEntity = useCallback(() => { if (!entities) { return ( @@ -260,9 +281,7 @@ const CustomEntities = () => { return ( !ei.deleted) - .sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) + nodes: filteredAndSortedEntities }} theme={entity_theme} layout={{ custom: true }} @@ -285,7 +304,10 @@ const CustomEntities = () => { {ei.name}  {ei.writeable && ( - + )} @@ -304,7 +326,17 @@ const CustomEntities = () => { )}
); - }; + }, [ + entities, + error, + fetchEntities, + entity_theme, + editEntityItem, + LL, + filteredAndSortedEntities, + showHex, + formatValue + ]); return ( diff --git a/interface/src/app/main/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index bf10b55fc..3041f0bba 100644 --- a/interface/src/app/main/CustomEntitiesDialog.tsx +++ b/interface/src/app/main/CustomEntitiesDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -33,6 +33,19 @@ import { validate } from 'validators'; import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } 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 { open: boolean; creating: boolean; @@ -55,64 +68,97 @@ const CustomEntitiesDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); useEffect(() => { if (open) { setFieldErrors(undefined); - setEditItem(selectedItem); - // convert to hex strings straight away + // Convert to hex strings - combined into single setEditItem call + const deviceIdHex = + typeof selectedItem.device_id === 'number' + ? selectedItem.device_id.toString(16).toUpperCase() + : selectedItem.device_id; + const typeIdHex = + typeof selectedItem.type_id === 'number' + ? selectedItem.type_id.toString(16).toUpperCase() + : selectedItem.type_id; + const factorValue = + selectedItem.value_type === DeviceValueType.BOOL && + typeof selectedItem.factor === 'number' + ? selectedItem.factor.toString(16).toUpperCase() + : selectedItem.factor; + setEditItem({ ...selectedItem, - device_id: selectedItem.device_id.toString(16).toUpperCase(), - type_id: selectedItem.type_id.toString(16).toUpperCase(), - factor: - selectedItem.value_type === DeviceValueType.BOOL - ? selectedItem.factor.toString(16).toUpperCase() - : selectedItem.factor + device_id: deviceIdHex, + type_id: typeIdHex, + factor: factorValue }); } }, [open, selectedItem]); - const handleClose = ( - _event: React.SyntheticEvent, - reason: 'backdropClick' | 'escapeKeyDown' - ) => { - if (reason !== 'backdropClick') { - onClose(); - } - }; + const handleClose = useCallback( + (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { + if (reason !== 'backdropClick') { + onClose(); + } + }, + [onClose] + ); - const save = async () => { + const save = useCallback(async () => { try { setFieldErrors(undefined); await validate(validator, editItem); - if (typeof editItem.device_id === 'string') { - editItem.device_id = parseInt(editItem.device_id, 16); + + // Create a copy to avoid mutating the state directly + const processedItem: EntityItem = { ...editItem }; + + if (typeof processedItem.device_id === 'string') { + processedItem.device_id = Number.parseInt(processedItem.device_id, 16); } - if (typeof editItem.type_id === 'string') { - editItem.type_id = parseInt(editItem.type_id, 16); + if (typeof processedItem.type_id === 'string') { + processedItem.type_id = Number.parseInt(processedItem.type_id, 16); } if ( - editItem.value_type === DeviceValueType.BOOL && - typeof editItem.factor === 'string' + processedItem.value_type === DeviceValueType.BOOL && + typeof processedItem.factor === 'string' ) { - editItem.factor = parseInt(editItem.factor, 16); + processedItem.factor = Number.parseInt(processedItem.factor, 16); } - onSave(editItem); + onSave(processedItem); } catch (error) { setFieldErrors(error as ValidateFieldsError); } - }; + }, [validator, editItem, onSave]); - const remove = () => { - editItem.deleted = true; - onSave(editItem); - }; + const remove = useCallback(() => { + const itemWithDeleted = { ...editItem, deleted: true }; + onSave(itemWithDeleted); + }, [editItem, onSave]); - const dup = () => { + const dup = useCallback(() => { onDup(editItem); - }; + }, [editItem, onDup]); + + // Memoize UOM menu items to avoid recreating on every render + const uomMenuItems = useMemo( + () => + DeviceValueUOM_s.map((val, i) => ( + + {val} + + )), + [] + ); return ( @@ -120,9 +166,6 @@ const CustomEntitiesDialog = ({ {creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()} - - - - {DeviceValueUOM_s.map((val, i) => ( - - {val} - - ))} + {uomMenuItems} @@ -275,33 +314,11 @@ const CustomEntitiesDialog = ({ margin="normal" select > - - {DeviceValueTypeNames[DeviceValueType.BOOL]} - - - {DeviceValueTypeNames[DeviceValueType.INT8]} - - - {DeviceValueTypeNames[DeviceValueType.UINT8]} - - - {DeviceValueTypeNames[DeviceValueType.INT16]} - - - {DeviceValueTypeNames[DeviceValueType.UINT16]} - - - {DeviceValueTypeNames[DeviceValueType.UINT24]} - - - {DeviceValueTypeNames[DeviceValueType.TIME]} - - - {DeviceValueTypeNames[DeviceValueType.UINT32]} - - - {DeviceValueTypeNames[DeviceValueType.STRING]} - + {VALUE_TYPE_OPTIONS.map((valueType) => ( + + {DeviceValueTypeNames[valueType]} + + ))} @@ -333,11 +350,7 @@ const CustomEntitiesDialog = ({ onChange={updateFormValue} select > - {DeviceValueUOM_s.map((val, i) => ( - - {val} - - ))} + {uomMenuItems} diff --git a/interface/src/app/main/Customizations.tsx b/interface/src/app/main/Customizations.tsx index 10d6fa6b3..2f340de14 100644 --- a/interface/src/app/main/Customizations.tsx +++ b/interface/src/app/main/Customizations.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useBlocker, useLocation } from 'react-router'; import { toast } from 'react-toastify'; @@ -62,7 +62,24 @@ import OptionIcon from './OptionIcon'; import { DeviceEntityMask } from './types'; import type { APIcall, Device, DeviceEntity } from './types'; -export const APIURL = window.location.origin + '/api/'; +export const APIURL = `${window.location.origin}/api/`; + +const MAX_BUFFER_SIZE = 2000; + +// Helper function to create masked entity ID - extracted to avoid duplication +const createMaskedEntityId = (de: DeviceEntity): string => { + const maskHex = de.m.toString(16).padStart(2, '0'); + const hasCustomizations = !!(de.cn || de.mi || de.ma); + const customizations = [ + de.cn || '', + de.mi ? `>${de.mi}` : '', + de.ma ? `<${de.ma}` : '' + ] + .filter(Boolean) + .join(''); + + return `${maskHex}${de.id}${hasCustomizations ? `|${customizations}` : ''}`; +}; const Customizations = () => { const { LL } = useI18nContext(); @@ -153,17 +170,19 @@ const Customizations = () => { ); }; - const entities_theme = useTheme({ - Table: ` + const entities_theme = useMemo( + () => + useTheme({ + Table: ` --data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto); `, - BaseRow: ` + BaseRow: ` font-size: 14px; .td { height: 32px; } `, - BaseCell: ` + BaseCell: ` &:nth-of-type(3) { text-align: right; } @@ -174,7 +193,7 @@ const Customizations = () => { text-align: right; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -186,7 +205,7 @@ const Customizations = () => { text-align: center; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -202,7 +221,7 @@ const Customizations = () => { background-color: #177ac9; } `, - Cell: ` + Cell: ` &:nth-of-type(2) { padding: 8px; } @@ -216,7 +235,9 @@ const Customizations = () => { padding-right: 8px; } ` - }); + }), + [] + ); function hasEntityChanged(de: DeviceEntity) { return ( @@ -229,19 +250,8 @@ const Customizations = () => { useEffect(() => { if (deviceEntities.length) { - setNumChanges( - deviceEntities - .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 - ); + const changedEntities = deviceEntities.filter((de) => hasEntityChanged(de)); + setNumChanges(changedEntities.length); } }, [deviceEntities]); @@ -275,18 +285,22 @@ const Customizations = () => { return value as string; } - const formatName = (de: DeviceEntity, withShortname: boolean) => - (de.n && de.n[0] === '!' - ? de.t - ? LL.COMMAND(1) + ': ' + de.t + ' ' + de.n.slice(1) - : LL.COMMAND(1) + ': ' + de.n.slice(1) - : de.cn && de.cn !== '' - ? de.t - ? de.t + ' ' + de.cn - : de.cn - : de.t - ? de.t + ' ' + de.n - : de.n) + (withShortname ? ' ' + de.id : ''); + const formatName = useCallback( + (de: DeviceEntity, withShortname: boolean) => { + let name: string; + if (de.n && de.n[0] === '!') { + name = de.t + ? `${LL.COMMAND(1)}: ${de.t} ${de.n.slice(1)}` + : `${LL.COMMAND(1)}: ${de.n.slice(1)}`; + } else if (de.cn && de.cn !== '') { + name = de.t ? `${de.t} ${de.cn}` : de.cn; + } else { + name = de.t ? `${de.t} ${de.n}` : de.n || ''; + } + return withShortname ? `${name} ${de.id}` : name; + }, + [LL] + ); const getMaskNumber = (newMask: string[]) => { let new_mask = 0; @@ -316,34 +330,33 @@ const Customizations = () => { return new_masks; }; - const filter_entity = (de: DeviceEntity) => - (de.m & selectedFilters || !selectedFilters) && - formatName(de, true).toLowerCase().includes(search.toLowerCase()); + const filter_entity = useCallback( + (de: DeviceEntity) => + (de.m & selectedFilters || !selectedFilters) && + formatName(de, true).toLowerCase().includes(search.toLowerCase()), + [selectedFilters, search, formatName] + ); - const maskDisabled = (set: boolean) => { - setDeviceEntities( - deviceEntities.map(function (de) { - if (filter_entity(de)) { - return { - ...de, - m: set - ? de.m | - (DeviceEntityMask.DV_API_MQTT_EXCLUDE | - DeviceEntityMask.DV_WEB_EXCLUDE) - : de.m & - ~( - DeviceEntityMask.DV_API_MQTT_EXCLUDE | - DeviceEntityMask.DV_WEB_EXCLUDE - ) - }; - } else { + const maskDisabled = useCallback( + (set: boolean) => { + setDeviceEntities((prev) => + prev.map((de) => { + if (filter_entity(de)) { + const excludeMask = + DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE; + return { + ...de, + m: set ? de.m | excludeMask : de.m & ~excludeMask + }; + } return de; - } - }) - ); - }; + }) + ); + }, + [filter_entity] + ); - const resetCustomization = async () => { + const resetCustomization = useCallback(async () => { try { await sendResetCustomizations(); toast.info(LL.CUSTOMIZATIONS_RESTART()); @@ -352,24 +365,28 @@ const Customizations = () => { } finally { setConfirmReset(false); } - }; + }, [sendResetCustomizations, LL]); const onDialogClose = () => { setDialogOpen(false); }; - const updateDeviceEntity = (updatedItem: DeviceEntity) => { + const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { setDeviceEntities( - deviceEntities?.map((de) => - de.id === updatedItem.id ? { ...de, ...updatedItem } : de - ) + (prev) => + prev?.map((de) => + de.id === updatedItem.id ? { ...de, ...updatedItem } : de + ) ?? [] ); - }; + }, []); - const onDialogSave = (updatedItem: DeviceEntity) => { - setDialogOpen(false); - updateDeviceEntity(updatedItem); - }; + const onDialogSave = useCallback( + (updatedItem: DeviceEntity) => { + setDialogOpen(false); + updateDeviceEntity(updatedItem); + }, + [updateDeviceEntity] + ); const editDeviceEntity = useCallback((de: DeviceEntity) => { if (de.n === undefined || (de.n && de.n[0] === '!')) { @@ -384,60 +401,54 @@ const Customizations = () => { setDialogOpen(true); }, []); - const saveCustomization = async () => { - if (devices && deviceEntities && selectedDevice !== -1) { - const masked_entities = deviceEntities - .filter((de: DeviceEntity) => 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 : '') - ); - - // check size in bytes to match buffer in CPP, which is 2048 - const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; - if (bytes > 2000) { - toast.warning(LL.CUSTOMIZATIONS_FULL()); - return; - } - - await sendCustomizationEntities({ - id: selectedDevice, - entity_ids: masked_entities - }) - .then(() => { - toast.success(LL.CUSTOMIZATIONS_SAVED()); - }) - .catch((error: Error) => { - if (error.message === 'Reboot required') { - setRestartNeeded(true); - } else { - toast.error(error.message); - } - }) - .finally(() => { - setOriginalSettings(deviceEntities); - }); + const saveCustomization = useCallback(async () => { + if (!devices || !deviceEntities || selectedDevice === -1) { + return; } - }; - const renameDevice = async () => { + const masked_entities = deviceEntities + .filter((de: DeviceEntity) => hasEntityChanged(de)) + .map((new_de) => createMaskedEntityId(new_de)); + + // check size in bytes to match buffer in CPP, which is 2048 + const bytes = new TextEncoder().encode(JSON.stringify(masked_entities)).length; + if (bytes > MAX_BUFFER_SIZE) { + toast.warning(LL.CUSTOMIZATIONS_FULL()); + return; + } + + await sendCustomizationEntities({ + id: selectedDevice, + entity_ids: masked_entities + }) + .then(() => { + toast.success(LL.CUSTOMIZATIONS_SAVED()); + }) + .catch((error: Error) => { + if (error.message === 'Reboot required') { + setRestartNeeded(true); + } else { + toast.error(error.message); + } + }) + .finally(() => { + setOriginalSettings(deviceEntities); + }); + }, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]); + + const renameDevice = useCallback(async () => { await sendDeviceName({ id: selectedDevice, name: selectedDeviceName }) .then(() => { toast.success(LL.UPDATED_OF(LL.NAME(1))); }) .catch(() => { - toast.error(LL.UPDATE_OF(LL.NAME(1)) + ' ' + LL.FAILED(1)); + toast.error(`${LL.UPDATE_OF(LL.NAME(1))} ${LL.FAILED(1)}`); }) .finally(async () => { setRename(false); await fetchCoreData(); }); - }; + }, [selectedDevice, selectedDeviceName, sendDeviceName, LL, fetchCoreData]); const renderDeviceList = () => ( <> @@ -512,9 +523,12 @@ const Customizations = () => { ); - const renderDeviceData = () => { - const shown_data = deviceEntities.filter((de) => filter_entity(de)); + const filteredEntities = useMemo( + () => deviceEntities.filter((de) => filter_entity(de)), + [deviceEntities, filter_entity] + ); + const renderDeviceData = () => { return ( <> @@ -544,6 +558,7 @@ const Customizations = () => { size="small" variant="outlined" placeholder={LL.SEARCH()} + aria-label={LL.SEARCH()} onChange={(event) => { setSearch(event.target.value); }} @@ -612,13 +627,13 @@ const Customizations = () => { - {LL.SHOWING()} {shown_data.length}/{deviceEntities.length} + {LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}  {LL.ENTITIES(deviceEntities.length)} @@ -719,7 +734,11 @@ const Customizations = () => { startIcon={} variant="outlined" color="secondary" - onClick={() => devices && sendDeviceEntities(selectedDevice)} + onClick={() => { + if (devices) { + void sendDeviceEntities(selectedDevice); + } + }} > {LL.CANCEL()} diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index f303634a0..d989ef08c 100644 --- a/interface/src/app/main/CustomizationsDialog.tsx +++ b/interface/src/app/main/CustomizationsDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; @@ -30,6 +30,23 @@ interface SettingsCustomizationsDialogProps { selectedItem: DeviceEntity; } +interface LabelValueProps { + label: string; + value: React.ReactNode; +} + +const LabelValue = memo(({ label, value }: LabelValueProps) => ( + + + {label}:  + + {value} + +)); +LabelValue.displayName = 'LabelValue'; + +const ICON_SIZE = 16; + const CustomizationsDialog = ({ open, onClose, @@ -40,12 +57,23 @@ const CustomizationsDialog = ({ const [editItem, setEditItem] = useState(selectedItem); const [error, setError] = useState(false); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); - const isWriteableNumber = - typeof editItem.v === 'number' && - editItem.w && - !(editItem.m & DeviceEntityMask.DV_READONLY); + const isWriteableNumber = useMemo( + () => + typeof editItem.v === 'number' && + editItem.w && + !(editItem.m & DeviceEntityMask.DV_READONLY), + [editItem.v, editItem.w, editItem.m] + ); useEffect(() => { if (open) { @@ -54,66 +82,59 @@ const CustomizationsDialog = ({ } }, [open, selectedItem]); - const handleClose = ( - _event: React.SyntheticEvent, - reason: 'backdropClick' | 'escapeKeyDown' - ) => { - if (reason !== 'backdropClick') { - onClose(); - } - }; + const handleClose = useCallback( + (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { + if (reason !== 'backdropClick') { + onClose(); + } + }, + [onClose] + ); - const save = () => { + const save = useCallback(() => { if ( isWriteableNumber && editItem.mi && editItem.ma && - editItem.mi > editItem?.ma + editItem.mi > editItem.ma ) { setError(true); } else { onSave(editItem); } - }; + }, [isWriteableNumber, editItem, onSave]); - const updateDeviceEntity = (updatedItem: DeviceEntity) => { - setEditItem({ ...editItem, m: updatedItem.m }); - }; + const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => { + setEditItem((prev) => ({ ...prev, m: updatedItem.m })); + }, []); + + const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]); + + const writeableIcon = useMemo( + () => + editItem.w ? ( + + ) : ( + + ), + [editItem.w] + ); return ( - {LL.EDIT() + ' ' + LL.ENTITY()} + {dialogTitle} - - - {LL.ID_OF(LL.ENTITY())}:  - - {editItem.id} - - - - - {LL.DEFAULT(1) + ' ' + LL.ENTITY_NAME(1)}:  - - {editItem.n} - - - - - {LL.WRITEABLE()}:  - - - {editItem.w ? ( - - ) : ( - - )} - - + + + + )} + {error && ( Error: Check min and max values )} +
{ sx={{ backgroundColor: 'black', position: 'absolute', - left: () => leftOffset(), + left: leftOffset, right: 0, bottom: 0, top: 64, @@ -671,7 +669,7 @@ const Devices = memo(() => { - + @@ -683,6 +681,7 @@ const Devices = memo(() => { variant="outlined" sx={{ width: '22ch' }} placeholder={LL.SEARCH()} + aria-label={LL.SEARCH()} onChange={(event) => { setSearch(event.target.value); }} @@ -697,19 +696,22 @@ const Devices = memo(() => { }} /> - setShowDeviceInfo(true)}> + setShowDeviceInfo(true)} + aria-label={LL.DEVICE_DETAILS()} + > {me.admin && ( - + )} - + @@ -744,7 +746,7 @@ const Devices = memo(() => {
(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]); useEffect(() => { if (open) { @@ -61,11 +61,7 @@ const DevicesDialog = ({ } }, [open, selectedItem]); - const close = () => { - onClose(); - }; - - const save = async () => { + const save = useCallback(async () => { try { setFieldErrors(undefined); await validate(validator, editItem); @@ -73,46 +69,66 @@ const DevicesDialog = ({ } catch (error) { setFieldErrors(error as ValidateFieldsError); } - }; + }, [validator, editItem, onSave]); - const setUom = (uom?: DeviceValueUOM) => { - if (uom === undefined) { - return; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return LL.HOURS(); - case DeviceValueUOM.MINUTES: - return LL.MINUTES(); - case DeviceValueUOM.SECONDS: - return LL.SECONDS(); - default: - return DeviceValueUOM_s[uom]; - } - }; + const setUom = useCallback( + (uom?: DeviceValueUOM) => { + if (uom === undefined) { + return; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return LL.HOURS(); + case DeviceValueUOM.MINUTES: + return LL.MINUTES(); + case DeviceValueUOM.SECONDS: + return LL.SECONDS(); + default: + return DeviceValueUOM_s[uom]; + } + }, + [LL] + ); - const showHelperText = (dv: DeviceValue) => - dv.h ? ( - dv.h - ) : dv.l ? ( - dv.l.join(' | ') - ) : dv.m !== undefined && dv.x !== undefined ? ( - <> - {dv.m} → {dv.x} - - ) : undefined; + const showHelperText = useCallback((dv: DeviceValue) => { + if (dv.h) return dv.h; + if (dv.l) return dv.l.join(' | '); + if (dv.m !== undefined && dv.x !== undefined) { + return ( + <> + {dv.m} → {dv.x} + + ); + } + 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 ( - - - {selectedItem.v === '' && selectedItem.c - ? LL.RUN_COMMAND() - : writeable - ? LL.CHANGE_VALUE() - : LL.VALUE(0)} - + + {dialogTitle} - + {editItem.id.slice(2)} @@ -120,8 +136,8 @@ const DevicesDialog = ({ {editItem.l ? ( )} - {writeable && ( + {writeable && helperText && ( - {showHelperText(editItem)} + {helperText} )} @@ -191,7 +207,7 @@ const DevicesDialog = ({ {progress && ( ) : ( - )} diff --git a/interface/src/app/main/EntityMaskToggle.tsx b/interface/src/app/main/EntityMaskToggle.tsx index 3fee880f3..d2304d6f1 100644 --- a/interface/src/app/main/EntityMaskToggle.tsx +++ b/interface/src/app/main/EntityMaskToggle.tsx @@ -1,3 +1,5 @@ +import { useCallback, useMemo } from 'react'; + import { ToggleButton, ToggleButtonGroup } from '@mui/material'; import OptionIcon from './OptionIcon'; @@ -9,92 +11,132 @@ interface EntityMaskToggleProps { de: DeviceEntity; } -const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { - const getMaskNumber = (newMask: string[]) => { - let new_mask = 0; - for (const entry of newMask) { - new_mask |= Number(entry); - } - return new_mask; - }; +// 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 +]; - const getMaskString = (m: number) => { - const new_masks: string[] = []; - if ((m & 1) === 1) { - new_masks.push('1'); - } - if ((m & 2) === 2) { - new_masks.push('2'); - } - if ((m & 4) === 4) { - new_masks.push('4'); - } - if ((m & 8) === 8) { - new_masks.push('8'); - } - if ((m & 128) === 128) { - new_masks.push('128'); - } - return new_masks; - }; +/** + * 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 handleChange = useCallback( + (_event: unknown, mask: string[]) => { + // Convert selected masks to a number + const newMask = getMaskNumber(mask); + const updatedDe = { ...de }; + + // Apply business logic for mask interactions + // If entity has no name and is set to readonly, also exclude from web + if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { + updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; + } else { + updatedDe.m = newMask; + } + + // If excluded from web, cannot be favorite + if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { + updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE; + } + + onUpdate(updatedDe); + }, + [de, onUpdate] + ); + + // Memoize mask string value + const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]); + + // Memoize disabled states + const isFavoriteDisabled = useMemo( + () => + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) || + de.n === undefined, + [de.m, de.n] + ); + + const isReadonlyDisabled = useMemo( + () => + !de.w || + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE), + [de.w, de.m] + ); + + const isApiMqttExcludeDisabled = useMemo( + () => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.n, de.m] + ); + + const isWebExcludeDisabled = useMemo( + () => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.n, de.m] + ); + + // Memoize mask flag checks + const isFavoriteSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_FAVORITE), + [de.m] + ); + const isReadonlySet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_READONLY), + [de.m] + ); + const isApiMqttExcludeSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE), + [de.m] + ); + const isWebExcludeSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE), + [de.m] + ); + const isDeletedSet = useMemo( + () => hasMask(de.m, DeviceEntityMask.DV_DELETED), + [de.m] + ); return ( { - 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); - }} + value={maskStringValue} + onChange={handleChange} > - - + + - = 3}> - + + - - + + - - + + - + ); diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index 81686d44e..fc13bf85e 100644 --- a/interface/src/app/main/Help.tsx +++ b/interface/src/app/main/Help.tsx @@ -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 CommentIcon from '@mui/icons-material/CommentTwoTone'; @@ -19,6 +20,7 @@ import { Stack, Typography } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; import { useRequest } from 'alova/client'; import { SectionContent, useLayoutTitle } from 'components'; @@ -29,26 +31,62 @@ import { saveFile } from 'utils'; import { API, callAction } from '../../api/app'; 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 = { + borderRadius: 3, + border: '1px solid lightblue', + justifyContent: 'space-evenly', + alignItems: 'center' +}; + +const IMAGE_STYLES: SxProps = { + maxHeight: { xs: 100, md: 250 } +}; + +const AVATAR_STYLES: SxProps = { + bgcolor: '#72caf9' +}; + +const HelpComponent = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.HELP()); const { me } = useContext(AuthenticatedContext); - const [customSupportIMG, setCustomSupportIMG] = useState(null); - const [customSupportHTML, setCustomSupportHTML] = useState(null); - const [notFound, setNotFound] = useState(false); + const [customSupport, setCustomSupport] = useState({ + img_url: null, + html: null + }); + const [imgError, setImgError] = useState(false); - useRequest(() => callAction({ action: 'getCustomSupport' })).onSuccess((event) => { - if (event && event.data && Object.keys(event.data).length !== 0) { - const data = (event.data as { Support: { img_url?: string; html?: string[] } }) - .Support; - if (data.img_url) { - setCustomSupportIMG(data.img_url); - } - if (data.html) { - setCustomSupportHTML(data.html.join('
')); - } + // Memoize the request method to prevent re-creation on every render + const getCustomSupportMethod = useMemo( + () => callAction({ action: 'getCustomSupport' }), + [] + ); + + useRequest(getCustomSupportMethod).onSuccess((event) => { + if (event?.data && Object.keys(event.data).length !== 0) { + const { Support } = event.data as { + Support: { img_url?: string; html?: string[] }; + }; + setCustomSupport({ + img_url: Support.img_url || null, + html: Support.html?.join('
') || null + }); } }); @@ -63,90 +101,88 @@ const Help = () => { toast.error(String(error.error?.message || 'An error occurred')); }); + // Optimize API call memoization + const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []); + + const handleDownloadSystemInfo = useCallback(() => { + void sendAPI(apiCall); + }, [sendAPI, apiCall]); + + const handleImageError = useCallback(() => { + setImgError(true); + }, []); + + // Memoize help links to prevent recreation on every render + const helpLinks: HelpLink[] = useMemo( + () => [ + { + href: 'https://docs.emsesp.org', + icon: , + label: () => LL.HELP_INFORMATION_1() + }, + { + href: 'https://discord.gg/3J3GgnzpyT', + icon: , + label: () => LL.HELP_INFORMATION_2() + }, + { + href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose', + icon: , + label: () => LL.HELP_INFORMATION_3() + } + ], + [LL] + ); + + const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]); + + // Memoize image source computation + const imageSrc = useMemo( + () => + imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url, + [imgError, customSupport.img_url] + ); + return ( - {customSupportHTML && ( + {customSupport.html && ( } - sx={{ - borderRadius: 3, - border: '1px solid lightblue', - justifyContent: 'space-evenly', - alignItems: 'center' - }} + sx={SUPPORT_BOX_STYLES} > -
+
setNotFound(true)} - src={ - notFound - ? '' - : customSupportIMG || - 'https://docs.emsesp.org/_media/images/installer.jpeg' - } + sx={IMAGE_STYLES} + onError={handleImageError} + src={imageSrc} /> )} - {me.admin && ( + {isAdmin && ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {helpLinks.map(({ href, icon, label }) => ( + + + + {icon} + + + + + ))} )} @@ -158,7 +194,7 @@ const Help = () => { startIcon={} variant="outlined" color="primary" - onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })} + onClick={handleDownloadSystemInfo} > {LL.SUPPORT_INFORMATION(0)} @@ -174,11 +210,14 @@ const Help = () => { href="https://emsesp.org" color="primary" > - {'emsesp.org'} + emsesp.org ); }; +// Memoize the component to prevent unnecessary re-renders +const Help = memo(HelpComponent); + export default Help; diff --git a/interface/src/app/main/Modules.tsx b/interface/src/app/main/Modules.tsx index d9ee062c8..35d191fdd 100644 --- a/interface/src/app/main/Modules.tsx +++ b/interface/src/app/main/Modules.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -31,6 +31,19 @@ import { readModules, writeModules } from '../../api/app'; import ModulesDialog from './ModulesDialog'; import type { ModuleItem } from './types'; +const PENDING_COLOR = 'red'; +const ACTIVATED_COLOR = '#00FF7F'; + +const hasModulesChanged = (mi: ModuleItem): boolean => + mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; + +const ColorStatus = memo(({ status }: { status: number }) => { + if (status === 1) { + return
Pending Activation
; + } + return
Activated
; +}); + const Modules = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -56,105 +69,107 @@ const Modules = () => { } ); - const modules_theme = useTheme({ - Table: ` - --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; - `, - BaseRow: ` - font-size: 14px; - .td { - height: 32px; - } - `, - BaseCell: ` - &:nth-of-type(1) { - text-align: center; - } - `, - HeaderRow: ` - text-transform: uppercase; - background-color: black; - color: #90CAF9; - .th { - border-bottom: 1px solid #565656; - height: 36px; - } - `, - Row: ` - background-color: #1e1e1e; - position: relative; - cursor: pointer; - .td { - border-top: 1px solid #565656; - border-bottom: 1px solid #565656; - } - &:hover .td { - border-top: 1px solid #177ac9; - border-bottom: 1px solid #177ac9; - } - &:nth-of-type(odd) .td { - background-color: #303030; - } - ` - }); + const modules_theme = useTheme( + useMemo( + () => ({ + Table: ` + --data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px; + `, + BaseRow: ` + font-size: 14px; + .td { + height: 32px; + } + `, + BaseCell: ` + &:nth-of-type(1) { + text-align: center; + } + `, + HeaderRow: ` + text-transform: uppercase; + background-color: black; + color: #90CAF9; + .th { + border-bottom: 1px solid #565656; + height: 36px; + } + `, + Row: ` + background-color: #1e1e1e; + position: relative; + cursor: pointer; + .td { + border-top: 1px solid #565656; + border-bottom: 1px solid #565656; + } + &:hover .td { + border-top: 1px solid #177ac9; + border-bottom: 1px solid #177ac9; + } + &:nth-of-type(odd) .td { + background-color: #303030; + } + ` + }), + [] + ) + ); - const onDialogClose = () => { + const onDialogClose = useCallback(() => { setDialogOpen(false); - }; + }, []); - const onDialogSave = (updatedItem: ModuleItem) => { - setDialogOpen(false); - updateModuleItem(updatedItem); - }; + 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); + updateModuleItem(updatedItem); + }, + [updateModuleItem] + ); const editModuleItem = useCallback((mi: ModuleItem) => { setSelectedModuleItem(mi); setDialogOpen(true); }, []); - const onCancel = async () => { + const onCancel = useCallback(async () => { await fetchModules().then(() => { setNumChanges(0); }); - }; + }, [fetchModules]); - function hasModulesChanged(mi: ModuleItem) { - 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 + const saveModules = useCallback(async () => { + try { + await Promise.all( + modules.map((condensed_mi: ModuleItem) => + updateModules({ + key: condensed_mi.key, + enabled: condensed_mi.enabled, + license: condensed_mi.license + }) + ) ); - setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length); - return new_data; - }); - }; + toast.success(LL.MODULES_UPDATED()); + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)); + } finally { + await fetchModules(); + setNumChanges(0); + } + }, [modules, updateModules, LL, fetchModules]); - const saveModules = async () => { - await Promise.all( - modules.map((condensed_mi: ModuleItem) => - updateModules({ - key: condensed_mi.key, - enabled: condensed_mi.enabled, - license: condensed_mi.license - }) - ) - ) - .then(() => { - toast.success(LL.MODULES_UPDATED()); - }) - .catch((error: Error) => { - toast.error(error.message); - }) - .finally(async () => { - await fetchModules(); - setNumChanges(0); - }); - }; - - const renderContent = () => { + const content = useMemo(() => { if (!modules) { return ( @@ -169,13 +184,6 @@ const Modules = () => { ); } - const colorStatus = (status: number) => { - if (status === 1) { - return
Pending Activation
; - } - return
Activated
; - }; - return ( <> @@ -218,7 +226,9 @@ const Modules = () => { {mi.author} {mi.version} {mi.message} - {colorStatus(mi.status)} + + + ))} @@ -252,12 +262,22 @@ const Modules = () => { ); - }; + }, [ + modules, + fetchModules, + error, + modules_theme, + editModuleItem, + LL, + numChanges, + onCancel, + saveModules + ]); return ( {blocker ? : null} - {renderContent()} + {content} {selectedModuleItem && ( (selectedItem); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); + // Sync form state when dialog opens or selected item changes useEffect(() => { if (open) { setEditItem(selectedItem); } }, [open, selectedItem]); - const close = () => { - onClose(); - }; - - const save = () => { + const handleSave = useCallback(() => { onSave(editItem); - }; + }, [editItem, onSave]); + + const dialogTitle = useMemo( + () => `${LL.EDIT()} ${editItem.key}`, + [LL, editItem.key] + ); return ( - {LL.EDIT() + ' ' + editItem.key} + {dialogTitle} } variant="outlined" - onClick={close} + onClick={onClose} color="secondary" > {LL.CANCEL()} @@ -93,7 +103,7 @@ const ModulesDialog = ({
!si.deleted) - .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags) - }} + data={{ nodes: filteredAndSortedSchedule }} theme={schedule_theme} layout={{ custom: true }} > @@ -275,22 +308,15 @@ const Scheduler = () => { {tableList.map((si: ScheduleItem) => ( editScheduleItem(si)}> - {si.active ? ( - - ) : ( - - )} + - {si.flags > 127 ? ( + {si.flags > SCHEDULE_FLAG_THRESHOLD ? ( scheduleType(si) ) : ( <> @@ -316,7 +342,17 @@ const Scheduler = () => { )}
); - }; + }, [ + schedule, + error, + fetchSchedule, + filteredAndSortedSchedule, + schedule_theme, + editScheduleItem, + LL, + dayBox, + scheduleType + ]); return ( diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index 812a99a81..06495fd94 100644 --- a/interface/src/app/main/SchedulerDialog.tsx +++ b/interface/src/app/main/SchedulerDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import AddIcon from '@mui/icons-material/Add'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -31,6 +31,34 @@ import { validate } from 'validators'; import { ScheduleFlag } from './types'; import type { ScheduleItem } from './types'; +// Constants +const FLAG_MASK_127 = 127; +const SCHEDULE_TYPE_THRESHOLD = 128; +const DEFAULT_TIME = '00:00'; +const TYPOGRAPHY_FONT_SIZE = 10; + +// Day of week flag configuration (static, defined outside component) +const DAY_FLAGS = [ + { value: '2', flag: ScheduleFlag.SCHEDULE_MON }, + { value: '4', flag: ScheduleFlag.SCHEDULE_TUE }, + { value: '8', flag: ScheduleFlag.SCHEDULE_WED }, + { value: '16', flag: ScheduleFlag.SCHEDULE_THU }, + { value: '32', flag: ScheduleFlag.SCHEDULE_FRI }, + { value: '64', flag: ScheduleFlag.SCHEDULE_SAT }, + { value: '1', flag: ScheduleFlag.SCHEDULE_SUN } +] as const; + +// Day of week flag values array (static) +const FLAG_VALUES = [ + ScheduleFlag.SCHEDULE_SUN, + ScheduleFlag.SCHEDULE_MON, + ScheduleFlag.SCHEDULE_TUE, + ScheduleFlag.SCHEDULE_WED, + ScheduleFlag.SCHEDULE_THU, + ScheduleFlag.SCHEDULE_FRI, + ScheduleFlag.SCHEDULE_SAT +] as const; + interface SchedulerDialogProps { open: boolean; creating: boolean; @@ -53,110 +81,163 @@ const SchedulerDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const [scheduleType, setScheduleType] = useState(); - const updateFormValue = updateValue(setEditItem); + const updateFormValue = useMemo( + () => + updateValue( + setEditItem as unknown as React.Dispatch< + React.SetStateAction> + > + ), + [] + ); useEffect(() => { if (open) { setFieldErrors(undefined); setEditItem(selectedItem); - // set the flags based on type when page is loaded... + // Set the flags based on type when page is loaded: // 0-127 is day schedule // 128 is timer // 129 is on change // 130 is on condition // 132 is immediate setScheduleType( - selectedItem.flags < 128 ? ScheduleFlag.SCHEDULE_DAY : selectedItem.flags + selectedItem.flags < SCHEDULE_TYPE_THRESHOLD + ? ScheduleFlag.SCHEDULE_DAY + : selectedItem.flags ); } }, [open, selectedItem]); - const save = async () => { - try { - setFieldErrors(undefined); - await validate(validator, editItem); - onSave(editItem); - } catch (error) { - setFieldErrors(error as ValidateFieldsError); - } - }; - - 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) => ( - - {dow[Math.log(flag) / Math.log(2)]} - + // Helper function to handle save operations + const handleSave = useCallback( + async (itemToSave: ScheduleItem) => { + try { + setFieldErrors(undefined); + await validate(validator, itemToSave); + onSave(itemToSave); + } catch (error) { + setFieldErrors(error as ValidateFieldsError); + } + }, + [validator, onSave] ); - const handleClose = ( - _event: React.SyntheticEvent, - reason: 'backdropClick' | 'escapeKeyDown' - ) => { - if (reason !== 'backdropClick') { - onClose(); + const save = useCallback(async () => { + await handleSave(editItem); + }, [editItem, handleSave]); + + const saveandactivate = useCallback(async () => { + await handleSave({ ...editItem, active: true }); + }, [editItem, handleSave]); + + const remove = useCallback(() => { + onSave({ ...editItem, deleted: true }); + }, [editItem, onSave]); + + // Optimize DOW flag conversion + const getFlagDOWnumber = useCallback((flags: string[]) => { + return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127; + }, []); + + const getFlagDOWstring = useCallback((f: number) => { + return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => + String(flag) + ); + }, []); + + // Day of week display component + const DayOfWeekButton = useCallback( + (flag: number) => { + const dayIndex = Math.log2(flag); + const isSelected = (editItem.flags & flag) === flag; + return ( + + {dow[dayIndex]} + + ); + }, + [editItem.flags, dow] + ); + + const handleClose = useCallback( + (_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => { + if (reason !== 'backdropClick') { + onClose(); + } + }, + [onClose] + ); + + const handleScheduleTypeChange = useCallback( + (_event: React.SyntheticEvent, flag: ScheduleFlag | null) => { + if (flag !== null) { + setFieldErrors(undefined); // clear any validation errors + setScheduleType(flag); + // wipe the time field when changing the schedule type + // set the flags based on type + const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? 0 : flag; + setEditItem((prev) => ({ ...prev, time: '', flags: newFlags })); + } + }, + [] + ); + + const handleDOWChange = useCallback( + (_event: React.SyntheticEvent, flags: string[]) => { + const newFlags = getFlagDOWnumber(flags); + setEditItem((prev) => ({ ...prev, flags: newFlags })); + }, + [getFlagDOWnumber] + ); + + // Memoize derived values + const isDaySchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_DAY, + [scheduleType] + ); + const isTimerSchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_TIMER, + [scheduleType] + ); + const isImmediateSchedule = useMemo( + () => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE, + [scheduleType] + ); + const needsTimeField = useMemo( + () => isDaySchedule || isTimerSchedule, + [isDaySchedule, isTimerSchedule] + ); + + const dowFlags = useMemo( + () => getFlagDOWstring(editItem.flags), + [editItem.flags, getFlagDOWstring] + ); + + const timeFieldValue = useMemo(() => { + if (needsTimeField) { + return editItem.time === '' ? DEFAULT_TIME : editItem.time; } - }; + return editItem.time === DEFAULT_TIME ? '' : editItem.time; + }, [editItem.time, needsTimeField]); + + const timeFieldLabel = useMemo(() => { + if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); + if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); + if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); + if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); + return LL.TIME(1); + }, [scheduleType, LL]); return ( - {creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()}  + {creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}  {LL.SCHEDULE(1)} @@ -166,47 +247,27 @@ const SchedulerDialog = ({ value={scheduleType} exclusive disabled={!creating} - onChange={(_event, flag: ScheduleFlag) => { - 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 } - ); - } - }} + onChange={handleScheduleTypeChange} > {LL.SCHEDULE(0)} {LL.TIMER(0)} {LL.IMMEDIATE()} - {scheduleType === ScheduleFlag.SCHEDULE_DAY && ( + {isDaySchedule && ( { - setEditItem({ ...editItem, flags: getFlagDOWnumber(flag) }); - }} + value={dowFlags} + onChange={handleDOWChange} > - - {showDOW(editItem, ScheduleFlag.SCHEDULE_MON)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_TUE)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_WED)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_THU)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_FRI)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_SAT)} - - - {showDOW(editItem, ScheduleFlag.SCHEDULE_SUN)} - + {DAY_FLAGS.map(({ value, flag }) => ( + + {DayOfWeekButton(flag)} + + ))} )} - {scheduleType !== ScheduleFlag.SCHEDULE_IMMEDIATE && ( + {!isImmediateSchedule && ( <> - {scheduleType === ScheduleFlag.SCHEDULE_DAY || - scheduleType === ScheduleFlag.SCHEDULE_TIMER ? ( + {needsTimeField ? ( <> - {scheduleType === ScheduleFlag.SCHEDULE_TIMER && ( + {isTimerSchedule && ( {LL.SCHEDULER_HELP_2()} @@ -310,16 +346,10 @@ const SchedulerDialog = ({ ) : ( @@ -386,7 +416,7 @@ const SchedulerDialog = ({ > {creating ? LL.ADD(0) : LL.UPDATE()} - {scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && ( + {isImmediateSchedule && editItem.cmd !== '' && ( + const gridButtons = downloadButtons.filter((btn) => btn.isGridButton); + const standaloneButton = downloadButtons.find((btn) => !btn.isGridButton); - - - - + return ( + + + {LL.DOWNLOAD(0)} + + + + {LL.DOWNLOAD_SETTINGS_TEXT()}. + + + + {gridButtons.map((button) => ( + + + + ))} + + + + {LL.DOWNLOAD_SETTINGS_TEXT2()}. + + + {standaloneButton && ( + )} - - {LL.UPLOAD()} - + + {LL.UPLOAD()} + - - {LL.UPLOAD_TEXT()}. - + + {LL.UPLOAD_TEXT()}. + - - - ); - }; - - return ( - {restarting ? : content()} + + ); }; diff --git a/interface/src/app/settings/MqttSettings.tsx b/interface/src/app/settings/MqttSettings.tsx index 917e9fe90..2ee9e3c8a 100644 --- a/interface/src/app/settings/MqttSettings.tsx +++ b/interface/src/app/settings/MqttSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import WarningIcon from '@mui/icons-material/Warning'; @@ -52,33 +52,67 @@ const MqttSettings = () => { const [fieldErrors, setFieldErrors] = useState(); - const updateFormValue = updateValueDirty( - origData, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void + const updateFormValue = useMemo( + () => + updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void + ), + [origData, dirtyFlags, setDirtyFlags, updateDataValue] ); - const SecondsInputProps = { - endAdornment: {LL.SECONDS()} - }; + const SecondsInputProps = useMemo( + () => ({ + endAdornment: {LL.SECONDS()} + }), + [LL] + ); - const content = () => { - if (!data) { - return ; + const emptyFieldErrors = useMemo(() => ({}), []); + + const validateAndSubmit = useCallback(async () => { + if (!data) return; + try { + setFieldErrors(undefined); + await validate(createMqttSettingsValidator(data), data); + await saveData(); + } catch (error) { + setFieldErrors(error as ValidateFieldsError); } + }, [data, saveData]); - const validateAndSubmit = async () => { - try { - setFieldErrors(undefined); - await validate(createMqttSettingsValidator(data), data); - await saveData(); - } catch (error) { - setFieldErrors(error as ValidateFieldsError); - } - }; + 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 ( + + {blocker ? : null} + + + ); + } + + return ( + + {blocker ? : null} <> { { { { { { {LL.MQTT_PUBLISH_INTERVALS()} (0=auto) - - - - - - - - - - - - - - - - - - - - - - - - + {publishIntervalFields.map((field) => ( + + {field.validated ? ( + + ) : ( + + )} + + ))} {dirtyFlags && dirtyFlags.length !== 0 && ( @@ -491,13 +448,6 @@ const MqttSettings = () => { )} - ); - }; - - return ( - - {blocker ? : null} - {content()} ); }; diff --git a/interface/src/app/settings/NTPSettings.tsx b/interface/src/app/settings/NTPSettings.tsx index 0c1f618d1..6b79b32ce 100644 --- a/interface/src/app/settings/NTPSettings.tsx +++ b/interface/src/app/settings/NTPSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; @@ -39,7 +39,7 @@ import { formatLocalDateTime, updateValueDirty, useRest } from 'utils'; import { validate } from 'validators'; import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp'; -import { TIME_ZONES, selectedTimeZone, timeZoneSelectItems } from './TZ'; +import { TIME_ZONES, selectedTimeZone, useTimeZoneSelectItems } from './TZ'; const NTPSettings = () => { const { @@ -61,9 +61,19 @@ const NTPSettings = () => { const { LL } = useI18nContext(); 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(''); const [settingTime, setSettingTime] = useState(false); const [processing, setProcessing] = useState(false); + const [fieldErrors, setFieldErrors] = useState(); const { send: updateTime } = useRequest( (local_time: Time) => NTPApi.updateTime(local_time), @@ -72,110 +82,79 @@ const NTPSettings = () => { } ); - const updateFormValue = updateValueDirty( - origData, - dirtyFlags, - setDirtyFlags, - updateDataValue as (value: unknown) => void + // Memoize updateFormValue to prevent recreation on every render + const updateFormValue = useMemo( + () => + updateValueDirty( + origData as unknown as Record, + dirtyFlags, + setDirtyFlags, + updateDataValue as (value: unknown) => void + ), + [origData, dirtyFlags, setDirtyFlags, updateDataValue] ); - const [fieldErrors, setFieldErrors] = useState(); + // Memoize updateLocalTime handler + const updateLocalTime = useCallback( + (event: React.ChangeEvent) => setLocalTime(event.target.value), + [] + ); - const updateLocalTime = (event: React.ChangeEvent) => - setLocalTime(event.target.value); - - const openSetTime = () => { + // Memoize openSetTime handler + const openSetTime = useCallback(() => { setLocalTime(formatLocalDateTime(new Date())); setSettingTime(true); - }; + }, []); - const configureTime = async () => { + // Memoize configureTime handler + const configureTime = useCallback(async () => { setProcessing(true); - await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) }) - .then(async () => { - toast.success(LL.TIME_SET()); - setSettingTime(false); - await loadData(); - }) - .catch(() => { - toast.error(LL.PROBLEM_UPDATING()); - }) - .finally(() => { - setProcessing(false); - }); - }; - - const renderSetTimeDialog = () => ( - setSettingTime(false)} - > - {LL.SET_TIME(1)} - - - {LL.SET_TIME_TEXT()} - - - - - - - - - ); - - const content = () => { - if (!data) { - return ; + try { + await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) }); + toast.success(LL.TIME_SET()); + setSettingTime(false); + await loadData(); + } catch { + toast.error(LL.PROBLEM_UPDATING()); + } finally { + setProcessing(false); } + }, [localTime, updateTime, LL, loadData]); - const validateAndSubmit = async () => { - try { - setFieldErrors(undefined); - await validate(NTP_SETTINGS_VALIDATOR, data); - await saveData(); - } catch (error) { - setFieldErrors(error as ValidateFieldsError); - } - }; + // Memoize close dialog handler + const handleCloseSetTime = useCallback(() => setSettingTime(false), []); - const changeTimeZone = (event: React.ChangeEvent) => { + // Memoize validate and submit handler + const validateAndSubmit = useCallback(async () => { + if (!data) return; + try { + setFieldErrors(undefined); + await validate(NTP_SETTINGS_VALIDATOR, data); + await saveData(); + } catch (error) { + setFieldErrors(error as ValidateFieldsError); + } + }, [data, saveData]); + + // Memoize timezone change handler + const changeTimeZone = useCallback( + (event: React.ChangeEvent) => { void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({ ...settings, tz_label: event.target.value, tz_format: TIME_ZONES[event.target.value] })); updateFormValue(event); - }; + }, + [updateFormValue] + ); + + // Memoize render content to prevent unnecessary re-renders + const renderContent = useMemo(() => { + if (!data) { + return ; + } return ( <> @@ -205,13 +184,13 @@ const NTPSettings = () => { label={LL.TIME_ZONE()} fullWidth variant="outlined" - value={selectedTimeZone(data.tz_label, data.tz_format)} + value={selectedTzValue} onChange={changeTimeZone} margin="normal" select > {LL.TIME_ZONE()}... - {timeZoneSelectItems()} + {timeZoneItems} @@ -230,7 +209,6 @@ const NTPSettings = () => { )}
- {renderSetTimeDialog()} {dirtyFlags && dirtyFlags.length !== 0 && ( @@ -258,12 +236,66 @@ const NTPSettings = () => { )} ); - }; + }, [ + data, + errorMessage, + loadData, + updateFormValue, + fieldErrors, + selectedTzValue, + changeTimeZone, + timeZoneItems, + dirtyFlags, + openSetTime, + saving, + validateAndSubmit, + LL + ]); return ( {blocker ? : null} - {content()} + {renderContent} + + {LL.SET_TIME(1)} + + + {LL.SET_TIME_TEXT()} + + + + + + + + ); }; diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index 2f596a285..57472392d 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -34,46 +34,25 @@ const Settings = () => { const { LL } = useI18nContext(); useLayoutTitle(LL.SETTINGS(0)); - const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); + const [confirmFactoryReset, setConfirmFactoryReset] = useState(false); const { send: sendAPI } = useRequest((data: APIcall) => API(data), { immediate: false }); - const doFormat = async () => { + const doFormat = useCallback(async () => { await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => { setConfirmFactoryReset(false); }); - }; + }, [sendAPI]); - const renderFactoryResetDialog = () => ( - setConfirmFactoryReset(false)} - > - {LL.FACTORY_RESET()} - {LL.SYSTEM_FACTORY_TEXT_DIALOG()} - - - - - - ); + const handleFactoryResetClose = useCallback(() => { + setConfirmFactoryReset(false); + }, []); + + const handleFactoryResetClick = useCallback(() => { + setConfirmFactoryReset(true); + }, []); return ( @@ -142,7 +121,32 @@ const Settings = () => { /> - {renderFactoryResetDialog()} + + {LL.FACTORY_RESET()} + {LL.SYSTEM_FACTORY_TEXT_DIALOG()} + + + + + @@ -156,7 +160,7 @@ const Settings = () => { - - -
+ const handleCloseRestartDialog = useCallback(() => { + setConfirmRestart(false); + }, []); + + const renderRestartDialog = useMemo( + () => ( + + {LL.RESTART()} + {LL.RESTART_CONFIRM()} + + + + + + ), + [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) { return ; } @@ -258,7 +306,7 @@ const SystemStatus = () => { icon={BuildIcon} bgcolor="#72caf9" label="EMS-ESP Firmware" - text={'v' + data.emsesp_version} + text={firmwareVersion} to="version" /> @@ -268,16 +316,13 @@ const SystemStatus = () => { - + {me.admin && ( @@ -289,29 +334,25 @@ const SystemStatus = () => { icon={MemoryIcon} bgcolor="#68374d" label={LL.HARDWARE()} - text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()} + text={freeMemoryText} to="/status/hardwarestatus" /> @@ -320,16 +361,16 @@ const SystemStatus = () => { icon={DeviceHubIcon} bgcolor={activeHighlight(data.mqtt_status)} label="MQTT" - text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)} + text={mqttStatusText} to="/status/mqtt" /> @@ -338,7 +379,7 @@ const SystemStatus = () => { icon={SettingsInputAntennaIcon} bgcolor={activeHighlight(data.ap_status)} label={LL.ACCESS_POINT(0)} - text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)} + text={apStatusText} to="/status/ap" /> @@ -352,14 +393,33 @@ const SystemStatus = () => { /> - {renderRestartDialog()} + {renderRestartDialog} ); - }; + }, [ + data, + LL, + firmwareVersion, + uptimeText, + freeMemoryText, + networkIcon, + mqttStatusText, + apStatusText, + busStatus, + busStatusHighlight, + networkStatusHighlight, + networkStatus, + ntpStatusHighlight, + ntpStatus, + activeHighlight, + me.admin, + handleRestartClick, + error, + loadData, + renderRestartDialog + ]); - return ( - {restarting ? : content()} - ); + return {restarting ? : content}; }; export default SystemStatus; diff --git a/interface/src/app/status/SystemLog.tsx b/interface/src/app/status/SystemLog.tsx index cafe73755..84663c19d 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,4 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -31,6 +38,8 @@ import type { LogEntry, LogSettings } from 'types'; import { LogLevel } from 'types'; import { updateValueDirty, useRest } from 'utils'; +const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues + const TextColors: Record = { [LogLevel.ERROR]: '#ff0000', // red [LogLevel.WARNING]: '#ff0000', // red @@ -47,11 +56,6 @@ const LogEntryLine = styled('span')( }) ); -const topOffset = () => - document.getElementById('log-window')?.getBoundingClientRect().bottom || 0; -const leftOffset = () => - document.getElementById('log-window')?.getBoundingClientRect().left || 0; - const levelLabel = (level: LogLevel) => { switch (level) { case LogLevel.ERROR: @@ -71,6 +75,39 @@ const levelLabel = (level: LogLevel) => { } }; +const paddedLevelLabel = (level: LogLevel, compact: boolean) => { + const label = levelLabel(level); + return compact ? ' ' + label[0] : label.padStart(8, '\xa0'); +}; + +const paddedNameLabel = (name: string, compact: boolean) => { + const label = '[' + name + ']'; + return compact ? label : label.padEnd(12, '\xa0'); +}; + +const paddedIDLabel = (id: number, compact: boolean) => { + const label = id + ':'; + return compact ? label : label.padEnd(7, '\xa0'); +}; + +// Memoized log entry component to prevent unnecessary re-renders +const LogEntryItem = memo( + ({ entry, compact }: { entry: LogEntry; compact: boolean }) => { + return ( +
+ {entry.t} + {paddedLevelLabel(entry.l, compact)}  + {paddedIDLabel(entry.i, compact)} + {paddedNameLabel(entry.n, compact)} + {entry.m} +
+ ); + }, + (prevProps, nextProps) => + prevProps.entry.i === nextProps.entry.i && + prevProps.compact === nextProps.compact +); + const SystemLog = () => { const { LL } = useI18nContext(); @@ -102,54 +139,89 @@ const SystemLog = () => { const [readOpen, setReadOpen] = useState(false); const [logEntries, setLogEntries] = useState([]); const [autoscroll, setAutoscroll] = useState(true); - const [lastId, setLastId] = useState(-1); + const [boxPosition, setBoxPosition] = useState({ top: 0, left: 0 }); const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/; const updateFormValue = updateValueDirty( - origData, + origData as unknown as Record, dirtyFlags, setDirtyFlags, updateDataValue as (value: unknown) => void ); + // Calculate box position after layout + useLayoutEffect(() => { + const logWindow = document.getElementById('log-window'); + if (!logWindow) { + return; + } + + const updatePosition = () => { + const windowElement = document.getElementById('log-window'); + if (!windowElement) { + return; + } + const rect = windowElement.getBoundingClientRect(); + setBoxPosition({ top: rect.bottom, left: rect.left }); + }; + + updatePosition(); + + // Debounce resize events with requestAnimationFrame + let rafId: number; + const handleResize = () => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(updatePosition); + }; + + // Update position on window resize + window.addEventListener('resize', handleResize); + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(logWindow); + + return () => { + window.removeEventListener('resize', handleResize); + resizeObserver.disconnect(); + cancelAnimationFrame(rafId); + }; + }, [data]); // Recalculate when data changes (in case layout shifts) + + // Memoize message handler to avoid recreating on every render + const handleLogMessage = useCallback((message: { data: string }) => { + const rawData = message.data; + const logentry = JSON.parse(rawData) as LogEntry; + setLogEntries((log) => { + // Skip if this is a duplicate entry (check last entry id) + if (log.length > 0) { + const lastEntry = log[log.length - 1]; + if (lastEntry && logentry.i <= lastEntry.i) { + return log; + } + } + const newLog = [...log, logentry]; + // Limit log entries to prevent memory issues - only slice when necessary + if (newLog.length > MAX_LOG_ENTRIES) { + return newLog.slice(-MAX_LOG_ENTRIES); + } + return newLog; + }); + }, []); + useSSE(fetchLogES, { immediate: true, interceptByGlobalResponded: false }) - .onMessage((message: { data: string }) => { - const rawData = message.data; - const logentry = JSON.parse(rawData) as LogEntry; - if (lastId < logentry.i) { - setLogEntries((log) => [...log, logentry]); - setLastId(logentry.i); - } - }) + .onMessage(handleLogMessage) .onError(() => { toast.error('No connection to Log service'); }); - const paddedLevelLabel = (level: LogLevel) => { - const label = levelLabel(level); - return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0'); - }; + const onDownload = useCallback(() => { + const result = logEntries + .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'); a.setAttribute( 'href', @@ -159,24 +231,28 @@ const SystemLog = () => { document.body.appendChild(a); a.click(); document.body.removeChild(a); - }; + }, [logEntries]); - const saveSettings = async () => { + const saveSettings = useCallback(async () => { await saveData(); - }; + }, [saveData]); - // handle scrolling + // handle scrolling - optimized to only scroll when needed const ref = useRef(null); + const logWindowRef = useRef(null); + useEffect(() => { if (logEntries.length && autoscroll) { - ref.current?.scrollIntoView({ - behavior: 'smooth', - block: 'end' - }); + const container = logWindowRef.current; + if (container) { + requestAnimationFrame(() => { + container.scrollTop = container.scrollHeight; + }); + } } - }, [logEntries.length]); + }, [logEntries.length, autoscroll]); - const sendReadCommand = () => { + const sendReadCommand = useCallback(() => { if (readValue === '') { setReadOpen(!readOpen); return; @@ -187,7 +263,7 @@ const SystemLog = () => { setReadOpen(false); setReadValue(''); } - }; + }, [readValue, readOpen, send]); const content = () => { if (!data) { @@ -279,6 +355,7 @@ const SystemLog = () => { > { setReadOpen(false); setReadValue(''); @@ -304,7 +381,7 @@ const SystemLog = () => { ) : ( <> {data.developer_mode && ( - + )} @@ -326,27 +403,20 @@ const SystemLog = () => { leftOffset(), - top: () => topOffset(), + left: boxPosition.left, + top: boxPosition.top, p: 1 }} > {logEntries.map((e) => ( -
- {e.t} - {paddedLevelLabel(e.l)}  - {paddedIDLabel(e.i)} - {paddedNameLabel(e.n)} - - {e.m} - -
+ ))}
diff --git a/interface/src/app/status/SystemMonitor.tsx b/interface/src/app/status/SystemMonitor.tsx index c1d43b05e..ba672f831 100644 --- a/interface/src/app/status/SystemMonitor.tsx +++ b/interface/src/app/status/SystemMonitor.tsx @@ -1,12 +1,11 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; -import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material'; +import { Box, Button, Typography } from '@mui/material'; import { callAction } from 'api/app'; import { readSystemStatus } from 'api/system'; -import { dialogStyle } from 'CustomTheme'; import { useRequest } from 'alova/client'; import MessageBox from 'components/MessageBox'; import { useI18nContext } from 'i18n/i18n-react'; @@ -17,11 +16,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW const SystemMonitor = () => { const [errorMessage, setErrorMessage] = useState(); - + const hasInitialized = useRef(false); const { LL } = useI18nContext(); - let count = 0; - const { send: setSystemStatus } = useRequest( (status: string) => callAction({ action: 'systemStatus', param: status }), { @@ -32,10 +29,12 @@ const SystemMonitor = () => { const { data, send } = useRequest(readSystemStatus, { force: true, async middleware(_, next) { - if (count++ >= 1) { - // skip first request (1 second) to allow AsyncWS to send its response - await next(); + // Skip first request to allow AsyncWS to send its response + if (!hasInitialized.current) { + hasInitialized.current = true; + return; // Don't await next() on first call } + await next(); } }) .onSuccess((event) => { @@ -58,33 +57,82 @@ const SystemMonitor = () => { void send(); }, 1000); // check every 1 second - const onCancel = async () => { + const { statusMessage, isUploading, progressValue } = useMemo(() => { + const status = data?.status; + + let message = ''; + if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) { + 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( - SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string - ); + await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL)); document.location.href = '/'; - }; + }, [setSystemStatus]); return ( - - - + + + + EMS-ESP - {data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING - ? 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()} + {statusMessage} {errorMessage ? ( @@ -105,20 +153,16 @@ const SystemMonitor = () => { {LL.PLEASE_WAIT()}… - {data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && ( + {isUploading && ( - + )} )} - - + + ); }; diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index 584ac975b..34d2430da 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -567,7 +567,10 @@ const Version = () => { {latestVersion?.name} - setShowVersionInfo(1)}> + setShowVersionInfo(1)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > {showButtons(false)} @@ -580,7 +583,10 @@ const Version = () => { {latestDevVersion?.name} - setShowVersionInfo(2)}> + setShowVersionInfo(2)} + aria-label={LL.FIRMWARE_VERSION_INFO()} + > {showButtons(true)} diff --git a/interface/src/components/ButtonRow.tsx b/interface/src/components/ButtonRow.tsx index f8f9a0002..8fff5e161 100644 --- a/interface/src/components/ButtonRow.tsx +++ b/interface/src/components/ButtonRow.tsx @@ -19,6 +19,4 @@ const ButtonRow = memo(({ children, ...rest }) => ( )); -ButtonRow.displayName = 'ButtonRow'; - export default ButtonRow; diff --git a/interface/src/components/MessageBox.tsx b/interface/src/components/MessageBox.tsx index 70196c722..010f96aec 100644 --- a/interface/src/components/MessageBox.tsx +++ b/interface/src/components/MessageBox.tsx @@ -1,11 +1,11 @@ -import type { FC } from 'react'; +import { type FC, memo, useMemo } from 'react'; import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined'; import ErrorIcon from '@mui/icons-material/Error'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined'; 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'; @@ -14,22 +14,18 @@ export interface MessageBoxProps extends BoxProps { message?: string; } -const LEVEL_ICONS: { - [type in MessageBoxLevel]: React.ComponentType; -} = { +const LEVEL_ICONS: Record> = { success: CheckCircleOutlineOutlinedIcon, info: InfoOutlinedIcon, warning: ReportProblemOutlinedIcon, error: ErrorIcon }; -const LEVEL_BACKGROUNDS: { - [type in MessageBoxLevel]: (theme: Theme) => string; -} = { - success: (theme: Theme) => theme.palette.success.dark, - info: (theme: Theme) => theme.palette.info.main, - warning: (theme: Theme) => theme.palette.warning.dark, - error: (theme: Theme) => theme.palette.error.dark +const LEVEL_PALETTE_PATHS: Record = { + success: 'success.dark', + info: 'info.main', + warning: 'warning.dark', + error: 'error.dark' }; const MessageBox: FC = ({ @@ -40,25 +36,38 @@ const MessageBox: FC = ({ ...rest }) => { const theme = useTheme(); - const Icon = LEVEL_ICONS[level]; - const backgroundColor = LEVEL_BACKGROUNDS[level](theme); - const color = 'white'; + + const { Icon, backgroundColor } = useMemo(() => { + const Icon = LEVEL_ICONS[level]; + const palettePath = LEVEL_PALETTE_PATHS[level]; + const [key, shade] = palettePath.split('.') as [ + keyof typeof theme.palette, + string + ]; + const paletteKey = theme.palette[key] as unknown as Record; + const backgroundColor = paletteKey[shade]; + + return { Icon, backgroundColor }; + }, [level, theme]); + return ( - - {message ?? ''} - - {children} + {(message || children) && ( + + {message} + {children} + + )} ); }; -export default MessageBox; +export default memo(MessageBox); diff --git a/interface/src/components/SectionContent.tsx b/interface/src/components/SectionContent.tsx index 97615bbc4..e6c2c561f 100644 --- a/interface/src/components/SectionContent.tsx +++ b/interface/src/components/SectionContent.tsx @@ -1,6 +1,8 @@ +import { memo } from 'react'; import type { FC } from 'react'; import { Paper } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; import type { RequiredChildrenProps } from 'utils'; @@ -8,16 +10,19 @@ interface SectionContentProps extends RequiredChildrenProps { id?: string; } -const SectionContent: FC = (props) => { - const { children, id } = props; - return ( - - {children} - - ); +// Extract styles to avoid recreation on every render +const paperStyles: SxProps = { + p: 1.5, + m: 1.5, + borderRadius: 3, + border: '1px solid rgb(65, 65, 65)' }; -export default SectionContent; +const SectionContent: FC = ({ children, id }) => ( + + {children} + +); + +// Memoize to prevent unnecessary re-renders +export default memo(SectionContent); diff --git a/interface/src/components/inputs/BlockFormControlLabel.tsx b/interface/src/components/inputs/BlockFormControlLabel.tsx index a07df2c87..8f7ba7672 100644 --- a/interface/src/components/inputs/BlockFormControlLabel.tsx +++ b/interface/src/components/inputs/BlockFormControlLabel.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { FC } from 'react'; import { FormControlLabel } from '@mui/material'; @@ -9,4 +10,4 @@ const BlockFormControlLabel: FC = (props) => (
); -export default BlockFormControlLabel; +export default memo(BlockFormControlLabel); diff --git a/interface/src/components/inputs/LanguageSelector.tsx b/interface/src/components/inputs/LanguageSelector.tsx index 9e3b12162..3b862a35f 100644 --- a/interface/src/components/inputs/LanguageSelector.tsx +++ b/interface/src/components/inputs/LanguageSelector.tsx @@ -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'; @@ -17,73 +19,68 @@ import { I18nContext } from 'i18n/i18n-react'; import type { Locales } from 'i18n/i18n-types'; import { loadLocaleAsync } from 'i18n/i18n-util.async'; -const LanguageSelector = () => { - const { setLocale, locale } = useContext(I18nContext); +// Extract style to constant to prevent recreation +const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' }; - const onLocaleSelected: ChangeEventHandler = async ({ - target - }) => { - const loc = target.value as Locales; - localStorage.setItem('lang', loc); - await loadLocaleAsync(loc); - setLocale(loc); - }; +// 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 { setLocale, locale, LL } = useContext(I18nContext); + + const onLocaleSelected: ChangeEventHandler = useCallback( + async ({ target }) => { + const loc = target.value as Locales; + localStorage.setItem('lang', loc); + await loadLocaleAsync(loc); + setLocale(loc); + }, + [setLocale] + ); + + // Memoize menu items to prevent recreation on every render + const menuItems = useMemo( + () => + LANGUAGE_OPTIONS.map(({ key, flag, label }) => ( + + {label} +  {label} + + )), + [] + ); return ( - - -  CZ - - - -  DE - - - -  EN - - - -  FR - - - -  IT - - - -  NL - - - -  NO - - - -  PL - - - -  SK - - - -  SV - - - -  TR - + {menuItems} ); }; -export default LanguageSelector; +export default memo(LanguageSelector); diff --git a/interface/src/components/inputs/ValidatedPasswordField.tsx b/interface/src/components/inputs/ValidatedPasswordField.tsx index 5e28cf2be..44ab69995 100644 --- a/interface/src/components/inputs/ValidatedPasswordField.tsx +++ b/interface/src/components/inputs/ValidatedPasswordField.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import type { FC } from 'react'; import VisibilityIcon from '@mui/icons-material/Visibility'; @@ -13,6 +13,10 @@ type ValidatedPasswordFieldProps = Omit; const ValidatedPasswordField: FC = ({ ...props }) => { const [showPassword, setShowPassword] = useState(false); + const togglePasswordVisibility = useCallback(() => { + setShowPassword((prev) => !prev); + }, []); + return ( = ({ ...props }) = input: { endAdornment: ( - setShowPassword(!showPassword)} edge="end"> + {showPassword ? : } @@ -32,4 +40,4 @@ const ValidatedPasswordField: FC = ({ ...props }) = ); }; -export default ValidatedPasswordField; +export default memo(ValidatedPasswordField); diff --git a/interface/src/components/inputs/ValidatedTextField.tsx b/interface/src/components/inputs/ValidatedTextField.tsx index c52fd3d09..b7ccd3ae6 100644 --- a/interface/src/components/inputs/ValidatedTextField.tsx +++ b/interface/src/components/inputs/ValidatedTextField.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react'; import type { FC } from 'react'; import { FormHelperText, TextField } from '@mui/material'; @@ -20,7 +21,7 @@ const ValidatedTextField: FC = ({ return ( <> - + {errors?.map((e) => ( {e.message} ))} @@ -28,4 +29,4 @@ const ValidatedTextField: FC = ({ ); }; -export default ValidatedTextField; +export default memo(ValidatedTextField); diff --git a/interface/src/components/layout/Layout.tsx b/interface/src/components/layout/Layout.tsx index 0354255c4..711cf8ad1 100644 --- a/interface/src/components/layout/Layout.tsx +++ b/interface/src/components/layout/Layout.tsx @@ -13,7 +13,7 @@ import { LayoutContext } from './context'; export const DRAWER_WIDTH = 210; -const Layout: FC = memo(({ children }) => { +const LayoutComponent: FC = ({ children }) => { const [mobileOpen, setMobileOpen] = useState(false); const [title, setTitle] = useState(PROJECT_NAME); const { pathname } = useLocation(); @@ -41,6 +41,8 @@ const Layout: FC = memo(({ children }) => {
); -}); +}; + +const Layout = memo(LayoutComponent); export default Layout; diff --git a/interface/src/components/layout/LayoutAppBar.tsx b/interface/src/components/layout/LayoutAppBar.tsx index bc0e84f46..6bea11462 100644 --- a/interface/src/components/layout/LayoutAppBar.tsx +++ b/interface/src/components/layout/LayoutAppBar.tsx @@ -1,8 +1,10 @@ +import { memo, useCallback, useMemo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import MenuIcon from '@mui/icons-material/Menu'; import { AppBar, IconButton, Toolbar, Typography } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; import { useI18nContext } from 'i18n/i18n-react'; @@ -13,30 +15,47 @@ interface LayoutAppBarProps { onToggleDrawer: () => void; } -const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => { +// Extract static styles +const appBarStyles: SxProps = { + width: { md: `calc(100% - ${DRAWER_WIDTH}px)` }, + ml: { md: `${DRAWER_WIDTH}px` }, + boxShadow: 'none', + backgroundColor: '#2e586a' +}; + +const menuButtonStyles: SxProps = { + mr: 2, + display: { md: 'none' } +}; + +const backButtonStyles: SxProps = { + mr: 1, + fontSize: 20, + verticalAlign: 'middle' +}; + +const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) => { const { LL } = useI18nContext(); const navigate = useNavigate(); + const location = useLocation(); - const pathnames = useLocation() - .pathname.split('/') - .filter((x) => x); + const pathnames = useMemo( + () => location.pathname.split('/').filter((x) => x), + [location.pathname] + ); + + const handleBackClick = useCallback(() => { + void navigate('/' + pathnames[0]); + }, [navigate, pathnames]); return ( - + @@ -44,10 +63,10 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => { {pathnames.length > 1 && ( <> navigate('/' + pathnames[0])} + onClick={handleBackClick} > @@ -70,4 +89,6 @@ const LayoutAppBar = ({ title, onToggleDrawer }: LayoutAppBarProps) => { ); }; +const LayoutAppBar = memo(LayoutAppBarComponent); + export default LayoutAppBar; diff --git a/interface/src/components/layout/LayoutDrawer.tsx b/interface/src/components/layout/LayoutDrawer.tsx index 320208129..8c85f666e 100644 --- a/interface/src/components/layout/LayoutDrawer.tsx +++ b/interface/src/components/layout/LayoutDrawer.tsx @@ -1,3 +1,5 @@ +import { memo, useMemo } from 'react'; + import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material'; import { PROJECT_NAME } from 'env'; @@ -21,19 +23,23 @@ interface LayoutDrawerProps { onClose: () => void; } -const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => { - const drawer = ( - <> - - - - {PROJECT_NAME} - - - - - - +const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => { + // Memoize drawer content to prevent unnecessary re-renders + const drawer = useMemo( + () => ( + <> + + + + {PROJECT_NAME} + + + + + + + ), + [] ); return ( @@ -66,4 +72,6 @@ const LayoutDrawerProps = ({ mobileOpen, onClose }: LayoutDrawerProps) => { ); }; -export default LayoutDrawerProps; +const LayoutDrawer = memo(LayoutDrawerComponent); + +export default LayoutDrawer; diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx index fca127c42..29746ff27 100644 --- a/interface/src/components/layout/LayoutMenu.tsx +++ b/interface/src/components/layout/LayoutMenu.tsx @@ -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 AssessmentIcon from '@mui/icons-material/Assessment'; @@ -30,24 +30,31 @@ import LayoutMenuItem from 'components/layout/LayoutMenuItem'; import { AuthenticatedContext } from 'contexts/authentication'; import { useI18nContext } from 'i18n/i18n-react'; -const LayoutMenu = () => { +const LayoutMenuComponent = () => { const { me, signOut } = useContext(AuthenticatedContext); const { LL } = useI18nContext(); const [anchorEl, setAnchorEl] = useState(null); - - const open = Boolean(anchorEl); - const id = anchorEl ? 'app-menu-popover' : undefined; - const [menuOpen, setMenuOpen] = useState(true); - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; + const open = useMemo(() => Boolean(anchorEl), [anchorEl]); + const id = useMemo(() => (anchorEl ? 'app-menu-popover' : undefined), [anchorEl]); - const handleClose = () => { + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleClose = useCallback(() => { setAnchorEl(null); - }; + }, []); + + const handleSignOut = useCallback(() => { + signOut(true); + }, [signOut]); + + const handleMenuToggle = useCallback(() => { + setMenuOpen((prev) => !prev); + }, []); return ( <> @@ -64,10 +71,8 @@ const LayoutMenu = () => { > setMenuOpen(!menuOpen)} + onClick={handleMenuToggle} sx={{ - pt: 2.5, - pb: menuOpen ? 0 : 2.5, '&:hover, &:focus': { '& svg': { opacity: 1 } } }} > @@ -173,7 +178,7 @@ const LayoutMenu = () => { variant="outlined" fullWidth color="primary" - onClick={() => signOut(true)} + onClick={handleSignOut} > {LL.SIGN_OUT()} @@ -196,4 +201,6 @@ const LayoutMenu = () => { ); }; +const LayoutMenu = memo(LayoutMenuComponent); + export default LayoutMenu; diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx index f93088c5f..a226138b4 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -1,7 +1,8 @@ +import { memo, useMemo } from 'react'; import { Link, useLocation } from 'react-router'; 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'; @@ -12,7 +13,7 @@ interface LayoutMenuItemProps { disabled?: boolean; } -const LayoutMenuItem = ({ +const LayoutMenuItemComponent = ({ icon: Icon, label, to, @@ -20,7 +21,53 @@ const LayoutMenuItem = ({ }: LayoutMenuItemProps) => { const { pathname } = useLocation(); - const selected = routeMatches(to, pathname); + const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]); + + // Memoize dynamic styles based on selected state + const buttonStyles: SxProps = useMemo( + () => ({ + transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transform: selected ? 'scale(1.02)' : 'scale(1)', + backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent', + borderRadius: '8px', + margin: '2px 8px', + '&:hover': { + backgroundColor: 'rgba(68, 82, 211, 0.39)', + transform: selected ? 'scale(1.02)' : 'scale(1.01)' + }, + '&::before': { + content: '""', + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: selected ? '4px' : '0px', + backgroundColor: '#90caf9', + borderRadius: '0 2px 2px 0', + transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)' + } + }), + [selected] + ); + + const iconStyles: SxProps = useMemo( + () => ({ + color: selected ? '#90caf9' : '#9e9e9e', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transform: selected ? 'scale(1.1)' : 'scale(1)', + transitionProperty: 'color, transform' + }), + [selected] + ); + + const textStyles: SxProps = useMemo( + () => ({ + color: selected ? '#90caf9' : '#f5f5f5', + transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)', + transitionProperty: 'color, font-weight' + }), + [selected] + ); return ( - + - - {label} - + {label} ); }; +const LayoutMenuItem = memo(LayoutMenuItemComponent); + export default LayoutMenuItem; diff --git a/interface/src/components/layout/ListMenuItem.tsx b/interface/src/components/layout/ListMenuItem.tsx index 4d957b595..b20d2409b 100644 --- a/interface/src/components/layout/ListMenuItem.tsx +++ b/interface/src/components/layout/ListMenuItem.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; +import type { CSSProperties } from 'react'; import { Link } from 'react-router'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; @@ -20,8 +22,15 @@ interface ListMenuItemProps { disabled?: boolean; } -function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) { - return ( +// Extract styles to prevent recreation +const iconStyles: CSSProperties = { + justifyContent: 'right', + color: 'lightblue', + verticalAlign: 'middle' +}; + +const RenderIcon = memo( + ({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( <> @@ -30,8 +39,8 @@ function RenderIcon({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) { - ); -} + ) +); const LayoutMenuItem = ({ icon, @@ -46,13 +55,7 @@ const LayoutMenuItem = ({ + } @@ -79,4 +82,4 @@ const LayoutMenuItem = ({ ); -export default LayoutMenuItem; +export default memo(LayoutMenuItem); diff --git a/interface/src/components/loading/FormLoader.tsx b/interface/src/components/loading/FormLoader.tsx index b44cbdcea..3d27e4200 100644 --- a/interface/src/components/loading/FormLoader.tsx +++ b/interface/src/components/loading/FormLoader.tsx @@ -1,3 +1,5 @@ +import { memo } from 'react'; + import RefreshIcon from '@mui/icons-material/Refresh'; import { Box, Button, CircularProgress } from '@mui/material'; @@ -9,7 +11,7 @@ interface FormLoaderProps { onRetry?: () => void; } -const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => { +const FormLoaderComponent = ({ errorMessage, onRetry }: FormLoaderProps) => { const { LL } = useI18nContext(); if (errorMessage) { @@ -38,4 +40,6 @@ const FormLoader = ({ errorMessage, onRetry }: FormLoaderProps) => { ); }; +const FormLoader = memo(FormLoaderComponent); + export default FormLoader; diff --git a/interface/src/components/loading/LazyLoader.tsx b/interface/src/components/loading/LazyLoader.tsx index 80d6f2d84..02ba6bce7 100644 --- a/interface/src/components/loading/LazyLoader.tsx +++ b/interface/src/components/loading/LazyLoader.tsx @@ -1,20 +1,22 @@ import { memo } from 'react'; import { Box, CircularProgress } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; + +// Extract styles to prevent recreation on every render +const containerStyles: SxProps = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + minHeight: '200px', + backgroundColor: 'background.default', + borderRadius: 1, + border: '1px solid', + borderColor: 'divider' +}; const LazyLoader = memo(() => ( - + )); diff --git a/interface/src/components/loading/LoadingSpinner.tsx b/interface/src/components/loading/LoadingSpinner.tsx index 8b61b056c..b824789bb 100644 --- a/interface/src/components/loading/LoadingSpinner.tsx +++ b/interface/src/components/loading/LoadingSpinner.tsx @@ -1,10 +1,18 @@ +import { memo } from 'react'; + import { Box, CircularProgress } from '@mui/material'; -import type { Theme } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; interface LoadingSpinnerProps { height?: number | string; } +// Extract styles to prevent recreation on every render +const circularProgressStyles: SxProps = (theme: Theme) => ({ + margin: theme.spacing(4), + color: theme.palette.text.secondary +}); + const LoadingSpinner = ({ height = '100%' }: LoadingSpinnerProps) => { return ( { padding={2} height={height} > - ({ - margin: theme.spacing(4), - color: theme.palette.text.secondary - })} - size={100} - /> + ); }; -export default LoadingSpinner; +export default memo(LoadingSpinner); diff --git a/interface/src/components/routing/BlockNavigation.tsx b/interface/src/components/routing/BlockNavigation.tsx index 3dd2f255d..74601d785 100644 --- a/interface/src/components/routing/BlockNavigation.tsx +++ b/interface/src/components/routing/BlockNavigation.tsx @@ -1,3 +1,4 @@ +import { memo, useCallback } from 'react'; import type { Blocker } from 'react-router'; import { @@ -14,23 +15,23 @@ import { useI18nContext } from 'i18n/i18n-react'; const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { const { LL } = useI18nContext(); + const handleReset = useCallback(() => { + blocker.reset?.(); + }, [blocker]); + + const handleProceed = useCallback(() => { + blocker.proceed?.(); + }, [blocker]); + return ( {LL.BLOCK_NAVIGATE_1()} {LL.BLOCK_NAVIGATE_2()} - - @@ -38,4 +39,4 @@ const BlockNavigation = ({ blocker }: { blocker: Blocker }) => { ); }; -export default BlockNavigation; +export default memo(BlockNavigation); diff --git a/interface/src/components/routing/RequireAdmin.tsx b/interface/src/components/routing/RequireAdmin.tsx index 5c363a30a..20be11eda 100644 --- a/interface/src/components/routing/RequireAdmin.tsx +++ b/interface/src/components/routing/RequireAdmin.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { memo, useContext } from 'react'; import type { FC } from 'react'; import { Navigate } from 'react-router'; @@ -14,4 +14,4 @@ const RequireAdmin: FC = ({ children }) => { ); }; -export default RequireAdmin; +export default memo(RequireAdmin); diff --git a/interface/src/components/routing/RequireAuthenticated.tsx b/interface/src/components/routing/RequireAuthenticated.tsx index 29a352158..36a32f0b3 100644 --- a/interface/src/components/routing/RequireAuthenticated.tsx +++ b/interface/src/components/routing/RequireAuthenticated.tsx @@ -1,4 +1,4 @@ -import { useContext, useEffect } from 'react'; +import { memo, useContext, useEffect } from 'react'; import type { FC } from 'react'; import { Navigate, useLocation } from 'react-router'; @@ -18,7 +18,7 @@ const RequireAuthenticated: FC = ({ children }) => { if (!authenticationContext.me) { storeLoginRedirect(location); } - }); + }, [authenticationContext.me, location]); return authenticationContext.me ? ( = ({ children }) => { ); }; -export default RequireAuthenticated; +export default memo(RequireAuthenticated); diff --git a/interface/src/components/routing/RequireUnauthenticated.tsx b/interface/src/components/routing/RequireUnauthenticated.tsx index 03632a85f..4f3a84875 100644 --- a/interface/src/components/routing/RequireUnauthenticated.tsx +++ b/interface/src/components/routing/RequireUnauthenticated.tsx @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { memo, useContext } from 'react'; import type { FC } from 'react'; import { Navigate } from 'react-router'; @@ -16,4 +16,4 @@ const RequireUnauthenticated: FC = ({ children }) => { ); }; -export default RequireUnauthenticated; +export default memo(RequireUnauthenticated); diff --git a/interface/src/components/routing/RouterTabs.tsx b/interface/src/components/routing/RouterTabs.tsx index 67e76c297..9a3d7d31e 100644 --- a/interface/src/components/routing/RouterTabs.tsx +++ b/interface/src/components/routing/RouterTabs.tsx @@ -1,3 +1,4 @@ +import { memo, useCallback } from 'react'; import type { FC } from 'react'; import { useNavigate } from 'react-router'; @@ -15,9 +16,12 @@ const RouterTabs: FC = ({ value, children }) => { const theme = useTheme(); const smallDown = useMediaQuery(theme.breakpoints.down('sm')); - const handleTabChange = (_event: unknown, path: string) => { - void navigate(path); - }; + const handleTabChange = useCallback( + (_event: unknown, path: string) => { + void navigate(path); + }, + [navigate] + ); return ( = ({ value, children }) => { ); }; -export default RouterTabs; +export default memo(RouterTabs); diff --git a/interface/src/components/routing/authentication.ts b/interface/src/components/routing/authentication.ts index fb660fe71..51c01fb94 100644 --- a/interface/src/components/routing/authentication.ts +++ b/interface/src/components/routing/authentication.ts @@ -13,8 +13,14 @@ export const verifyAuthorization = () => export const signIn = (request: SignInRequest) => alovaInstance.Post('/rest/signIn', request); +// Cache storage reference to avoid repeated checks +let cachedStorage: Storage | undefined; + export function getStorage() { - return localStorage || sessionStorage; + if (!cachedStorage) { + cachedStorage = localStorage || sessionStorage; + } + return cachedStorage; } export function storeLoginRedirect(location?: { pathname: string; search: string }) { diff --git a/interface/src/i18n/cz/index.ts b/interface/src/i18n/cz/index.ts index 72eadb180..b7131532c 100644 --- a/interface/src/i18n/cz/index.ts +++ b/interface/src/i18n/cz/index.ts @@ -187,6 +187,7 @@ const cz: Translation = { BUFFER_SIZE: 'MaximĆ”lnĆ­ velikost vyrovnĆ”vacĆ­ paměti', COMPACT: 'KompaktnĆ­', DOWNLOAD_SETTINGS_TEXT: 'Vytvořte zĆ”lohu svĆ©ho nastavenĆ­ a konfigurace', + DOWNLOAD_SETTINGS_TEXT2: 'Exportovat vÅ”echna data', UPLOAD_TEXT: 'Nahrajte nový soubor firmwaru (.bin) nebo zĆ”ložnĆ­ soubor (.json)', UPLOAD_DROP_TEXT: 'PřetĆ”hněte soubor sem nebo klikněte pro výběr', ERROR: 'NeočekĆ”vanĆ” chyba, zkuste to prosĆ­m znovu', diff --git a/interface/src/i18n/de/index.ts b/interface/src/i18n/de/index.ts index 6e70eb07e..f77904b6b 100644 --- a/interface/src/i18n/de/index.ts +++ b/interface/src/i18n/de/index.ts @@ -187,6 +187,7 @@ const de: Translation = { BUFFER_SIZE: 'Max. Puffergröße', COMPACT: 'Kompakte Darstellung', DOWNLOAD_SETTINGS_TEXT: 'Erstellen Sie eine Sicherung Ihrer Konfigurationen und Einstellungen', + DOWNLOAD_SETTINGS_TEXT2: 'Exportiere alle Daten', UPLOAD_TEXT: 'Laden Sie eine neue Firmware-Datei (.bin) oder eine Sicherungsdatei (.json) hoch', UPLOAD_DROP_TEXT: 'Legen Sie eine Firmware-Datei (.bin) ab oder klicken Sie hier', ERROR: 'Unerwarteter Fehler, bitte versuchen Sie es erneut.', diff --git a/interface/src/i18n/en/index.ts b/interface/src/i18n/en/index.ts index 91826fb83..d9d18b7d5 100644 --- a/interface/src/i18n/en/index.ts +++ b/interface/src/i18n/en/index.ts @@ -187,6 +187,7 @@ const en: Translation = { BUFFER_SIZE: 'Max Buffer Size', COMPACT: 'Compact', DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings', + DOWNLOAD_SETTINGS_TEXT2: 'Export all data', UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', ERROR: 'Unexpected Error, please try again', diff --git a/interface/src/i18n/fr/index.ts b/interface/src/i18n/fr/index.ts index a355e1673..f5a123b60 100644 --- a/interface/src/i18n/fr/index.ts +++ b/interface/src/i18n/fr/index.ts @@ -187,6 +187,7 @@ const fr: Translation = { BUFFER_SIZE: 'Max taille du buffer', COMPACT: 'Compact', DOWNLOAD_SETTINGS_TEXT: 'CrĆ©er une sauvegarde de vos paramĆØtres et configurations', + DOWNLOAD_SETTINGS_TEXT2: 'Exporter toutes les donnĆ©es', UPLOAD_TEXT: 'TĆ©lĆ©charger un nouveau fichier firmware (.bin) ou une sauvegarde (.json)', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', ERROR: 'Erreur inattendue, veuillez rĆ©essayer', diff --git a/interface/src/i18n/it/index.ts b/interface/src/i18n/it/index.ts index 026928cbb..44f60c691 100644 --- a/interface/src/i18n/it/index.ts +++ b/interface/src/i18n/it/index.ts @@ -187,6 +187,7 @@ const it: Translation = { BUFFER_SIZE: 'Max Buffer Size', COMPACT: 'Compatto', DOWNLOAD_SETTINGS_TEXT: 'Create a backup of your configuration and settings', + DOWNLOAD_SETTINGS_TEXT2: 'Esporta tutti i dati', UPLOAD_TEXT: 'Upload a new firmware file (.bin) or a backup file (.json)', UPLOAD_DROP_TEXT: 'Drop a firmware .bin file or click here', ERROR: 'Errore Inaspettato, prego tenta ancora', diff --git a/interface/src/i18n/nl/index.ts b/interface/src/i18n/nl/index.ts index cb1569e03..acdf83c03 100644 --- a/interface/src/i18n/nl/index.ts +++ b/interface/src/i18n/nl/index.ts @@ -187,6 +187,7 @@ const nl: Translation = { BUFFER_SIZE: 'Max buffer grootte', COMPACT: 'Compact', DOWNLOAD_SETTINGS_TEXT: 'Maak een back-up van uw configuratie en instellingen', + DOWNLOAD_SETTINGS_TEXT2: 'Exporteer alle data', UPLOAD_TEXT: 'Upload een nieuw firmwarebestand (.bin) of een back-upbestand (.json)', UPLOAD_DROP_TEXT: 'Sleep en firmware .bin bestand hierheen of klik hier', ERROR: 'Onverwachte fout, probeer opnieuw', diff --git a/interface/src/i18n/no/index.ts b/interface/src/i18n/no/index.ts index 4d155ec66..e27d38089 100644 --- a/interface/src/i18n/no/index.ts +++ b/interface/src/i18n/no/index.ts @@ -187,6 +187,7 @@ const no: Translation = { BUFFER_SIZE: 'Max Buffer StĆørrelse', COMPACT: 'Komprimere', DOWNLOAD_SETTINGS_TEXT: 'Lag en sikkerhetskopi av dine konfigurasjon og innstillinger', + DOWNLOAD_SETTINGS_TEXT2: 'Eksporter alle data', UPLOAD_TEXT: 'Last opp en ny firmware fil (.bin) eller en sikkerhetskopi fil (.json)', UPLOAD_DROP_TEXT: 'Dropp en firmware fil (.bin) eller klikk her', ERROR: 'Ukjent feil, prĆøv igjen', diff --git a/interface/src/i18n/pl/index.ts b/interface/src/i18n/pl/index.ts index 5a58c5969..9fd1cbc02 100644 --- a/interface/src/i18n/pl/index.ts +++ b/interface/src/i18n/pl/index.ts @@ -187,6 +187,7 @@ const pl: BaseTranslation = { BUFFER_SIZE: 'Maksymalna pojemność bufora (ilość wpisów)', COMPACT: 'Kompaktowy', DOWNLOAD_SETTINGS_TEXT: 'Utwórz kopię swoich ustawień i konfiguracji', + DOWNLOAD_SETTINGS_TEXT2: 'Eksportuj wszystkie dane', UPLOAD_TEXT: 'Wgraj nowy plik firmware (.bin) lub kopię ustawień (.json)', UPLOAD_DROP_TEXT: 'Upuść plik firmware .bin lub kliknij tutaj', ERROR: 'Nieoczekiwany błąd, spróbuj ponownie!', diff --git a/interface/src/i18n/sk/index.ts b/interface/src/i18n/sk/index.ts index cb2022a42..737113010 100644 --- a/interface/src/i18n/sk/index.ts +++ b/interface/src/i18n/sk/index.ts @@ -187,6 +187,7 @@ const sk: Translation = { BUFFER_SIZE: 'Buffer-max. veľkosÅ„', COMPACT: 'KompaktnĆ©', DOWNLOAD_SETTINGS_TEXT: 'Vytvorte zĆ”lohu svojej konfigurĆ”cie a nastavenĆ­', + DOWNLOAD_SETTINGS_TEXT2: 'ExportovaÅ„ vÅ”etky dĆ”ta', UPLOAD_TEXT: 'Nahrajte nový sĆŗbor firmvĆ©ru (.bin) alebo sĆŗbor zĆ”lohy (.json)', UPLOAD_DROP_TEXT: 'Presuňte sĆŗbor .bin firmvĆ©ru alebo kliknite sem', ERROR: 'NeočakĆ”vanĆ” chyba, prosĆ­m skĆŗste to znova', diff --git a/interface/src/i18n/sv/index.ts b/interface/src/i18n/sv/index.ts index 2acf299f3..48f513017 100644 --- a/interface/src/i18n/sv/index.ts +++ b/interface/src/i18n/sv/index.ts @@ -187,6 +187,7 @@ const sv: Translation = { BUFFER_SIZE: 'Max bufferstorlek', COMPACT: 'Komprimerad', DOWNLOAD_SETTINGS_TEXT: 'Skapa en sƤkerhetskopia av din konfiguration och instƤllningar', + DOWNLOAD_SETTINGS_TEXT2: 'Exportera alla data', UPLOAD_TEXT: 'Ladda upp en ny firmwarefil (.bin) eller en sƤkerhetskopiafil (.json)', UPLOAD_DROP_TEXT: 'Droppa en firmware .bin fil eller klicka hƤr', ERROR: 'OkƤnt fel, var god fƶrsƶk igen', diff --git a/interface/src/i18n/tr/index.ts b/interface/src/i18n/tr/index.ts index 3a262c29c..d263176bb 100644 --- a/interface/src/i18n/tr/index.ts +++ b/interface/src/i18n/tr/index.ts @@ -187,6 +187,7 @@ const tr: Translation = { BUFFER_SIZE: 'En fazla bellek boyutu', COMPACT: 'Sıkışık', DOWNLOAD_SETTINGS_TEXT: 'Yapılandırma ve ayarlarınızın yedekleme yapın', + DOWNLOAD_SETTINGS_TEXT2: 'Tüm verileri dışarı al', UPLOAD_TEXT: 'Yeni bir firmware dosyası (.bin) veya yedek dosyası (.json) yükle', UPLOAD_DROP_TEXT: 'Bir firmware .bin dosyası veya buraya tıklayın', ERROR: 'Beklenemedik hata, lütfen tekrar deneyin.', diff --git a/interface/src/index.tsx b/interface/src/index.tsx index 0b319cdab..37f0bb676 100644 --- a/interface/src/index.tsx +++ b/interface/src/index.tsx @@ -4,13 +4,109 @@ import { Route, RouterProvider, createBrowserRouter, - createRoutesFromElements + createRoutesFromElements, + useRouteError } from 'react-router'; import App from 'App'; +const errorPageStyles = { + container: { + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + justifyContent: 'center', + minHeight: '100vh', + padding: '2rem', + textAlign: 'center' as const, + backgroundColor: '#1e1e1e', + color: '#eee' + }, + logo: { + width: '100px', + height: '100px', + marginBottom: '2rem' + }, + title: { + fontSize: '2rem', + margin: '0 0 1rem 0', + color: '#90CAF9', + fontWeight: 500 as const + }, + status: { + color: '#2196f3', + fontSize: '1.5rem', + fontWeight: 400 as const, + margin: '0 0 1rem 0' + }, + message: { + fontFamily: 'monospace, monospace', + fontSize: '1.1rem', + color: '#9e9e9e', + maxWidth: '600px', + margin: '0 0 2rem 0' + }, + message2: { + fontSize: '1.1rem', + color: '#2196f3', + maxWidth: '600px', + margin: '0 0 2rem 0' + } +}; +interface ErrorWithStatus { + status?: number | string; + statusText?: string; + message?: string; +} + +function isErrorWithDetails(error: unknown): error is ErrorWithStatus { + return typeof error === 'object' && error !== null; +} + +function getErrorStatus(error: unknown): string { + if (isErrorWithDetails(error) && 'status' in error && error.status != null) { + return String(error.status); + } + return 'Error'; +} + +function getErrorMessage(error: unknown): string { + if (!isErrorWithDetails(error)) { + return 'Something went wrong'; + } + return error.statusText || error.message || 'Something went wrong'; +} + +// Custom Error Component +function ErrorPage() { + const error = useRouteError(); + + return ( +
+ EMS-ESP Logo +

The WebUI is having problems

+

+ {getErrorStatus(error)}: {getErrorMessage(error)} +

+

+ Please report on{' '} + + https://docs.emsesp.org/Support + +

+
+ ); +} + const router = createBrowserRouter( - createRoutesFromElements(} />) + createRoutesFromElements( + } errorElement={} /> + ) ); createRoot(document.getElementById('root')!).render( diff --git a/interface/src/types/index.ts b/interface/src/types/index.ts index 420f7e2bf..8c2f8760c 100644 --- a/interface/src/types/index.ts +++ b/interface/src/types/index.ts @@ -1,8 +1,9 @@ export * from './ap'; +export * from './features'; export * from './me'; export * from './mqtt'; +export * from './network'; export * from './ntp'; export * from './security'; export * from './signin'; export * from './system'; -export * from './network'; diff --git a/interface/src/types/network.ts b/interface/src/types/network.ts index f771f34c4..9aef2621b 100644 --- a/interface/src/types/network.ts +++ b/interface/src/types/network.ts @@ -54,6 +54,7 @@ export interface NetworkSettingsType { enableMDNS: boolean; enableCORS: boolean; CORSOrigin: string; + [key: string]: unknown; } export interface WiFiNetworkList { diff --git a/interface/src/utils/binding.ts b/interface/src/utils/binding.ts index 443f5a0f9..d5c0bb4a3 100644 --- a/interface/src/utils/binding.ts +++ b/interface/src/utils/binding.ts @@ -1,60 +1,67 @@ -export const numberValue = (value?: number) => { - if (value !== undefined) { - return isNaN(value) ? '' : value.toString(); - } - return ''; -}; +/** + * Converts a number value to a string for input fields. + * Returns empty string for undefined or NaN values. + */ +export const numberValue = (value?: number): string => + value === undefined || isNaN(value) ? '' : String(value); -export const extractEventValue = (event: React.ChangeEvent) => { - switch (event.target.type) { - case 'number': - return event.target.valueAsNumber; - case 'checkbox': - return event.target.checked; - default: - return event.target.value; - } +/** + * Extracts the appropriate value from an input event based on input type. + */ +export const extractEventValue = ( + event: React.ChangeEvent +): string | number | boolean => { + const { type, valueAsNumber, checked, value } = event.target; + + if (type === 'number') return valueAsNumber; + if (type === 'checkbox') return checked; + return value; }; type UpdateEntity = (state: (prevState: Readonly) => S) => void; +/** + * Creates an event handler that updates an entity's state based on input changes. + */ export const updateValue = - (updateEntity: UpdateEntity) => - (event: React.ChangeEvent) => { + >(updateEntity: UpdateEntity) => + (event: React.ChangeEvent): void => { + const { name } = event.target; + const value = extractEventValue(event); + updateEntity((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 = - ( - origData: unknown, + >( + origData: T, dirtyFlags: string[], setDirtyFlags: React.Dispatch>, - updateDataValue: (value: unknown) => void + updateDataValue: (updater: (prevState: T) => T) => void ) => - (event: React.ChangeEvent) => { - const updated_value = extractEventValue(event); - const name = event.target.name; + (event: React.ChangeEvent): void => { + const { name } = event.target; + const updatedValue = extractEventValue(event); - updateDataValue((prevState: unknown) => ({ - ...(prevState as Record), - [name]: updated_value + updateDataValue((prevState) => ({ + ...prevState, + [name]: updatedValue })); - const arr: string[] = dirtyFlags; + const isDirty = origData[name] !== updatedValue; + const wasDirty = dirtyFlags.includes(name); - if ((origData as Record)[name] !== updated_value) { - if (!arr.includes(name)) { - arr.push(name); - } - } else { - const startIndex = arr.indexOf(name); - if (startIndex !== -1) { - arr.splice(startIndex, 1); - } + // Only update dirty flags if the state changed + if (isDirty !== wasDirty) { + setDirtyFlags( + isDirty ? [...dirtyFlags, name] : dirtyFlags.filter((f) => f !== name) + ); } - - setDirtyFlags(arr); }; diff --git a/interface/src/utils/file.ts b/interface/src/utils/file.ts index d6d5df4d5..547a30cad 100644 --- a/interface/src/utils/file.ts +++ b/interface/src/utils/file.ts @@ -1,11 +1,28 @@ -export const saveFile = (json: unknown, filename: string, extension: string) => { - const anchor = document.createElement('a'); - anchor.href = URL.createObjectURL( - new Blob([JSON.stringify(json, null, 2)], { - type: 'text/plain' - }) - ); - anchor.download = 'emsesp_' + filename + extension; - anchor.click(); - URL.revokeObjectURL(anchor.href); +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'); + anchor.href = url; + anchor.download = `emsesp_${filename}${extension}`; + + // Trigger download + document.body.appendChild(anchor); + anchor.click(); + 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}`); + } }; diff --git a/interface/src/utils/time.ts b/interface/src/utils/time.ts index 3ff3b7f0e..9ccadb5c6 100644 --- a/interface/src/utils/time.ts +++ b/interface/src/utils/time.ts @@ -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(); const rtfCache = new Map(); -// Pre-computed time divisions for relative time formatting +// Pre-computed constants +const MS_TO_MINUTES = 60000; // 60 * 1000 const TIME_DIVISIONS = [ { amount: 60, name: 'seconds' as const }, { amount: 60, name: 'minutes' as const }, @@ -13,30 +15,79 @@ const TIME_DIVISIONS = [ { amount: Number.POSITIVE_INFINITY, name: 'years' 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( options: Intl.DateTimeFormatOptions ): Intl.DateTimeFormat { - const key = JSON.stringify(options); - if (!formatterCache.has(key)) { - formatterCache.set( - key, - new Intl.DateTimeFormat([...window.navigator.languages], options) - ); + const key = createFormatterKey(options); + + if (formatterCache.has(key)) { + // Move to end for LRU behavior + 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 { - if (!rtfCache.has(locale)) { - rtfCache.set(locale, new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })); + if (rtfCache.has(locale)) { + // 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); - // Use for...of for better performance and readability + // Find the appropriate time division for (const division of TIME_DIVISIONS) { if (Math.abs(duration) < division.amount) { return rtf.format(Math.round(duration), division.name); @@ -57,7 +108,8 @@ function formatTimeAgo(locale: string, date: Date): string { 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'; } - // Calculate local time offset in milliseconds - const offsetMs = date.getTimezoneOffset() * 60000; + // Calculate local time offset using pre-computed constant + const offsetMs = date.getTimezoneOffset() * MS_TO_MINUTES; const localTime = date.getTime() - offsetMs; // Convert to ISO string and remove timezone info diff --git a/interface/src/utils/useInterval.ts b/interface/src/utils/useInterval.ts index e319b4896..82cbaaf1e 100644 --- a/interface/src/utils/useInterval.ts +++ b/interface/src/utils/useInterval.ts @@ -2,24 +2,43 @@ import { useEffect, useRef } from 'react'; const DEFAULT_DELAY = 3000; -// adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/ -export const useInterval = (callback: () => void, delay: number = DEFAULT_DELAY) => { - const intervalRef = useRef(null); - const savedCallback = useRef<() => void>(callback); +/** + * Custom hook for setting up an interval with proper cleanup + * Adapted from https://www.joshwcomeau.com/snippets/react-hooks/use-interval/ + * + * @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 | null>(null); + const savedCallback = useRef(callback); + // Remember the latest callback without resetting the interval useEffect(() => { savedCallback.current = callback; }, [callback]); useEffect(() => { const tick = () => savedCallback.current(); - intervalRef.current = window.setInterval(tick, delay); + + // Execute immediately if requested + if (immediate) { + tick(); + } + + intervalRef.current = setInterval(tick, delay); return () => { - if (intervalRef.current !== null) { - window.clearInterval(intervalRef.current); + if (intervalRef.current) { + clearInterval(intervalRef.current); } }; - }, [delay]); + }, [delay, immediate]); return intervalRef; }; diff --git a/interface/src/utils/usePersistState.ts b/interface/src/utils/usePersistState.ts index 86cc4ae93..2a627a852 100644 --- a/interface/src/utils/usePersistState.ts +++ b/interface/src/utils/usePersistState.ts @@ -4,23 +4,38 @@ export const usePersistState = ( initial_value: T, id: string ): [T, (new_state: T) => void] => { - // Set initial value + // Set initial value - only computed once on mount const _initial_value = useMemo(() => { - const local_storage_value_str = localStorage.getItem('state:' + id); - // If there is a value stored in localStorage, use that - if (local_storage_value_str) { - return JSON.parse(local_storage_value_str) as T; + try { + const local_storage_value_str = localStorage.getItem(`state:${id}`); + // If there is a value stored in localStorage, use that + if (local_storage_value_str) { + 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 return initial_value; - }, []); + }, [id]); // initial_value intentionally omitted - only read on first mount const [state, setState] = useState(_initial_value); useEffect(() => { - const state_str = JSON.stringify(state); // Stringified state - localStorage.setItem('state:' + id, state_str); // Set stringified state as item in localStorage - }, [state]); + try { + const state_str = JSON.stringify(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]; }; diff --git a/interface/src/utils/useRest.ts b/interface/src/utils/useRest.ts index 6800b0c4d..aa4096623 100644 --- a/interface/src/utils/useRest.ts +++ b/interface/src/utils/useRest.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -11,10 +11,12 @@ export interface RestRequestOptions { update: (value: D) => Method; } +const REBOOT_ERROR_MESSAGE = 'Reboot required'; + export const useRest = ({ read, update }: RestRequestOptions) => { const { LL } = useI18nContext(); const [errorMessage, setErrorMessage] = useState(); - const [restartNeeded, setRestartNeeded] = useState(false); + const [restartNeeded, setRestartNeeded] = useState(false); const [origData, setOrigData] = useState(); const [dirtyFlags, setDirtyFlags] = useState([]); const blocker = useBlocker(dirtyFlags.length !== 0); @@ -35,55 +37,71 @@ export const useRest = ({ read, update }: RestRequestOptions) => { setDirtyFlags([]); }); - // Memoize updateDataValue to prevent unnecessary re-renders const updateDataValue = useCallback( - (new_data: D) => { - updateData({ data: new_data }); - }, + (new_data: D) => updateData({ data: new_data }), [updateData] ); - // Memoize loadData to prevent unnecessary re-renders const loadData = useCallback(async () => { setDirtyFlags([]); setErrorMessage(undefined); - await readData().catch((error: Error) => { - toast.error(error.message); - setErrorMessage(error.message); - }); + try { + await readData(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(message); + setErrorMessage(message); + } }, [readData]); - // Memoize saveData to prevent unnecessary re-renders const saveData = useCallback(async () => { - if (!data) { - return; - } + if (!data) return; + + // Reset states before saving setRestartNeeded(false); setErrorMessage(undefined); setDirtyFlags([]); 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); } else { - toast.error(error.message); - setErrorMessage(error.message); + toast.error(message); + setErrorMessage(message); } - }); + } }, [data, writeData]); - return { - loadData, - saveData, - saving: saving as boolean, - updateDataValue, - data: data as D, - origData: origData as D, - dirtyFlags, - setDirtyFlags, - setOrigData, - blocker, - errorMessage, - restartNeeded - } as const; + return useMemo( + () => ({ + loadData, + saveData, + saving: !!saving, + updateDataValue, + data: data as D, + origData: origData as D, + dirtyFlags, + setDirtyFlags, + setOrigData, + blocker, + errorMessage, + restartNeeded + }), + [ + loadData, + saveData, + saving, + updateDataValue, + data, + origData, + dirtyFlags, + blocker, + errorMessage, + restartNeeded + ] + ); }; diff --git a/interface/src/validators/ap.ts b/interface/src/validators/ap.ts index 5a1514909..4aa5638a2 100644 --- a/interface/src/validators/ap.ts +++ b/interface/src/validators/ap.ts @@ -4,6 +4,42 @@ import type { APSettingsType } from 'types'; 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) => new Schema({ provision_mode: { @@ -11,47 +47,12 @@ export const createAPSettingsValidator = (apSettings: APSettingsType) => message: 'Please provide a provision mode' }, ...(isAPEnabled(apSettings) && { - ssid: [ - { required: true, message: 'Please provide an SSID' }, - { - type: 'string', - max: 32, - message: 'SSID must be 32 characters or less' - } - ], - 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 - ] + ssid: SSID_RULES, + password: PASSWORD_RULES, + channel: CHANNEL_RULES, + max_clients: MAX_CLIENTS_RULES, + local_ip: IP_FIELD_RULE('Local IP address'), + gateway_ip: IP_FIELD_RULE('Gateway IP address'), + subnet_mask: IP_FIELD_RULE('Subnet mask') }) }); diff --git a/interface/src/validators/mqtt.ts b/interface/src/validators/mqtt.ts index c90d3c23a..c3ffc92f2 100644 --- a/interface/src/validators/mqtt.ts +++ b/interface/src/validators/mqtt.ts @@ -3,35 +3,57 @@ import type { MqttSettingsType } from 'types'; 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) => new Schema({ ...(mqttSettings.enabled && { - host: [ - { required: true, message: 'Host is required' }, - IP_OR_HOSTNAME_VALIDATOR - ], - base: { required: true, message: 'Base is required' }, - port: [ - { required: true, message: 'Port is required' }, - { type: 'number', min: 0, max: 65535, message: 'Invalid Port' } - ], - keep_alive: [ - { required: true, message: 'Keep alive is required' }, - { - 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' - } - ] + host: REQUIRED_HOST_VALIDATOR, + base: REQUIRED_BASE_VALIDATOR, + port: PORT_VALIDATOR, + keep_alive: createNumberValidator( + 'Keep alive', + KEEP_ALIVE_MIN, + KEEP_ALIVE_MAX + ), + publish_time_heartbeat: createNumberValidator( + 'Heartbeat', + HEARTBEAT_MIN, + HEARTBEAT_MAX + ) }) }); diff --git a/interface/src/validators/network.ts b/interface/src/validators/network.ts index 4f5f8e866..5a4cb87dc 100644 --- a/interface/src/validators/network.ts +++ b/interface/src/validators/network.ts @@ -3,6 +3,23 @@ import type { NetworkSettingsType } from 'types'; 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 = ( networkSettings: NetworkSettingsType ) => @@ -17,29 +34,16 @@ export const createNetworkSettingsValidator = ( message: 'BSSID must be 17 characters or empty' } ], - password: { - type: 'string', - max: 64, - message: 'Password must be 64 characters or less' - }, + password: [ + { + type: 'string', + max: 64, + message: 'Password must be 64 characters or less' + } + ], hostname: [ { required: true, message: 'Hostname is required' }, HOSTNAME_VALIDATOR ], - ...(networkSettings.static_ip_config && { - 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 - }) + ...(networkSettings.static_ip_config && createStaticIpRules()) }); diff --git a/interface/src/validators/ntp.ts b/interface/src/validators/ntp.ts index 81aad6932..e59329b12 100644 --- a/interface/src/validators/ntp.ts +++ b/interface/src/validators/ntp.ts @@ -7,8 +7,5 @@ export const NTP_SETTINGS_VALIDATOR = new Schema({ { required: true, message: 'Server is required' }, IP_OR_HOSTNAME_VALIDATOR ], - tz_label: { - required: true, - message: 'Time zone is required' - } + tz_label: [{ required: true, message: 'Time zone is required' }] }); diff --git a/interface/src/validators/security.ts b/interface/src/validators/security.ts index c57de6b6a..109aa045e 100644 --- a/interface/src/validators/security.ts +++ b/interface/src/validators/security.ts @@ -2,25 +2,34 @@ import Schema from 'async-validator'; import type { InternalRuleItem } from 'async-validator'; 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({ jwt_secret: [ { required: true, message: 'JWT secret is required' }, { type: 'string', min: 1, - max: 64, - message: 'JWT secret must be between 1 and 64 characters' + max: JWT_SECRET_MAX_LENGTH, + 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[]) => ({ validator( - rule: InternalRuleItem, + _rule: InternalRuleItem, username: string, 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'); } else { 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) => new Schema({ username: [ { required: true, message: 'Username is required' }, { type: 'string', - pattern: /^[a-zA-Z0-9_\\.]{1,24}$/, + pattern: USERNAME_PATTERN, message: "Must be 1-24 characters: alphanumeric, '_' or '.'" }, ...(creating ? [createUniqueUsernameValidator(users)] : []) @@ -44,8 +59,8 @@ export const createUserValidator = (users: UserType[], creating: boolean) => { type: 'string', min: 1, - max: 64, - message: 'Password must be 1-64 characters' + max: PASSWORD_MAX_LENGTH, + message: `Password must be 1-${PASSWORD_MAX_LENGTH} characters` } ] }); diff --git a/interface/src/validators/shared.ts b/interface/src/validators/shared.ts index c3237327b..d1132d184 100644 --- a/interface/src/validators/shared.ts +++ b/interface/src/validators/shared.ts @@ -7,66 +7,54 @@ export const validate = ( options?: ValidateOption ): Promise => new Promise((resolve, reject) => { - void validator.validate(source, options || {}, (errors, fieldErrors) => { - if (errors) { - reject(fieldErrors as Error); - } else { - resolve(source as T); - } + void validator.validate(source, options ?? {}, (errors, fieldErrors) => { + errors ? reject(fieldErrors as Error) : resolve(source as T); }); }); -// updated to support both IPv4 and IPv6 -const IP_ADDRESS_REGEXP = - /((^\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*$))/; +// IPv4 pattern: matches 0.0.0.0 to 255.255.255.255 +const IPV4_PATTERN = + /^((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( - rule: InternalRuleItem, + _rule: InternalRuleItem, value: string, callback: (error?: string) => void ) { - if (value && !isValidIpAddress(value)) { - callback('Must be an IP address'); - } else { - callback(); - } + callback(value && !validatorFn(value) ? errorMessage : undefined); } -}; +}); -const HOSTNAME_LENGTH_REGEXP = /^.{0,200}$/; -const HOSTNAME_PATTERN_REGEXP = - /^(([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])$/; +export const IP_ADDRESS_VALIDATOR = createValidator( + isValidIpAddress, + 'Must be an IP address' +); -const isValidHostname = (value: string) => - HOSTNAME_LENGTH_REGEXP.test(value) && HOSTNAME_PATTERN_REGEXP.test(value); +export const HOSTNAME_VALIDATOR = createValidator( + isValidHostname, + 'Must be a valid hostname' +); -export const HOSTNAME_VALIDATOR = { - validator( - rule: InternalRuleItem, - 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(); - } - } -}; +export const IP_OR_HOSTNAME_VALIDATOR = createValidator( + (value) => isValidIpAddress(value) || isValidHostname(value), + 'Must be a valid IP address or hostname' +); diff --git a/interface/vite.config.ts b/interface/vite.config.ts index 608f2dcc8..e73106230 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -2,8 +2,7 @@ import preact from '@preact/preset-vite'; import fs from 'fs'; import path from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; -import { defineConfig } from 'vite'; -import { Plugin } from 'vite'; +import { Plugin, defineConfig } from 'vite'; import viteImagemin from 'vite-plugin-imagemin'; import viteTsconfigPaths from 'vite-tsconfig-paths'; import zlib from 'zlib'; @@ -11,6 +10,29 @@ import zlib from 'zlib'; // @ts-expect-error - mock server doesn't have type declarations import mockServer from '../mock-api/mockServer.js'; +// Constants +const KB_DIVISOR = 1024; +const REPEAT_CHAR = '='; +const REPEAT_COUNT = 50; +const DEFAULT_OUT_DIR = 'dist'; +const ES_TARGET = 'es2020'; +const CHUNK_SIZE_WARNING_LIMIT = 512; +const ASSETS_INLINE_LIMIT = 4096; + +// Common resolve aliases +const RESOLVE_ALIASES = { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime' +}; + +// Bundle file interface +interface BundleFile { + name: string; + size: number; + gzipSize: number; +} + // Plugin to display bundle size information const bundleSizeReporter = (): Plugin => { return { @@ -18,92 +40,189 @@ const bundleSizeReporter = (): Plugin => { // eslint-disable-next-line @typescript-eslint/no-explicit-any writeBundle(options: any, bundle: any) { console.log('\nšŸ“¦ Bundle Size Report:'); - console.log('='.repeat(50)); + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); - let totalSize = 0; - const files: Array<{ name: string; size: number; gzipSize?: number }> = []; - - for (const [fileName, chunk] of Object.entries( - bundle as Record - )) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((chunk as any).type === 'chunk' || (chunk as any).type === 'asset') { - const filePath = path.join((options.dir as string) || 'dist', fileName); - let size = 0; - let gzipSize = 0; + const files: BundleFile[] = []; + const outDir = options.dir || DEFAULT_OUT_DIR; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + const bundleEntries: Array<[string, any]> = Object.entries(bundle); + for (const [fileName, chunk] of bundleEntries) { + if (chunk?.type === 'chunk' || chunk?.type === 'asset') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const filePath = path.join(outDir, fileName); try { const stats = fs.statSync(filePath); - size = stats.size; - totalSize += size; - - // Calculate gzip size + const size = stats.size; const fileContent = fs.readFileSync(filePath); - gzipSize = zlib.gzipSync(fileContent).length; + const gzipSize = zlib.gzipSync(fileContent).length; - files.push({ - name: fileName, - size, - gzipSize - }); + files.push({ name: fileName, size, gzipSize }); } catch (error) { console.warn(`Could not read file ${fileName}:`, error); } } } - // Sort files by size (largest first) files.sort((a, b) => b.size - a.size); - // Display individual file sizes - files.forEach((file) => { - const sizeKB = (file.size / 1024).toFixed(2); - const gzipKB = file.gzipSize ? (file.gzipSize / 1024).toFixed(2) : 'N/A'; - console.log( - `šŸ“„ ${file.name.padEnd(30)} ${sizeKB.padStart(8)} KB (${gzipKB} KB gzipped)` - ); - }); + // files.forEach((file) => { + // const sizeKB = (file.size / KB_DIVISOR).toFixed(2); + // const gzipKB = (file.gzipSize / KB_DIVISOR).toFixed(2); + // console.log( + // `šŸ“„ ${file.name.padEnd(30)} ${sizeKB.padStart(8)} KB (${gzipKB} KB gzipped)` + // ); + // }); - console.log('='.repeat(50)); - console.log(`šŸ“Š Total Bundle Size: ${(totalSize / 1024).toFixed(2)} KB`); + const totalSize = files.reduce((sum, file) => sum + file.size, 0); + const totalGzipSize = files.reduce((sum, file) => sum + file.gzipSize, 0); + const compressionRatio = ((totalSize - totalGzipSize) / totalSize) * 100; - // Calculate and display gzip total - const totalGzipSize = files.reduce( - (sum, file) => sum + (file.gzipSize || 0), - 0 + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); + console.log(`šŸ“Š Total Bundle Size: ${(totalSize / KB_DIVISOR).toFixed(2)} KB`); + console.log( + `šŸ—œļø Total Gzipped Size: ${(totalGzipSize / KB_DIVISOR).toFixed(2)} KB` ); - console.log(`šŸ—œļø Total Gzipped Size: ${(totalGzipSize / 1024).toFixed(2)} KB`); - - // Show compression ratio - const compressionRatio = ( - ((totalSize - totalGzipSize) / totalSize) * - 100 - ).toFixed(1); - console.log(`šŸ“ˆ Compression Ratio: ${compressionRatio}%`); - - console.log('='.repeat(50)); + console.log(`šŸ“ˆ Compression Ratio: ${compressionRatio.toFixed(1)}%`); + console.log(REPEAT_CHAR.repeat(REPEAT_COUNT)); } }; }; +// Common preact plugin config +const createPreactPlugin = (devToolsEnabled: boolean) => + preact({ + devToolsEnabled, + prefreshEnabled: false + }); + +// Common base plugins +const createBasePlugins = ( + devToolsEnabled: boolean, + includeBundleReporter = true +) => { + const plugins = [createPreactPlugin(devToolsEnabled), viteTsconfigPaths()]; + if (includeBundleReporter) { + plugins.push(bundleSizeReporter()); + } + return plugins; +}; + +// Manual chunk splitting strategy +const createManualChunks = (detailed = false) => { + return (id: string): string | undefined => { + if (id.includes('node_modules')) { + if (id.includes('preact')) return '@preact'; + if (detailed) { + if (id.includes('react-router')) return '@react-router'; + if (id.includes('@mui/material')) return '@mui-material'; + if (id.includes('@mui/icons-material')) return '@mui-icons'; + if (id.includes('alova')) return '@alova'; + if (id.includes('typesafe-i18n')) return '@i18n'; + if (id.includes('react-toastify')) return '@toastify'; + if (id.includes('@table-library')) return '@table-library'; + if (id.includes('uuid')) return '@uuid'; + if (id.includes('axios') || id.includes('fetch')) return '@http'; + if (id.includes('lodash') || id.includes('ramda')) return '@utils'; + } + return 'vendor'; + } + if (detailed) { + if (id.includes('components/')) return 'components'; + if (id.includes('app/')) return 'app'; + if (id.includes('utils/')) return 'utils'; + if (id.includes('api/')) return 'api'; + } + return undefined; + }; +}; + +// Common build base configuration +const createBaseBuildConfig = () => ({ + target: ES_TARGET, + chunkSizeWarningLimit: CHUNK_SIZE_WARNING_LIMIT, + cssMinify: true, + assetsInlineLimit: ASSETS_INLINE_LIMIT +}); + +// Terser options for hosted builds +const createHostedTerserOptions = () => ({ + compress: { + passes: 3, + drop_console: true, + drop_debugger: true, + dead_code: true, + unused: true + }, + mangle: { + toplevel: true + }, + ecma: 2020 as const +}); + +// Terser options for production builds +const createProductionTerserOptions = () => ({ + compress: { + passes: 6, + arrows: true, + drop_console: true, + drop_debugger: true, + sequences: true + }, + mangle: { + toplevel: true, + module: true + }, + ecma: 2020 as const, + enclose: false, + keep_classnames: false, + keep_fnames: false, + ie8: false, + module: false, + safari10: false, + toplevel: true +}); + +// Image optimization plugin +const imageOptimizationPlugin = { + ...viteImagemin({ + verbose: false, + gifsicle: { + optimizationLevel: 7, + interlaced: false + }, + optipng: { + optimizationLevel: 7 + }, + mozjpeg: { + quality: 20 + }, + pngquant: { + quality: [0.8, 0.9], + speed: 4 + }, + svgo: { + plugins: [ + { name: 'removeViewBox' }, + { name: 'removeEmptyAttrs', active: false } + ] + } + }), + enforce: 'pre' as const +}; + export default defineConfig( ({ command, mode }: { command: string; mode: string }) => { if (command === 'serve') { - console.log('Preparing for standalone build with server, mode=' + mode); + console.log(`Preparing for standalone build with server, mode=${mode}`); return { - plugins: [ - preact({ - // Keep dev tools enabled for development - devToolsEnabled: true, - prefreshEnabled: true - }), - viteTsconfigPaths(), - bundleSizeReporter(), // Add bundle size reporting - mockServer() - ], + plugins: [...createBasePlugins(true, true), mockServer()], + resolve: { + alias: RESOLVE_ALIASES + }, server: { open: true, - port: mode == 'production' ? 4173 : 3000, + port: mode === 'production' ? 4173 : 3000, proxy: { '/api': { target: 'http://localhost:3080', @@ -111,14 +230,13 @@ export default defineConfig( secure: false }, '/rest': 'http://localhost:3080', - '/gh': 'http://localhost:3080' // mock for GitHub API + '/gh': 'http://localhost:3080' } }, - // Optimize development builds build: { - target: 'es2020', - minify: false, // Disable minification for faster dev builds - sourcemap: true // Enable source maps for debugging + target: ES_TARGET, + minify: false, + sourcemap: true } }; } @@ -126,48 +244,20 @@ export default defineConfig( if (mode === 'hosted') { console.log('Preparing for hosted build'); return { - plugins: [ - preact({ - // Enable Preact optimizations for hosted build - devToolsEnabled: false, - prefreshEnabled: false - }), - viteTsconfigPaths(), - bundleSizeReporter() // Add bundle size reporting - ], + plugins: createBasePlugins(false, true), + resolve: { + alias: RESOLVE_ALIASES + }, build: { - target: 'es2020', - chunkSizeWarningLimit: 512, - minify: 'terser', - cssMinify: true, - assetsInlineLimit: 4096, - terserOptions: { - compress: { - passes: 3, - drop_console: true, - drop_debugger: true, - dead_code: true, - unused: true - }, - mangle: { - toplevel: true - }, - ecma: 2020 - }, + ...createBaseBuildConfig(), + minify: 'terser' as const, + terserOptions: createHostedTerserOptions(), rollupOptions: { treeshake: { moduleSideEffects: false }, output: { - manualChunks(id: string) { - if (id.includes('node_modules')) { - if (id.includes('preact')) { - return '@preact'; - } - return 'vendor'; - } - return undefined; - } + manualChunks: createManualChunks(false) } } } @@ -178,110 +268,24 @@ export default defineConfig( return { plugins: [ - preact({ - // Enable Preact optimizations - devToolsEnabled: false, - prefreshEnabled: false - }), - viteTsconfigPaths(), - // Enable image optimization for size reduction - { - ...viteImagemin({ - verbose: false, - gifsicle: { - optimizationLevel: 7, - interlaced: false - }, - optipng: { - optimizationLevel: 7 - }, - mozjpeg: { - quality: 20 - }, - pngquant: { - quality: [0.8, 0.9], - speed: 4 - }, - svgo: { - plugins: [ - { - name: 'removeViewBox' - }, - { - name: 'removeEmptyAttrs', - active: false - } - ] - } - }), - enforce: 'pre' - }, + ...createBasePlugins(false, true), + imageOptimizationPlugin, visualizer({ - template: 'treemap', // or sunburst + template: 'treemap', open: false, gzipSize: true, brotliSize: true, - filename: '../analyse.html' // will be saved in project's root - }), - bundleSizeReporter() // Add bundle size reporting + filename: '../analyse.html' + }) ], - + resolve: { + alias: RESOLVE_ALIASES + }, build: { - // Target modern browsers for smaller bundles - target: 'es2020', - chunkSizeWarningLimit: 512, - minify: 'terser', - // Enable CSS minification - cssMinify: true, - // Optimize asset handling - assetsInlineLimit: 4096, // Inline small assets - terserOptions: { - compress: { - passes: 6, - arrows: true, - drop_console: true, - drop_debugger: true, - sequences: true - // Additional aggressive compression options - // dead_code: true, - // hoist_funs: true, - // hoist_vars: true, - // if_return: true, - // join_vars: true, - // loops: true, - // pure_getters: true, - // reduce_vars: true, - // side_effects: false, - // switches: true, - // unsafe: true, - // unsafe_arrows: true, - // unsafe_comps: true, - // unsafe_Function: true, - // unsafe_math: true, - // unsafe_proto: true, - // unsafe_regexp: true, - // unsafe_undefined: true, - // unused: true - }, - mangle: { - toplevel: true, // Enable top-level mangling - module: true // Enable module mangling - // properties: { - // regex: /^_/ // Mangle properties starting with _ - // } - }, - ecma: 2020, // Updated to modern ECMAScript - enclose: false, - keep_classnames: false, - keep_fnames: false, - ie8: false, - module: false, - safari10: false, - toplevel: true // Enable top-level optimization - }, - + ...createBaseBuildConfig(), + minify: 'terser' as const, + terserOptions: createProductionTerserOptions(), rollupOptions: { - // Enable aggressive tree shaking treeshake: { moduleSideEffects: false, propertyReadSideEffects: false, @@ -289,65 +293,11 @@ export default defineConfig( unknownGlobalSideEffects: false }, output: { - // Optimize chunk naming for better caching chunkFileNames: 'assets/[name]-[hash].js', entryFileNames: 'assets/[name]-[hash].js', assetFileNames: 'assets/[name]-[hash].[ext]', - manualChunks(id: string) { - if (id.includes('node_modules')) { - // More granular chunk splitting for better caching - if (id.includes('react-router')) { - return '@react-router'; - } - if (id.includes('preact')) { - return '@preact'; - } - if (id.includes('@mui/material')) { - return '@mui-material'; - } - if (id.includes('@mui/icons-material')) { - return '@mui-icons'; - } - if (id.includes('alova')) { - return '@alova'; - } - if (id.includes('typesafe-i18n')) { - return '@i18n'; - } - if (id.includes('react-toastify')) { - return '@toastify'; - } - if (id.includes('@table-library')) { - return '@table-library'; - } - if (id.includes('uuid')) { - return '@uuid'; - } - if (id.includes('axios') || id.includes('fetch')) { - return '@http'; - } - if (id.includes('lodash') || id.includes('ramda')) { - return '@utils'; - } - return 'vendor'; - } - // Split large application modules - if (id.includes('components/')) { - return 'components'; - } - if (id.includes('app/')) { - return 'app'; - } - if (id.includes('utils/')) { - return 'utils'; - } - if (id.includes('api/')) { - return 'api'; - } - return undefined; - }, - // Enable source maps for debugging (optional) - sourcemap: false // Disable for production to save space + manualChunks: createManualChunks(true), + sourcemap: false } } } diff --git a/mock-api/mockServer.js b/mock-api/mockServer.js index 1f9245bd6..ef1e82845 100644 --- a/mock-api/mockServer.js +++ b/mock-api/mockServer.js @@ -2,9 +2,26 @@ // Simulates file uploads and EventSource (SSE) for log messages import formidable from 'formidable'; +// Constants reused across requests +const VALID_EXTENSIONS = new Set(['bin', 'json', 'md5']); +const ONE_SECOND_MS = 1000; +const TEN_PERCENT = 10; + // Optimized padding function const pad = (number) => String(number).padStart(2, '0'); +// Simple throttle helper (time-based) +const throttle = (fn, intervalMs) => { + let last = 0; + return (...args) => { + const now = Date.now(); + if (now - last >= intervalMs) { + last = now; + fn(...args); + } + }; +}; + // Cached date formatter to avoid prototype pollution const formatDate = (date) => { const year = date.getUTCFullYear(); @@ -28,22 +45,50 @@ export default () => { server.middlewares.use(async (req, res, next) => { // Handle file uploads if (req.url.startsWith('/rest/uploadFile')) { + // CORS preflight support + if (req.method === 'OPTIONS') { + res.writeHead(204, { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control', + 'Access-Control-Max-Age': '600' + }); + res.end(); + return; + } + + if (req.method !== 'POST') { + res.statusCode = 405; + res.setHeader('Allow', 'POST, OPTIONS'); + res.end('Method Not Allowed'); + return; + } + const fileSize = parseInt(req.headers['content-length'] || '0', 10); let progress = 0; // Track upload progress + const logThrottled = throttle((percentage) => { + console.log(`Upload progress: ${percentage}%`); + }, ONE_SECOND_MS); + req.on('data', (chunk) => { progress += chunk.length; if (fileSize > 0) { const percentage = Math.round((progress / fileSize) * 100); - console.log(`Upload progress: ${percentage}%`); + // Only log every ~1s and for meaningful changes (>=10%) + if (percentage % TEN_PERCENT === 0) { + logThrottled(percentage); + } } }); try { const form = formidable({ maxFileSize: 50 * 1024 * 1024, // 50MB limit - keepExtensions: true + keepExtensions: true, + multiples: false, + allowEmptyFiles: false }); const [fields, files] = await form.parse(req); @@ -54,7 +99,9 @@ export default () => { return; } - const uploadedFile = files.file[0]; + const uploadedFile = Array.isArray(files.file) + ? files.file[0] + : files.file; const fileName = uploadedFile.originalFilename; const fileExtension = fileName .substring(fileName.lastIndexOf('.') + 1) @@ -65,8 +112,7 @@ export default () => { ); // Validate file extension - const validExtensions = new Set(['bin', 'json', 'md5']); - if (!validExtensions.has(fileExtension)) { + if (!VALID_EXTENSIONS.has(fileExtension)) { res.statusCode = 406; res.end('Invalid file extension'); return; @@ -85,10 +131,10 @@ export default () => { res.end(); } } catch (err) { - console.error('Upload error:', err.message); + console.error('Upload error:', err && err.message ? err.message : err); res.statusCode = err.httpCode || 400; res.setHeader('Content-Type', 'text/plain'); - res.end(err.message); + res.end(err && err.message ? err.message : 'Upload error'); } } @@ -97,12 +143,18 @@ export default () => { // Set SSE headers res.writeHead(200, { Connection: 'keep-alive', - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-cache, no-transform', 'Content-Type': 'text/event-stream', 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control' + 'Access-Control-Allow-Headers': 'Cache-Control', + 'X-Accel-Buffering': 'no' // disable proxy buffering (nginx, etc.) }); + // Flush headers early when supported + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + let messageCount = 0; const logLevels = [3, 4, 5, 6, 7, 8]; // Different log levels const logNames = ['system', 'ems', 'wifi', 'mqtt', 'ntp', 'api']; @@ -131,15 +183,24 @@ export default () => { }; // Send initial message + res.write(`retry: 2000\n\n`); // client reconnection delay sendLogMessage(); // Set up interval for periodic messages - const interval = setInterval(sendLogMessage, 1000); + const messageInterval = setInterval(sendLogMessage, 500); + if (typeof messageInterval.unref === 'function') messageInterval.unref(); + + // Heartbeat to keep connections alive through proxies + const heartbeat = setInterval(() => { + res.write(`:keep-alive ${Date.now()}\n\n`); + }, 15 * ONE_SECOND_MS); + if (typeof heartbeat.unref === 'function') heartbeat.unref(); // Clean up on connection close const cleanup = () => { console.log('SSE connection closed'); - clearInterval(interval); + clearInterval(messageInterval); + clearInterval(heartbeat); if (!res.destroyed) { res.end(); } @@ -147,6 +208,7 @@ export default () => { res.on('close', cleanup); res.on('error', cleanup); + res.on('finish', cleanup); } else { next(); // Continue to next middleware } diff --git a/mock-api/package.json b/mock-api/package.json index 33ef45bef..8f2345cb9 100644 --- a/mock-api/package.json +++ b/mock-api/package.json @@ -10,10 +10,10 @@ }, "dependencies": { "@msgpack/msgpack": "^3.1.2", - "@trivago/prettier-plugin-sort-imports": "^5.2.2", + "@trivago/prettier-plugin-sort-imports": "^6.0.0", "formidable": "^3.5.4", "itty-router": "^5.0.22", "prettier": "^3.6.2" }, - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" } diff --git a/mock-api/pnpm-lock.yaml b/mock-api/pnpm-lock.yaml index 14781c83e..c408890a4 100644 --- a/mock-api/pnpm-lock.yaml +++ b/mock-api/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^3.1.2 version: 3.1.2 '@trivago/prettier-plugin-sort-imports': - specifier: ^5.2.2 - version: 5.2.2(prettier@3.6.2) + specifier: ^6.0.0 + version: 6.0.0(prettier@3.6.2) formidable: specifier: ^3.5.4 version: 3.5.4 @@ -87,17 +87,20 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} - '@trivago/prettier-plugin-sort-imports@5.2.2': - resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} - engines: {node: '>18.12'} + '@trivago/prettier-plugin-sort-imports@6.0.0': + resolution: {integrity: sha512-Xarx55ow0R8oC7ViL5fPmDsg1EBa1dVhyZFVbFXNtPPJyW2w9bJADIla8YFSaNG9N06XfcklA9O9vmw4noNxkQ==} + engines: {node: '>= 20'} peerDependencies: '@vue/compiler-sfc': 3.x prettier: 2.x - 3.x + prettier-plugin-ember-template-tag: '>= 2.0.0' prettier-plugin-svelte: 3.x svelte: 4.x || 5.x peerDependenciesMeta: '@vue/compiler-sfc': optional: true + prettier-plugin-ember-template-tag: + optional: true prettier-plugin-svelte: optional: true svelte: @@ -106,6 +109,12 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -136,8 +145,12 @@ packages: engines: {node: '>=6'} hasBin: true - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -145,6 +158,12 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -227,20 +246,28 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 - '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)': + '@trivago/prettier-plugin-sort-imports@6.0.0(prettier@3.6.2)': dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.28.5 '@babel/traverse': 7.28.5 '@babel/types': 7.28.5 javascript-natural-sort: 0.7.1 - lodash: 4.17.21 + lodash-es: 4.17.21 + minimatch: 9.0.5 + parse-imports-exports: 0.2.4 prettier: 3.6.2 transitivePeerDependencies: - supports-color asap@2.0.6: {} + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -264,7 +291,11 @@ snapshots: jsesc@3.1.0: {} - lodash@4.17.21: {} + lodash-es@4.17.21: {} + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 ms@2.1.3: {} @@ -272,6 +303,12 @@ snapshots: dependencies: wrappy: 1.0.2 + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-statements@1.0.11: {} + picocolors@1.1.1: {} prettier@3.6.2: {} diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index f760dc3e5..cb3627521 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -4353,13 +4353,19 @@ router // SYSTEM and SETTINGS router .get(ACTIVITY_ENDPOINT, () => activity) - .get(SYSTEM_STATUS_ENDPOINT, () => { + .get(SYSTEM_STATUS_ENDPOINT, async () => { if (countHardwarePoll >= 2) { countHardwarePoll = 0; system_status.status = 0; // SYSTEM_STATUS_NORMAL } countHardwarePoll++; + // Add a small artificial delay to better simulate a real network, to see if flash is fixed + // await new Promise((resolve) => setTimeout(resolve, 3000)); + + system_status.uptime += 3; // simulate 3 seconds of uptime + system_status.bus_uptime += 3; + return system_status; }) .get(SECURITY_SETTINGS_ENDPOINT, () => security_settings) @@ -4376,143 +4382,55 @@ router // EMS-ESP Project stuff // +// Lookup maps to avoid repetitive branching per request +const DEVICE_DATA_MAP: Record = { + 1: emsesp_devicedata_1, + 2: emsesp_devicedata_2, + 3: emsesp_devicedata_3, + 4: emsesp_devicedata_4, + 5: emsesp_devicedata_5, + 6: emsesp_devicedata_6, + 7: emsesp_devicedata_7, + 8: emsesp_devicedata_8, + 9: emsesp_devicedata_9, + 10: emsesp_devicedata_10, + 11: emsesp_devicedata_11, + 99: emsesp_devicedata_99 +}; + +const DEVICE_ENTITIES_MAP: Record = { + 1: emsesp_deviceentities_1, + 2: emsesp_deviceentities_2, + 3: emsesp_deviceentities_3, + 4: emsesp_deviceentities_4, + 5: emsesp_deviceentities_5, + 6: emsesp_deviceentities_6, + 7: emsesp_deviceentities_7, + 8: emsesp_deviceentities_8, + 9: emsesp_deviceentities_9, + 10: emsesp_deviceentities_10 +}; + function deviceData(id: number) { - if (id == 1) { - return new Response(encoder.encode(emsesp_devicedata_1) as BodyInit, { - headers - }); - } - if (id == 2) { - return new Response(encoder.encode(emsesp_devicedata_2) as BodyInit, { - headers - }); - } - if (id == 3) { - return new Response(encoder.encode(emsesp_devicedata_3) as BodyInit, { - headers - }); - } - if (id == 4) { - return new Response(encoder.encode(emsesp_devicedata_4) as BodyInit, { - headers - }); - } - if (id == 5) { - return new Response(encoder.encode(emsesp_devicedata_5) as BodyInit, { - headers - }); - } - if (id == 6) { - return new Response(encoder.encode(emsesp_devicedata_6) as BodyInit, { - headers - }); - } - if (id == 7) { - return new Response(encoder.encode(emsesp_devicedata_7) as BodyInit, { - headers - }); - } - if (id == 8) { + if (id === 8) { // test changing the selected flow temp on a Bosch Compress 7000i AW Heat Pump (Boiler/HP) emsesp_devicedata_8.nodes[4].v = Math.floor(Math.random() * 100); - return new Response(encoder.encode(emsesp_devicedata_8) as BodyInit, { - headers - }); } - if (id == 9) { - return new Response(encoder.encode(emsesp_devicedata_9) as BodyInit, { - headers - }); - } - if (id == 10) { - return new Response(encoder.encode(emsesp_devicedata_10) as BodyInit, { - headers - }); - } - if (id == 11) { - return new Response(encoder.encode(emsesp_devicedata_11) as BodyInit, { - headers - }); - } - if (id == 99) { - return new Response(encoder.encode(emsesp_devicedata_99) as BodyInit, { - headers - }); + + const data = DEVICE_DATA_MAP[id]; + if (data) { + return new Response(encoder.encode(data) as BodyInit, { headers }); } } function deviceEntities(id: number) { - if (id == 1) { - return new Response(encoder.encode(emsesp_deviceentities_1) as BodyInit, { - headers - }); - } - if (id == 2) { - return new Response(encoder.encode(emsesp_deviceentities_2) as BodyInit, { - headers - }); - } - if (id == 3) { - return new Response(encoder.encode(emsesp_deviceentities_3) as BodyInit, { - headers - }); - } - if (id == 4) { - return new Response(encoder.encode(emsesp_deviceentities_4) as BodyInit, { - headers - }); - } - if (id == 5) { - return new Response(encoder.encode(emsesp_deviceentities_5) as BodyInit, { - headers - }); - } - if (id == 6) { - return new Response(encoder.encode(emsesp_deviceentities_6) as BodyInit, { - headers - }); - } - if (id == 7) { - return new Response(encoder.encode(emsesp_deviceentities_7) as BodyInit, { - headers - }); - } - if (id == 8) { - return new Response(encoder.encode(emsesp_deviceentities_8) as BodyInit, { - headers - }); - } - if (id == 9) { - return new Response(encoder.encode(emsesp_deviceentities_9) as BodyInit, { - headers - }); - } - if (id == 10) { - return new Response(encoder.encode(emsesp_deviceentities_10) as BodyInit, { - headers - }); - } - // not found, return empty - return new Response(encoder.encode(emsesp_deviceentities_none) as BodyInit, { - headers - }); + const data = DEVICE_ENTITIES_MAP[id] || emsesp_deviceentities_none; + return new Response(encoder.encode(data) as BodyInit, { headers }); } // prepare dashboard data function getDashboardEntityData(id: number) { - let device_data = {}; - if (id == 1) device_data = emsesp_devicedata_1; - else if (id == 2) device_data = emsesp_devicedata_2; - else if (id == 3) device_data = emsesp_devicedata_3; - else if (id == 4) device_data = emsesp_devicedata_4; - else if (id == 5) device_data = emsesp_devicedata_5; - else if (id == 6) device_data = emsesp_devicedata_6; - else if (id == 7) device_data = emsesp_devicedata_7; - else if (id == 8) device_data = emsesp_devicedata_8; - else if (id == 9) device_data = emsesp_devicedata_9; - else if (id == 10) device_data = emsesp_devicedata_10; - else if (id == 11) device_data = emsesp_devicedata_11; - else if (id == 99) device_data = emsesp_devicedata_99; + const device_data = DEVICE_DATA_MAP[id] || { nodes: [] }; // filter device_data on // - only add favorite (mask has bit 8 set) except for Custom Entities (type 99) diff --git a/project-words.txt b/project-words.txt index ee977b90d..feffbe623 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1433,4 +1433,6 @@ bedsingle beddouble teddybear washingmachine -switchprogram \ No newline at end of file +switchprogram +brotlin +fanspd \ No newline at end of file diff --git a/scripts/update_all.sh b/scripts/update_all.sh index 26f3cd344..b414f59b3 100644 --- a/scripts/update_all.sh +++ b/scripts/update_all.sh @@ -20,7 +20,6 @@ pnpm format cd .. cd interface -pnpm build pnpm webUI cd .. diff --git a/scripts/upload.py b/scripts/upload.py index f0d7322d7..19be74710 100644 --- a/scripts/upload.py +++ b/scripts/upload.py @@ -36,8 +36,34 @@ except ImportError: from termcolor import cprint -def print_success(x): return cprint(x, 'green') -def print_fail(x): return cprint('Error: '+x, 'red') +def print_success(x): + cprint(x, 'green') + + +def print_fail(x): + cprint(f'Error: {x}', 'red') + + +def build_headers(host_ip, emsesp_url, content_type='application/json', access_token=None, extra_headers=None): + """Build common HTTP headers with optional overrides.""" + headers = { + 'Host': host_ip, + 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', + 'Accept': '*/*', + 'Accept-Language': 'en-US', + 'Accept-Encoding': 'gzip, deflate', + 'Referer': emsesp_url, + 'Content-Type': content_type, + 'Connection': 'keep-alive' + } + + if access_token: + headers['Authorization'] = f'Bearer {access_token}' + + if extra_headers: + headers.update(extra_headers) + + return headers def on_upload(source, target, env): @@ -53,26 +79,16 @@ def on_upload(source, target, env): username = env.GetProjectOption('custom_username') password = env.GetProjectOption('custom_password') emsesp_ip = env.GetProjectOption('custom_emsesp_ip') - except: - print_fail('Missing settings. Add these to your pio_local.ini file: \n\ncustom_username=username\ncustom_password=password\ncustom_emsesp_ip=ems-esp.local\n') + except Exception as e: + print_fail(f'Missing settings. Add these to your pio_local.ini file:\n\ncustom_username=username\ncustom_password=password\ncustom_emsesp_ip=ems-esp.local\n') return - emsesp_url = "http://" + env.GetProjectOption('custom_emsesp_ip') + emsesp_url = f"http://{emsesp_ip}" parsed_url = urlparse(emsesp_url) host_ip = parsed_url.netloc signon_url = f"{emsesp_url}/rest/signIn" - - signon_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Content-Type': 'application/json', - 'Connection': 'keep-alive' - } + signon_headers = build_headers(host_ip, emsesp_url) username_password = { "username": username, @@ -114,19 +130,16 @@ def on_upload(source, target, env): monitor = MultipartEncoderMonitor( encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n)) - post_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Connection': 'keep-alive', - 'Content-Type': monitor.content_type, - 'Content-Length': str(monitor.len), - 'Origin': f'{emsesp_url}', - 'Authorization': 'Bearer ' + f'{access_token}' - } + post_headers = build_headers( + host_ip, + emsesp_url, + content_type=monitor.content_type, + access_token=access_token, + extra_headers={ + 'Content-Length': str(monitor.len), + 'Origin': emsesp_url + } + ) upload_url = f"{emsesp_url}/rest/uploadFile" @@ -139,25 +152,15 @@ def on_upload(source, target, env): print() if response.status_code != 200: - print_fail("Upload failed (code " + response.status.code + ").") + print_fail(f"Upload failed (code {response.status_code}).") else: print_success("Upload successful. Rebooting device.") - restart_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - 'Authorization': 'Bearer ' + f'{access_token}' - } + restart_headers = build_headers( + host_ip, emsesp_url, access_token=access_token) restart_url = f"{emsesp_url}/api/system/restart" response = requests.get(restart_url, headers=restart_headers) if response.status_code != 200: - print_fail("Restart failed (code " + - str(response.status_code) + ")") + print_fail(f"Restart failed (code {response.status_code})") print() diff --git a/scripts/upload_cli.py b/scripts/upload_cli.py index c4e066e3a..a6ed77681 100644 --- a/scripts/upload_cli.py +++ b/scripts/upload_cli.py @@ -11,125 +11,154 @@ # python3 upload_cli.py -i 10.10.10.175 -f ../build/firmware/EMS-ESP-3_7_0-dev_34-ESP32S3-16MB+.bin import argparse -import requests import hashlib -from urllib.parse import urlparse +import sys import time +from pathlib import Path + +import requests from requests_toolbelt import MultipartEncoder, MultipartEncoderMonitor -from tqdm import tqdm from termcolor import cprint +from tqdm import tqdm + +# Constants +USER_AGENT = 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0' +CHUNK_SIZE = 8192 # 8KB chunks for MD5 calculation -def print_success(x): return cprint(x, 'green') -def print_fail(x): return cprint(x, 'red') +def print_success(x): + return cprint(x, 'green') + + +def print_fail(x): + return cprint(x, 'red') + + +def calculate_md5(file_path): + """Calculate MD5 hash of a file in chunks for memory efficiency.""" + md5_hash = hashlib.md5() + with open(file_path, 'rb') as f: + for chunk in iter(lambda: f.read(CHUNK_SIZE), b''): + md5_hash.update(chunk) + return md5_hash.hexdigest() + + +def create_base_headers(host_ip, emsesp_url): + """Create base headers used across all requests.""" + return { + 'Host': host_ip, + 'User-Agent': USER_AGENT, + 'Accept': '*/*', + 'Accept-Language': 'en-US', + 'Accept-Encoding': 'gzip, deflate', + 'Referer': emsesp_url, + 'Connection': 'keep-alive' + } def upload(file, ip, username, password): - + """Upload firmware to EMS-ESP device.""" # Print welcome message print() print("EMS-ESP Firmware Upload") - # first check authentication - emsesp_url = "http://" + f'{ip}' - parsed_url = urlparse(emsesp_url) - host_ip = parsed_url.netloc - - signon_url = f"{emsesp_url}/rest/signIn" - - signon_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Content-Type': 'application/json', - 'Connection': 'keep-alive' - } - - username_password = { - "username": username, - "password": password - } - - response = requests.post( - signon_url, json=username_password, headers=signon_headers, auth=None) - - if response.status_code != 200: - print_fail("Authentication failed (code " + - str(response.status_code) + ")") + # Validate file exists + file_path = Path(file) + if not file_path.exists(): + print_fail(f"File not found: {file}") return - print_success("Authentication successful") - access_token = response.json().get('access_token') + # Setup URLs and headers + emsesp_url = f"http://{ip}" + host_ip = ip + + # Use a session for connection pooling and persistence + session = requests.Session() + + try: + # Authentication + signon_url = f"{emsesp_url}/rest/signIn" + signon_headers = create_base_headers(host_ip, emsesp_url) + signon_headers['Content-Type'] = 'application/json' - # start the upload - with open(file, 'rb') as firmware: - md5 = hashlib.md5(firmware.read()).hexdigest() - - firmware.seek(0) - - encoder = MultipartEncoder(fields={ - 'MD5': md5, - 'file': (file, firmware, 'application/octet-stream')} - ) - - bar = tqdm(desc='Upload Progress', - total=encoder.len, - dynamic_ncols=True, - unit='B', - unit_scale=True, - unit_divisor=1024 - ) - - monitor = MultipartEncoderMonitor( - encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n)) - - post_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Connection': 'keep-alive', - 'Content-Type': monitor.content_type, - 'Content-Length': str(monitor.len), - 'Origin': f'{emsesp_url}', - 'Authorization': 'Bearer ' + f'{access_token}' + username_password = { + "username": username, + "password": password } - upload_url = f"{emsesp_url}/rest/uploadFile" - - response = requests.post( - upload_url, data=monitor, headers=post_headers, auth=None) - - bar.close() - time.sleep(0.1) + response = session.post(signon_url, json=username_password, headers=signon_headers) if response.status_code != 200: - print_fail("Upload failed (code " + response.status.code + ").") - else: - print_success("Upload successful. Rebooting device.") - restart_headers = { - 'Host': host_ip, - 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0', - 'Accept': '*/*', - 'Accept-Language': 'en-US', - 'Accept-Encoding': 'gzip, deflate', - 'Referer': f'{emsesp_url}', - 'Content-Type': 'application/json', - 'Connection': 'keep-alive', - 'Authorization': 'Bearer ' + f'{access_token}' - } - restart_url = f"{emsesp_url}/api/system/restart" - response = requests.get(restart_url, headers=restart_headers) - if response.status_code != 200: - print_fail("Restart failed (code " + - str(response.status_code) + ")") + print_fail(f"Authentication failed (code {response.status_code})") + return - print() + print_success("Authentication successful") + access_token = response.json().get('access_token') + + # Calculate MD5 hash + print("Calculating MD5 hash...") + md5 = calculate_md5(file_path) + + # Start the upload + with open(file_path, 'rb') as firmware: + encoder = MultipartEncoder(fields={ + 'MD5': md5, + 'file': (file, firmware, 'application/octet-stream') + }) + + bar = tqdm( + desc='Upload Progress', + total=encoder.len, + dynamic_ncols=True, + unit='B', + unit_scale=True, + unit_divisor=1024 + ) + + monitor = MultipartEncoderMonitor( + encoder, lambda monitor: bar.update(monitor.bytes_read - bar.n)) + + post_headers = create_base_headers(host_ip, emsesp_url) + post_headers.update({ + 'Content-Type': monitor.content_type, + 'Content-Length': str(monitor.len), + 'Origin': emsesp_url, + 'Authorization': f'Bearer {access_token}' + }) + + upload_url = f"{emsesp_url}/rest/uploadFile" + response = session.post(upload_url, data=monitor, headers=post_headers) + + bar.close() + time.sleep(0.1) + + if response.status_code != 200: + print_fail(f"Upload failed (code {response.status_code})") + else: + print_success("Upload successful. Rebooting device.") + restart_headers = create_base_headers(host_ip, emsesp_url) + restart_headers.update({ + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {access_token}' + }) + restart_url = f"{emsesp_url}/api/system/restart" + response = session.get(restart_url, headers=restart_headers) + if response.status_code != 200: + print_fail(f"Restart failed (code {response.status_code})") + + except requests.RequestException as e: + print_fail(f"Network error: {e}") + sys.exit(1) + except IOError as e: + print_fail(f"File error: {e}") + sys.exit(1) + except Exception as e: + print_fail(f"Unexpected error: {e}") + sys.exit(1) + finally: + session.close() + + print() # main diff --git a/src/ESP32React/ESP32React.cpp b/src/ESP32React/ESP32React.cpp index 0047528c2..72bd28a2d 100644 --- a/src/ESP32React/ESP32React.cpp +++ b/src/ESP32React/ESP32React.cpp @@ -19,47 +19,49 @@ ESP32React::ESP32React(AsyncWebServer * server, FS * fs) // Serve static web resources // - // Populate the last modification date based on build datetime - static char last_modified[50]; - sprintf(last_modified, "%s %s CET", __DATE__, __TIME__); + ArRequestHandlerFunction indexHtmlHandler = nullptr; - WWWData::registerRoutes([server](const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash) { + WWWData::registerRoutes([server, &indexHtmlHandler](const char * uri, const String & contentType, const uint8_t * content, size_t len, const String & hash) { ArRequestHandlerFunction requestHandler = [contentType, content, len, hash](AsyncWebServerRequest * request) { + AsyncWebServerResponse * response; + // Check if the client already has the same version and respond with a 304 (Not modified) - if (request->header("If-Modified-Since").indexOf(last_modified) > 0) { - return request->send(304); - } else if (request->header("If-None-Match").equals(hash)) { - return request->send(304); + if (request->header("If-None-Match").equals(hash)) { + response = request->beginResponse(304); + } else { + response = request->beginResponse(200, contentType, content, len); + response->addHeader("Content-Encoding", "gzip"); // not br for brotlin only works over HTTPS } - AsyncWebServerResponse * response = request->beginResponse(200, contentType, content, len); - - response->addHeader("Content-Encoding", "gzip"); - // response->addHeader("Content-Encoding", "br"); // only works over HTTPS - // response->addHeader("Cache-Control", "public, immutable, max-age=31536000"); - response->addHeader("Cache-Control", "must-revalidate"); // ensure that a client will check the server for a change - response->addHeader("Last-Modified", last_modified); + // always send these headers - see https://datatracker.ietf.org/doc/html/rfc7232#section-4.1 response->addHeader("ETag", hash); + response->addHeader("Cache-Control", "no-cache"); // Requires revalidation before using cached content (ETags enable 304 responses) request->send(response); }; server->on(uri, HTTP_GET, requestHandler); - // Serving non matching get requests with "/index.html" - // OPTIONS get a straight up 200 response - if (strncmp(uri, "/index.html", 11) == 0) { - server->onNotFound([requestHandler](AsyncWebServerRequest * request) { - if (request->method() == HTTP_GET) { - requestHandler(request); - } else if (request->method() == HTTP_OPTIONS) { - request->send(200); - } else { - request->send(404); - } - }); + // Capture index.html handler to set onNotFound once after all routes are registered + if (strcmp(uri, "/index.html") == 0) { + indexHtmlHandler = requestHandler; } }); + + // Set onNotFound handler once after all routes are registered + // Serving non matching get requests with "/index.html" + // OPTIONS get a straight up 200 response + if (indexHtmlHandler != nullptr) { + server->onNotFound([indexHtmlHandler](AsyncWebServerRequest * request) { + if (request->method() == HTTP_GET) { + indexHtmlHandler(request); + } else if (request->method() == HTTP_OPTIONS) { + request->send(200); + } else { + request->send(404); // not found + } + }); + } } void ESP32React::begin() { diff --git a/src/core/modbus.cpp b/src/core/modbus.cpp index 0121e7be5..803ab80d4 100644 --- a/src/core/modbus.cpp +++ b/src/core/modbus.cpp @@ -455,7 +455,7 @@ int Modbus::getRegisterCount(const DeviceValue & dv) { uint32_t num_values = std::max(dv.max, (uint32_t)abs(dv.min)); int num_registers = 0; - if (num_values < (1L << 16)) + if (num_values < (1L << 16)) num_registers = 1; else if (num_values <= (0xFFFFFFFF)) num_registers = 2; diff --git a/src/core/system.cpp b/src/core/system.cpp index 2bf2fe7a7..5ca1e9a4d 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -214,7 +214,7 @@ bool System::command_message(const char * value, const int8_t id, JsonObject out EMSESP::webSchedulerService.computed_value.clear(); EMSESP::webSchedulerService.raw_value = value; - for (uint8_t wait = 0; wait < 2000 && !EMSESP::webSchedulerService.raw_value.empty(); wait++) { + for (uint16_t wait = 0; wait < 2000 && !EMSESP::webSchedulerService.raw_value.empty(); wait++) { delay(1); } diff --git a/src/devices/boiler.h b/src/devices/boiler.h index 34faffa64..d893a911a 100644 --- a/src/devices/boiler.h +++ b/src/devices/boiler.h @@ -247,9 +247,9 @@ class Boiler : public EMSdevice { uint8_t fan_; uint8_t fanspd_; uint8_t hpshutdown_; - uint8_t receiverValveVr0_; - uint8_t expansionValveVr1_; - uint8_t hpTargetSpd_; + uint8_t receiverValveVr0_; + uint8_t expansionValveVr1_; + uint8_t hpTargetSpd_; // Pool unit int8_t poolSetTemp_; diff --git a/src/devices/connect.cpp b/src/devices/connect.cpp index b64ab5903..e06a40650 100644 --- a/src/devices/connect.cpp +++ b/src/devices/connect.cpp @@ -208,7 +208,7 @@ bool Connect::set_mode(const char * value, const int8_t id) { } uint8_t v; if (Helpers::value2enum(value, v, FL_(enum_mode2), {3, 1, 0})) { - // if (Helpers::value2enum(value, v, FL_(enum_mode8))) { + // if (Helpers::value2enum(value, v, FL_(enum_mode8))) { write_command(0xBB5 + rc->room(), 0, v); // no validate, mode change is broadcasted return true; } diff --git a/src/emsesp_version.h b/src/emsesp_version.h index 02ce67215..caaa33454 100644 --- a/src/emsesp_version.h +++ b/src/emsesp_version.h @@ -1 +1 @@ -#define EMSESP_APP_VERSION "3.7.3-dev.24" +#define EMSESP_APP_VERSION "3.7.3-dev.25" diff --git a/src/web/WebLogService.cpp b/src/web/WebLogService.cpp index 214204f2c..363b11576 100644 --- a/src/web/WebLogService.cpp +++ b/src/web/WebLogService.cpp @@ -32,6 +32,9 @@ WebLogService::WebLogService(AsyncWebServer * server, SecurityManager * security [this](AsyncWebServerRequest * request, JsonVariant json) { getSetValues(request, json); }, HTTP_ANY); + // Add authentication filter to EventSource + // EventSource (SSE) cannot use custom headers, so authentication is done via URL parameter + // events_.setFilter(securityManager->filterRequest(AuthenticationPredicates::IS_AUTHENTICATED)); server->addHandler(&events_); } diff --git a/test/test_api/api_test.http b/test/test_api/api_test.http index 08cb74bf3..018f14d28 100755 --- a/test/test_api/api_test.http +++ b/test/test_api/api_test.http @@ -4,7 +4,7 @@ # The response will be shown in the right panel # @host = http://ems-esp.local -@host = http://192.168.1.223 +@host = http://192.168.1.65 @host_dev = http://10.10.10.175 @host_standalone = http://localhost:3080 @host_standalone2 = http://localhost:3082 @@ -17,6 +17,17 @@ GET {{host}}/api/system/info GET {{host}}/api/thermostat/seltemp +### + +POST {{host}}/api/system/message +Content-Type: application/json +Authorization: Bearer {{token}} + +{ + "value" : "system/settings/locale" +} + + ### POST {{host}}/api/thermostat/seltemp