diff --git a/interface/package.json b/interface/package.json index ae0c1770d..a6b0240e3 100644 --- a/interface/package.json +++ b/interface/package.json @@ -13,9 +13,9 @@ "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", "format": "prettier -l -w '**/*.{ts,tsx,js,css,json,md}'", @@ -27,6 +27,7 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^7.3.4", "@mui/material": "^7.3.4", + "@preact/compat": "^18.3.1", "@table-library/react-table-library": "4.1.15", "alova": "3.3.4", "async-validator": "^4.2.5", @@ -46,10 +47,9 @@ "devDependencies": { "@babel/core": "^7.28.5", "@eslint/js": "^9.38.0", - "@preact/compat": "^18.3.1", "@preact/preset-vite": "^2.10.2", "@trivago/prettier-plugin-sort-imports": "^5.2.2", - "@types/node": "^24.9.1", + "@types/node": "^24.9.2", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "concurrently": "^9.2.1", @@ -63,5 +63,5 @@ "vite-plugin-imagemin": "^0.6.1", "vite-tsconfig-paths": "^5.1.4" }, - "packageManager": "pnpm@10.19.0+sha512.c9fc7236e92adf5c8af42fd5bf1612df99c2ceb62f27047032f4720b33f8eacdde311865e91c411f2774f618d82f320808ecb51718bfa82c060c4ba7c76a32b8" + "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" } diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index bc79ff892..c059a8ccd 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@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) + '@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) @@ -75,18 +78,15 @@ importers: '@eslint/js': specifier: ^9.38.0 version: 9.38.0 - '@preact/compat': - specifier: ^18.3.1 - version: 18.3.1(preact@10.27.2) '@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.9.1)(terser@5.44.0)) + version: 2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)) '@trivago/prettier-plugin-sort-imports': specifier: ^5.2.2 version: 5.2.2(prettier@3.6.2) '@types/node': - specifier: ^24.9.1 - version: 24.9.1 + specifier: ^24.9.2 + version: 24.9.2 '@types/react': specifier: ^19.2.2 version: 19.2.2 @@ -116,13 +116,13 @@ importers: version: 8.46.2(eslint@9.38.0)(typescript@5.9.3) vite: specifier: ^7.1.12 - version: 7.1.12(@types/node@24.9.1)(terser@5.44.0) + version: 7.1.12(@types/node@24.9.2)(terser@5.44.0) vite-plugin-imagemin: specifier: ^0.6.1 - version: 0.6.1(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)) + version: 0.6.1(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)) + version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)) packages: @@ -851,8 +851,8 @@ packages: resolution: {integrity: sha512-zmPitbQ8+6zNutpwgcQuLcsEpn/Cj54Kbn7L5pX0Os5kdWplB7xPgEh/g+SWOB/qmows2gpuCaPyduq8ZZRnxA==} deprecated: This is a stub types definition. minimatch provides its own type definitions, so you do not need this installed. - '@types/node@24.9.1': - resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} + '@types/node@24.9.2': + resolution: {integrity: sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1324,8 +1324,8 @@ packages: duplexer3@0.1.5: resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - electron-to-chromium@1.5.240: - resolution: {integrity: sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==} + electron-to-chromium@1.5.241: + resolution: {integrity: sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2650,8 +2650,8 @@ packages: engines: {node: '>=10'} hasBin: true - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -3575,18 +3575,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.9.1)(terser@5.44.0))': + '@preact/preset-vite@2.10.2(@babel/core@7.28.5)(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))': dependencies: '@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.9.1)(terser@5.44.0)) + '@prefresh/vite': 2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)) '@rollup/pluginutils': 4.2.1 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.9.1)(terser@5.44.0) - vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)) + vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0) + vite-prerender-plugin: 0.5.12(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)) transitivePeerDependencies: - preact - supports-color @@ -3599,7 +3599,7 @@ snapshots: '@prefresh/utils@1.2.1': {} - '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0))': + '@prefresh/vite@2.4.11(preact@10.27.2)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0))': dependencies: '@babel/core': 7.28.5 '@prefresh/babel-plugin': 0.5.2 @@ -3607,7 +3607,7 @@ snapshots: '@prefresh/utils': 1.2.1 '@rollup/pluginutils': 4.2.1 preact: 10.27.2 - vite: 7.1.12(@types/node@24.9.1)(terser@5.44.0) + vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0) transitivePeerDependencies: - supports-color @@ -3712,7 +3712,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 24.9.1 + '@types/node': 24.9.2 '@types/imagemin-gifsicle@7.0.4': dependencies: @@ -3741,19 +3741,19 @@ snapshots: '@types/imagemin@7.0.1': dependencies: - '@types/node': 24.9.1 + '@types/node': 24.9.2 '@types/json-schema@7.0.15': {} '@types/keyv@3.1.4': dependencies: - '@types/node': 24.9.1 + '@types/node': 24.9.2 '@types/minimatch@6.0.0': dependencies: minimatch: 10.0.3 - '@types/node@24.9.1': + '@types/node@24.9.2': dependencies: undici-types: 7.16.0 @@ -3775,11 +3775,11 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 24.9.1 + '@types/node': 24.9.2 '@types/svgo@2.6.4': dependencies: - '@types/node': 24.9.1 + '@types/node': 24.9.2 '@typescript-eslint/eslint-plugin@8.46.2(@typescript-eslint/parser@8.46.2(eslint@9.38.0)(typescript@5.9.3))(eslint@9.38.0)(typescript@5.9.3)': dependencies: @@ -3995,7 +3995,7 @@ snapshots: dependencies: baseline-browser-mapping: 2.8.20 caniuse-lite: 1.0.30001751 - electron-to-chromium: 1.5.240 + electron-to-chromium: 1.5.241 node-releases: 2.0.26 update-browserslist-db: 1.1.4(browserslist@4.27.0) @@ -4340,7 +4340,7 @@ snapshots: duplexer3@0.1.5: {} - electron-to-chromium@1.5.240: {} + electron-to-chromium@1.5.241: {} emoji-regex@8.0.0: {} @@ -5501,7 +5501,7 @@ snapshots: dependencies: cookie: 1.0.2 react: 19.2.0 - set-cookie-parser: 2.7.1 + set-cookie-parser: 2.7.2 optionalDependencies: react-dom: 19.2.0(react@19.2.0) @@ -5653,7 +5653,7 @@ snapshots: semver@7.7.3: {} - set-cookie-parser@2.7.1: {} + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: dependencies: @@ -5935,7 +5935,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.9.1)(terser@5.44.0)): + vite-plugin-imagemin@0.6.1(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)): dependencies: '@types/imagemin': 7.0.1 '@types/imagemin-gifsicle': 7.0.4 @@ -5960,11 +5960,11 @@ snapshots: imagemin-webp: 6.1.0 jpegtran-bin: 6.0.1 pathe: 0.2.0 - vite: 7.1.12(@types/node@24.9.1)(terser@5.44.0) + vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0) transitivePeerDependencies: - supports-color - vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)): + vite-prerender-plugin@0.5.12(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)): dependencies: kolorist: 1.8.0 magic-string: 0.30.21 @@ -5972,20 +5972,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.9.1)(terser@5.44.0) + vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0) - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.1)(terser@5.44.0)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@24.9.2)(terser@5.44.0)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.1.12(@types/node@24.9.1)(terser@5.44.0) + vite: 7.1.12(@types/node@24.9.2)(terser@5.44.0) transitivePeerDependencies: - supports-color - typescript - vite@7.1.12(@types/node@24.9.1)(terser@5.44.0): + vite@7.1.12(@types/node@24.9.2)(terser@5.44.0): dependencies: esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) @@ -5994,7 +5994,7 @@ snapshots: rollup: 4.52.5 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.9.1 + '@types/node': 24.9.2 fsevents: 2.3.3 terser: 5.44.0 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/CustomEntitiesDialog.tsx b/interface/src/app/main/CustomEntitiesDialog.tsx index bf10b55fc..26eb8f8b0 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; @@ -60,8 +73,7 @@ const CustomEntitiesDialog = ({ useEffect(() => { if (open) { setFieldErrors(undefined); - setEditItem(selectedItem); - // convert to hex strings straight away + // Convert to hex strings - combined into single setEditItem call setEditItem({ ...selectedItem, device_id: selectedItem.device_id.toString(16).toUpperCase(), @@ -83,36 +95,51 @@ const CustomEntitiesDialog = ({ } }; - 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 = 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 = 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 = 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 +147,6 @@ const CustomEntitiesDialog = ({ {creating ? LL.ADD(1) + ' ' + LL.NEW(1) : LL.EDIT()} {LL.ENTITY()} - - - - {DeviceValueUOM_s.map((val, i) => ( - - {val} - - ))} + {uomMenuItems} @@ -275,33 +295,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 +331,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..1f030b3b7 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'; @@ -64,6 +64,15 @@ import type { APIcall, Device, DeviceEntity } from './types'; export const APIURL = window.location.origin + '/api/'; +// Helper function to create masked entity ID - extracted to avoid duplication +const createMaskedEntityId = (de: DeviceEntity): string => + de.m.toString(16).padStart(2, '0') + + de.id + + (de.cn || de.mi || de.ma ? '|' : '') + + (de.cn ? de.cn : '') + + (de.mi ? '>' + de.mi : '') + + (de.ma ? '<' + de.ma : ''); + const Customizations = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -153,17 +162,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 +185,7 @@ const Customizations = () => { text-align: right; } `, - HeaderRow: ` + HeaderRow: ` text-transform: uppercase; background-color: black; color: #90CAF9; @@ -186,7 +197,7 @@ const Customizations = () => { text-align: center; } `, - Row: ` + Row: ` background-color: #1e1e1e; position: relative; cursor: pointer; @@ -202,7 +213,7 @@ const Customizations = () => { background-color: #177ac9; } `, - Cell: ` + Cell: ` &:nth-of-type(2) { padding: 8px; } @@ -216,7 +227,9 @@ const Customizations = () => { padding-right: 8px; } ` - }); + }), + [] + ); function hasEntityChanged(de: DeviceEntity) { return ( @@ -229,19 +242,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]); @@ -316,9 +318,12 @@ 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] + ); const maskDisabled = (set: boolean) => { setDeviceEntities( @@ -388,15 +393,7 @@ const Customizations = () => { 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 : '') - ); + .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; @@ -512,9 +509,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 ( <> @@ -612,13 +612,13 @@ const Customizations = () => { - {LL.SHOWING()} {shown_data.length}/{deviceEntities.length} + {LL.SHOWING()} {filteredEntities.length}/{deviceEntities.length}  {LL.ENTITIES(deviceEntities.length)} diff --git a/interface/src/app/main/CustomizationsDialog.tsx b/interface/src/app/main/CustomizationsDialog.tsx index f303634a0..7b15893ad 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 { useCallback, useEffect, useMemo, useState } from 'react'; import CancelIcon from '@mui/icons-material/Cancel'; import CloseIcon from '@mui/icons-material/Close'; @@ -30,6 +30,20 @@ interface SettingsCustomizationsDialogProps { selectedItem: DeviceEntity; } +interface LabelValueProps { + label: string; + value: React.ReactNode; +} + +const LabelValue = ({ label, value }: LabelValueProps) => ( + + + {label}:  + + {value} + +); + const CustomizationsDialog = ({ open, onClose, @@ -42,10 +56,13 @@ const CustomizationsDialog = ({ const updateFormValue = updateValue(setEditItem); - 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 +71,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({ ...editItem, m: updatedItem.m }); + }, + [editItem] + ); return ( {LL.EDIT() + ' ' + LL.ENTITY()} - - - {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 )} + {progress && ( ) : ( - )} diff --git a/interface/src/app/main/EntityMaskToggle.tsx b/interface/src/app/main/EntityMaskToggle.tsx index 3fee880f3..5f9ffd55d 100644 --- a/interface/src/app/main/EntityMaskToggle.tsx +++ b/interface/src/app/main/EntityMaskToggle.tsx @@ -9,91 +9,110 @@ interface EntityMaskToggleProps { de: DeviceEntity; } +// Available mask values +const MASK_VALUES = [ + DeviceEntityMask.DV_WEB_EXCLUDE, // 1 + DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2 + DeviceEntityMask.DV_READONLY, // 4 + DeviceEntityMask.DV_FAVORITE, // 8 + DeviceEntityMask.DV_DELETED // 128 +]; + +/** + * Converts an array of mask strings to a bitmask number + */ +const getMaskNumber = (newMask: string[]): number => { + return newMask.reduce((mask, entry) => mask | Number(entry), 0); +}; + +/** + * Converts a bitmask number to an array of mask strings + */ +const getMaskString = (mask: number): string[] => { + return MASK_VALUES.filter((value) => (mask & value) === value).map((value) => + String(value) + ); +}; + +/** + * Checks if a specific mask bit is set + */ +const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag; + const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => { - const getMaskNumber = (newMask: string[]) => { - let new_mask = 0; - for (const entry of newMask) { - new_mask |= Number(entry); + const handleChange = (_event: unknown, mask: string[]) => { + // Convert selected masks to a number + const newMask = getMaskNumber(mask); + + // Apply business logic for mask interactions + // If entity has no name and is set to readonly, also exclude from web + if (de.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) { + de.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE; + } else { + de.m = newMask; } - return new_mask; + + // If excluded from web, cannot be favorite + if (hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)) { + de.m = de.m & ~DeviceEntityMask.DV_FAVORITE; + } + + onUpdate(de); }; - const getMaskString = (m: number) => { - 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; - }; + // Check if favorite button should be disabled + const isFavoriteDisabled = + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) || + de.n === undefined; + + // Check if readonly button should be disabled + const isReadonlyDisabled = + !de.w || + hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE); + + // Check if api/mqtt exclude button should be disabled + const isApiMqttExcludeDisabled = + de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED); + + // Check if web exclude button should be disabled + const isWebExcludeDisabled = + de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED); 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); - }} + onChange={handleChange} > - + - = 3}> + - + - + diff --git a/interface/src/app/main/Help.tsx b/interface/src/app/main/Help.tsx index 81686d44e..b91e1220e 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,83 @@ const Help = () => { toast.error(String(error.error?.message || 'An error occurred')); }); + const handleDownloadSystemInfo = useCallback(() => { + void sendAPI({ device: 'system', cmd: 'info', id: 0 }); + }, [sendAPI]); + + const handleImageError = useCallback(() => { + setImgError(true); + }, []); + + // Memoize help links to prevent recreation on every render + const helpLinks: HelpLink[] = useMemo( + () => [ + { + href: 'https://docs.emsesp.org', + icon: , + 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] + ); + + // 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 && ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + {helpLinks.map(({ href, icon, label }) => ( + + + + {icon} + + + + + ))} )} @@ -158,7 +189,7 @@ const Help = () => { startIcon={} variant="outlined" color="primary" - onClick={() => sendAPI({ device: 'system', cmd: 'info', id: 0 })} + onClick={handleDownloadSystemInfo} > {LL.SUPPORT_INFORMATION(0)} @@ -174,11 +205,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..ef4f7bec0 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 { useCallback, useMemo, useState } from 'react'; import { useBlocker } from 'react-router'; import { toast } from 'react-toastify'; @@ -31,6 +31,20 @@ import { readModules, writeModules } from '../../api/app'; import ModulesDialog from './ModulesDialog'; import type { ModuleItem } from './types'; +const PENDING_COLOR = 'red'; +const ACTIVATED_COLOR = '#00FF7F'; + +function hasModulesChanged(mi: ModuleItem): boolean { + return mi.enabled !== mi.o_enabled || mi.license !== mi.o_license; +} + +const colorStatus = (status: number) => { + if (status === 1) { + return
Pending Activation
; + } + return
Activated
; +}; + const Modules = () => { const { LL } = useI18nContext(); const [numChanges, setNumChanges] = useState(0); @@ -56,83 +70,87 @@ 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 - ); - setNumChanges(new_data.filter((mi) => hasModulesChanged(mi)).length); - return new_data; - }); - }; - - const saveModules = async () => { + const saveModules = useCallback(async () => { await Promise.all( modules.map((condensed_mi: ModuleItem) => updateModules({ @@ -152,9 +170,9 @@ const Modules = () => { await fetchModules(); setNumChanges(0); }); - }; + }, [modules, updateModules, LL, fetchModules]); - const renderContent = () => { + const content = useMemo(() => { if (!modules) { return ( @@ -169,13 +187,6 @@ const Modules = () => { ); } - const colorStatus = (status: number) => { - if (status === 1) { - return
Pending Activation
; - } - return
Activated
; - }; - return ( <> @@ -252,12 +263,22 @@ const Modules = () => { ); - }; + }, [ + modules, + fetchModules, + error, + modules_theme, + editModuleItem, + LL, + numChanges, + onCancel, + saveModules + ]); return ( {blocker ? : null} - {renderContent()} + {content} {selectedModuleItem && ( { if (open) { setEditItem(selectedItem); } }, [open, selectedItem]); - const close = () => { - onClose(); - }; - - const save = () => { - onSave(editItem); - }; - return ( {LL.EDIT() + ' ' + editItem.key} @@ -85,7 +78,7 @@ const ModulesDialog = ({
!si.deleted) - .sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags) - }} + data={{ nodes: filteredAndSortedSchedule }} theme={schedule_theme} layout={{ custom: true }} > diff --git a/interface/src/app/main/SchedulerDialog.tsx b/interface/src/app/main/SchedulerDialog.tsx index 812a99a81..ede07b7a9 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'; @@ -53,7 +53,6 @@ const SchedulerDialog = ({ const { LL } = useI18nContext(); const [editItem, setEditItem] = useState(selectedItem); const [fieldErrors, setFieldErrors] = useState(); - const [scheduleType, setScheduleType] = useState(); const updateFormValue = updateValue(setEditItem); @@ -74,84 +73,135 @@ const SchedulerDialog = ({ } }, [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) & 127; + }, []); + + const getFlagDOWstring = useCallback((f: number) => { + const flagValues = [ + ScheduleFlag.SCHEDULE_SUN, + ScheduleFlag.SCHEDULE_MON, + ScheduleFlag.SCHEDULE_TUE, + ScheduleFlag.SCHEDULE_WED, + ScheduleFlag.SCHEDULE_THU, + ScheduleFlag.SCHEDULE_FRI, + ScheduleFlag.SCHEDULE_SAT + ]; + return flagValues + .filter((flag) => (f & flag) === flag) + .map((flag) => String(flag)); + }, []); + + // Day of week display component + const DayOfWeekButton = useMemo( + () => (flag: number) => { + const dayIndex = Math.log2(flag); + return ( + + {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({ ...editItem, time: '', flags: newFlags }); + } + }, + [editItem] + ); + + const handleDOWChange = useCallback( + (_event: React.SyntheticEvent, flags: string[]) => { + const newFlags = getFlagDOWnumber(flags); + setEditItem({ ...editItem, flags: newFlags }); + }, + [editItem, getFlagDOWnumber] + ); + + // Memoize derived values + const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY; + const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER; + const isImmediateSchedule = scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE; + const needsTimeField = isDaySchedule || isTimerSchedule; + + const dowFlags = useMemo( + () => getFlagDOWstring(editItem.flags), + [editItem.flags, getFlagDOWstring] + ); + + const timeFieldValue = useMemo(() => { + if (needsTimeField) { + return editItem.time === '' ? '00:00' : editItem.time; } - }; + return editItem.time === '00:00' ? '' : editItem.time; + }, [editItem.time, needsTimeField]); + + const timeFieldLabel = useMemo(() => { + if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1); + if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION(); + if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE(); + if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE(); + return LL.TIME(1); + }, [scheduleType, LL]); + + // Day of week configuration + const dayFlags = [ + { value: '2', flag: ScheduleFlag.SCHEDULE_MON }, + { value: '4', flag: ScheduleFlag.SCHEDULE_TUE }, + { value: '8', flag: ScheduleFlag.SCHEDULE_WED }, + { value: '16', flag: ScheduleFlag.SCHEDULE_THU }, + { value: '32', flag: ScheduleFlag.SCHEDULE_FRI }, + { value: '64', flag: ScheduleFlag.SCHEDULE_SAT }, + { value: '1', flag: ScheduleFlag.SCHEDULE_SUN } + ]; return ( @@ -166,30 +216,12 @@ 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)} @@ -197,9 +229,7 @@ const SchedulerDialog = ({ {LL.TIMER(0)} @@ -227,49 +257,29 @@ const SchedulerDialog = ({ {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)} - + {dayFlags.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 +315,10 @@ const SchedulerDialog = ({ ) : ( @@ -386,7 +385,7 @@ const SchedulerDialog = ({ > {creating ? LL.ADD(0) : LL.UPDATE()} - {scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE && editItem.cmd !== '' && ( + {isImmediateSchedule && editItem.cmd !== '' && (
- {(tableList: TemperatureSensor[]) => ( - <> -
- - - - - - - - -
- - {tableList.map((ts: TemperatureSensor) => ( - updateTemperatureSensor(ts)}> - {ts.n} - {formatValue(ts.t, ts.u)} - - ))} - - - )} -
- ); - - const getSortIcon = (state: State, sortKey: unknown) => { + const getSortIcon = useCallback((state: State, sortKey: unknown) => { if (state.sortKey === sortKey && state.reverse) { return ; } @@ -203,7 +147,7 @@ const Sensors = () => { return ; } return ; - }; + }, []); const analog_sort = useSort( { nodes: sensorData.as }, @@ -245,98 +189,113 @@ const Sensors = () => { useLayoutTitle(LL.SENSORS()); - const formatDurationMin = (duration_min: number) => { - const days = Math.trunc((duration_min * 60000) / 86400000); - const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; - const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; + const formatDurationMin = useCallback( + (duration_min: number) => { + const days = Math.trunc((duration_min * 60000) / 86400000); + const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; + const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; - let formatted = ''; - if (days) { - formatted += LL.NUM_DAYS({ num: days }) + ' '; - } - if (hours) { - formatted += LL.NUM_HOURS({ num: hours }) + ' '; - } - if (minutes) { - formatted += LL.NUM_MINUTES({ num: minutes }); - } - return formatted; - }; + let formatted = ''; + if (days) { + formatted += LL.NUM_DAYS({ num: days }) + ' '; + } + if (hours) { + formatted += LL.NUM_HOURS({ num: hours }) + ' '; + } + if (minutes) { + formatted += LL.NUM_MINUTES({ num: minutes }); + } + return formatted; + }, + [LL] + ); - function formatValue(value: unknown, uom: DeviceValueUOM) { - if (value === undefined) { - return ''; - } - if (typeof value !== 'number') { - return value as string; - } - switch (uom) { - case DeviceValueUOM.HOURS: - return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); - case DeviceValueUOM.MINUTES: - return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); - case DeviceValueUOM.SECONDS: - return LL.NUM_SECONDS({ num: value }); - case DeviceValueUOM.NONE: - return new Intl.NumberFormat().format(value); - case DeviceValueUOM.DEGREES: - case DeviceValueUOM.DEGREES_R: - case DeviceValueUOM.FAHRENHEIT: - return ( - new Intl.NumberFormat(undefined, { - minimumFractionDigits: 1 - }).format(value) + - ' ' + - DeviceValueUOM_s[uom] - ); - default: - return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; - } - } + const formatValue = useCallback( + (value: unknown, uom: DeviceValueUOM) => { + if (value === undefined) { + return ''; + } + if (typeof value !== 'number') { + return value as string; + } + switch (uom) { + case DeviceValueUOM.HOURS: + return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 }); + case DeviceValueUOM.MINUTES: + return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 }); + case DeviceValueUOM.SECONDS: + return LL.NUM_SECONDS({ num: value }); + case DeviceValueUOM.NONE: + return new Intl.NumberFormat().format(value); + case DeviceValueUOM.DEGREES: + case DeviceValueUOM.DEGREES_R: + case DeviceValueUOM.FAHRENHEIT: + return ( + new Intl.NumberFormat(undefined, { + minimumFractionDigits: 1 + }).format(value) + + ' ' + + DeviceValueUOM_s[uom] + ); + default: + return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; + } + }, + [formatDurationMin, LL] + ); - const updateTemperatureSensor = (ts: TemperatureSensor) => { - if (me.admin) { - ts.o_n = ts.n; - setSelectedTemperatureSensor(ts); - setTemperatureDialogOpen(true); - } - }; + const updateTemperatureSensor = useCallback( + (ts: TemperatureSensor) => { + if (me.admin) { + ts.o_n = ts.n; + setSelectedTemperatureSensor(ts); + setTemperatureDialogOpen(true); + } + }, + [me.admin] + ); - const onTemperatureDialogClose = () => { + const onTemperatureDialogClose = useCallback(() => { setTemperatureDialogOpen(false); void fetchSensorData(); - }; + }, [fetchSensorData]); - const onTemperatureDialogSave = async (ts: TemperatureSensor) => { - await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.SENSOR(1))); - }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setTemperatureDialogOpen(false); - setSelectedTemperatureSensor(undefined); - void fetchSensorData(); - }); - }; + const onTemperatureDialogSave = useCallback( + async (ts: TemperatureSensor) => { + await sendTemperatureSensor({ id: ts.id, name: ts.n, offset: ts.o }) + .then(() => { + toast.success(LL.UPDATED_OF(LL.SENSOR(1))); + }) + .catch(() => { + toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setTemperatureDialogOpen(false); + setSelectedTemperatureSensor(undefined); + void fetchSensorData(); + }); + }, + [sendTemperatureSensor, LL, fetchSensorData] + ); - const updateAnalogSensor = (as: AnalogSensor) => { - if (me.admin) { - setCreating(false); - as.o_n = as.n; - setSelectedAnalogSensor(as); - setAnalogDialogOpen(true); - } - }; + const updateAnalogSensor = useCallback( + (as: AnalogSensor) => { + if (me.admin) { + setCreating(false); + as.o_n = as.n; + setSelectedAnalogSensor(as); + setAnalogDialogOpen(true); + } + }, + [me.admin] + ); - const onAnalogDialogClose = () => { + const onAnalogDialogClose = useCallback(() => { setAnalogDialogOpen(false); void fetchSensorData(); - }; + }, [fetchSensorData]); - const addAnalogSensor = () => { + const addAnalogSensor = useCallback(() => { setCreating(true); setSelectedAnalogSensor({ id: Math.floor(Math.random() * (Math.floor(200) - 100) + 100), @@ -351,112 +310,193 @@ const Sensors = () => { o_n: '' }); setAnalogDialogOpen(true); - }; + }, []); - const onAnalogDialogSave = async (as: AnalogSensor) => { - await sendAnalogSensor({ - id: as.id, - gpio: as.g, - name: as.n, - offset: as.o, - factor: as.f, - uom: as.u, - type: as.t, - deleted: as.d - }) - .then(() => { - toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); + const onAnalogDialogSave = useCallback( + async (as: AnalogSensor) => { + await sendAnalogSensor({ + id: as.id, + gpio: as.g, + name: as.n, + offset: as.o, + factor: as.f, + uom: as.u, + type: as.t, + deleted: as.d }) - .catch(() => { - toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); - }) - .finally(() => { - setAnalogDialogOpen(false); - setSelectedAnalogSensor(undefined); - void fetchSensorData(); - }); - }; + .then(() => { + toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2))); + }) + .catch(() => { + toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1)); + }) + .finally(() => { + setAnalogDialogOpen(false); + setSelectedAnalogSensor(undefined); + void fetchSensorData(); + }); + }, + [sendAnalogSensor, LL, fetchSensorData] + ); - const RenderAnalogSensors = () => ( - - {(tableList: AnalogSensor[]) => ( - <> -
- - -
+ {(tableList: AnalogSensor[]) => ( + <> +
+ + + + + + + + + + + + + + +
+ + {tableList.map((a: AnalogSensor) => ( + updateAnalogSensor(a)}> + {a.g} + {a.n} + {AnalogTypeNames[a.t]} + {(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) || + a.t === AnalogType.DIGITAL_IN || + a.t === AnalogType.PULSE ? ( + {a.v ? LL.ON() : LL.OFF()} + ) : ( + {a.t ? formatValue(a.v, a.u) : ''} + )} + + ))} + + + )} +
+ ), + [ + analog_sort, + analog_theme, + getSortIcon, + sensorData.as, + LL, + updateAnalogSensor, + formatValue + ] + ); + + const RenderTemperatureSensors = useMemo( + () => ( + + {(tableList: TemperatureSensor[]) => ( + <> +
+ + + + + + + + +
+ + {tableList.map((ts: TemperatureSensor) => ( + updateTemperatureSensor(ts)} > - GPIO - - - - - - - - - - - - - - - {tableList.map((a: AnalogSensor) => ( - updateAnalogSensor(a)}> - {a.g} - {a.n} - {AnalogTypeNames[a.t]} - {(a.t === AnalogType.DIGITAL_OUT && a.g !== 25 && a.g !== 26) || - a.t === AnalogType.DIGITAL_IN || - a.t === AnalogType.PULSE ? ( - {a.v ? LL.ON() : LL.OFF()} - ) : ( - {a.t ? formatValue(a.v, a.u) : ''} - )} - - ))} - - - )} -
+ {ts.n} + {formatValue(ts.t, ts.u)} + + ))} + + + )} + + ), + [ + temperature_sort, + temperature_theme, + getSortIcon, + sensorData.ts, + LL, + updateTemperatureSensor, + formatValue + ] ); return ( - + {LL.TEMP_SENSORS()} - + {RenderTemperatureSensors} {selectedTemperatureSensor && ( { )} /> )} - + {LL.ANALOG_SENSORS()} - + {RenderAnalogSensors} {selectedAnalogSensor && ( (selectedItem); const updateFormValue = updateValue(setEditItem); + // Helper functions to check sensor type conditions + const isCounterOrRate = + editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE; + const isFreqType = + editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2; + const isPWM = + editItem.t === AnalogType.PWM_0 || + editItem.t === AnalogType.PWM_1 || + editItem.t === AnalogType.PWM_2; + const isDigitalOutGPIO = + editItem.t === AnalogType.DIGITAL_OUT && + (editItem.g === 25 || editItem.g === 26); + const isDigitalOutNonGPIO = + editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26; + + // Memoize menu items to avoid recreation on each render + const analogTypeMenuItems = useMemo( + () => + AnalogTypeNames.map((val, i) => ( + + {val} + + )), + [] + ); + + const uomMenuItems = useMemo( + () => + DeviceValueUOM_s.map((val, i) => ( + + {val} + + )), + [] + ); + useEffect(() => { if (open) { setFieldErrors(undefined); @@ -57,16 +93,16 @@ const SensorsAnalogDialog = ({ } }, [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); @@ -74,12 +110,12 @@ const SensorsAnalogDialog = ({ } catch (error) { setFieldErrors(error as ValidateFieldsError); } - }; + }, [validator, editItem, onSave]); - const remove = () => { + const remove = useCallback(() => { editItem.d = true; onSave(editItem); - }; + }, [editItem, onSave]); return ( @@ -128,16 +164,10 @@ const SensorsAnalogDialog = ({ select onChange={updateFormValue} > - {AnalogTypeNames.map((val, i) => ( - - {val} - - ))} + {analogTypeMenuItems} - {((editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE) || - (editItem.t >= AnalogType.FREQ_0 && - editItem.t <= AnalogType.FREQ_2)) && ( + {(isCounterOrRate || isFreqType) && ( - {DeviceValueUOM_s.map((val, i) => ( - - {val} - - ))} + {uomMenuItems} )} @@ -226,7 +252,7 @@ const SensorsAnalogDialog = ({ /> )} - {editItem.t >= AnalogType.COUNTER && editItem.t <= AnalogType.RATE && ( + {isCounterOrRate && ( )} - {editItem.t === AnalogType.DIGITAL_OUT && - (editItem.g === 25 || editItem.g === 26) && ( + {isDigitalOutGPIO && ( + + + + )} + {isDigitalOutNonGPIO && ( + <> + > + {LL.OFF()} + {LL.ON()} + - )} - {editItem.t === AnalogType.DIGITAL_OUT && - editItem.g !== 25 && - editItem.g !== 26 && ( - <> - - - {LL.OFF()} - {LL.ON()} - - - - - {LL.ACTIVEHIGH()} - {LL.ACTIVELOW()} - - - - - {LL.UNCHANGED()} - - {LL.ALWAYS()} {LL.OFF()} - - - {LL.ALWAYS()} {LL.ON()} - - - - - )} - {(editItem.t === AnalogType.PWM_0 || - editItem.t === AnalogType.PWM_1 || - editItem.t === AnalogType.PWM_2) && ( + + + {LL.ACTIVEHIGH()} + {LL.ACTIVELOW()} + + + + + {LL.UNCHANGED()} + + {LL.ALWAYS()} {LL.OFF()} + + + {LL.ALWAYS()} {LL.ON()} + + + + + )} + {isPWM && ( <> { - 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); @@ -69,7 +69,7 @@ const SensorsTemperatureDialog = ({ } catch (error) { setFieldErrors(error as ValidateFieldsError); } - }; + }, [validator, editItem, onSave]); return ( diff --git a/interface/src/app/main/deviceValue.ts b/interface/src/app/main/deviceValue.ts index 57b0dfaa3..9a156d829 100644 --- a/interface/src/app/main/deviceValue.ts +++ b/interface/src/app/main/deviceValue.ts @@ -2,27 +2,30 @@ import type { TranslationFunctions } from 'i18n/i18n-types'; import { DeviceValueUOM, DeviceValueUOM_s } from './types'; +// Cache NumberFormat instances for better performance +const numberFormatter = new Intl.NumberFormat(); +const numberFormatterWithDecimal = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 1 +}); + const formatDurationMin = (LL: TranslationFunctions, duration_min: number) => { - const days = Math.trunc((duration_min * 60000) / 86400000); - const hours = Math.trunc((duration_min * 60000) / 3600000) % 24; - const minutes = Math.trunc((duration_min * 60000) / 60000) % 60; + const totalMs = duration_min * 60000; + const days = Math.trunc(totalMs / 86400000); + const hours = Math.trunc(totalMs / 3600000) % 24; + const minutes = Math.trunc(duration_min) % 60; - let formatted = ''; + const parts: string[] = []; if (days) { - formatted += LL.NUM_DAYS({ num: days }); + parts.push(LL.NUM_DAYS({ num: days })); } - if (hours) { - if (formatted) formatted += ' '; - formatted += LL.NUM_HOURS({ num: hours }); + parts.push(LL.NUM_HOURS({ num: hours })); } - if (minutes) { - if (formatted) formatted += ' '; - formatted += LL.NUM_MINUTES({ num: minutes }); + parts.push(LL.NUM_MINUTES({ num: minutes })); } - return formatted; + return parts.join(' '); }; export function formatValue( @@ -30,18 +33,21 @@ export function formatValue( value?: unknown, uom?: DeviceValueUOM ) { + // Handle non-numeric values or missing data if (typeof value !== 'number' || uom === undefined || value === undefined) { if (value === undefined || typeof value === 'boolean') { return ''; } + // Type assertion is safe here since we know it's not a number, boolean, or undefined return ( (value as string) + - (value === '' || uom === undefined || uom === 0 + (value === '' || uom === undefined || uom === DeviceValueUOM.NONE ? '' : ' ' + DeviceValueUOM_s[uom]) ); } + // Handle numeric values switch (uom) { case DeviceValueUOM.HOURS: return value ? formatDurationMin(LL, value * 60) : LL.NUM_HOURS({ num: 0 }); @@ -50,18 +56,12 @@ export function formatValue( case DeviceValueUOM.SECONDS: return LL.NUM_SECONDS({ num: value }); case DeviceValueUOM.NONE: - return new Intl.NumberFormat().format(value); + return numberFormatter.format(value); case DeviceValueUOM.DEGREES: case DeviceValueUOM.DEGREES_R: case DeviceValueUOM.FAHRENHEIT: - return ( - new Intl.NumberFormat(undefined, { - minimumFractionDigits: 1 - }).format(value) + - ' ' + - DeviceValueUOM_s[uom] - ); + return numberFormatterWithDecimal.format(value) + ' ' + DeviceValueUOM_s[uom]; default: - return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom]; + return numberFormatter.format(value) + ' ' + DeviceValueUOM_s[uom]; } } diff --git a/interface/src/app/main/types.ts b/interface/src/app/main/types.ts index 5c86daf4f..7b7663354 100644 --- a/interface/src/app/main/types.ts +++ b/interface/src/app/main/types.ts @@ -122,6 +122,7 @@ export interface DashboardItem { n?: string; // name, optional dv?: DeviceValue; // device value, optional nodes?: DashboardItem[]; // children nodes, optional + parentNode: DashboardItem; // to stop lint errors } export interface DashboardData { diff --git a/interface/src/app/main/validators.ts b/interface/src/app/main/validators.ts index 04749ae9d..e66e50090 100644 --- a/interface/src/app/main/validators.ts +++ b/interface/src/app/main/validators.ts @@ -11,273 +11,210 @@ import type { TemperatureSensor } from './types'; -export const GPIO_VALIDATOR = { +// Helper to create GPIO validator from invalid ranges +const createGPIOValidator = ( + invalidRanges: Array, + maxValue: number +) => ({ validator( _rule: InternalRuleItem, value: number, callback: (error?: string) => void ) { - if ( - value && - (value === 1 || - (value >= 6 && value <= 11) || - value === 20 || - value === 24 || - (value >= 28 && value <= 31) || - value > 40 || - value < 0) - ) { - callback('Must be an valid GPIO port'); - } else { + if (!value) { callback(); + return; } - } -}; -export const GPIO_VALIDATORR = { - validator( - _rule: InternalRuleItem, - value: number, - callback: (error?: string) => void + if (value < 0 || value > maxValue) { + callback('Must be an valid GPIO port'); + return; + } + + for (const range of invalidRanges) { + if (typeof range === 'number') { + if (value === range) { + callback('Must be an valid GPIO port'); + return; + } + } else { + const [start, end] = range; + if (value >= start && value <= end) { + callback('Must be an valid GPIO port'); + return; + } + } + } + + callback(); + } +}); + +export const GPIO_VALIDATOR = createGPIOValidator( + [[6, 11], 1, 20, 24, [28, 31]], + 40 +); + +export const GPIO_VALIDATORC3 = createGPIOValidator([[11, 19]], 21); + +export const GPIO_VALIDATORS2 = createGPIOValidator( + [ + [19, 20], + [22, 32] + ], + 40 +); + +export const GPIO_VALIDATORS3 = createGPIOValidator( + [ + [19, 20], + [22, 37], + [39, 42] + ], + 48 +); + +const GPIO_FIELD_NAMES = [ + 'led_gpio', + 'dallas_gpio', + 'pbutton_gpio', + 'tx_gpio', + 'rx_gpio' +] as const; + +type ValidationRules = Array<{ + required?: boolean; + message?: string; + [key: string]: unknown; +}>; + +const createGPIOValidations = ( + validator: typeof GPIO_VALIDATOR +): Record => + GPIO_FIELD_NAMES.reduce( + (acc, field) => { + const fieldName = field.replace('_gpio', ''); + acc[field] = [ + { required: true, message: `${fieldName.toUpperCase()} GPIO is required` }, + validator + ]; + return acc; + }, + {} as Record + ); + +const PLATFORM_VALIDATORS = { + ESP32: GPIO_VALIDATOR, + ESP32C3: GPIO_VALIDATORC3, + ESP32S2: GPIO_VALIDATORS2, + ESP32S3: GPIO_VALIDATORS3 +} as const; + +export const createSettingsValidator = (settings: Settings) => { + const schema: Record = {}; + + // Add GPIO validations for CUSTOM board profiles + if ( + settings.board_profile === 'CUSTOM' && + settings.platform in PLATFORM_VALIDATORS ) { - if ( - value && - (value === 1 || - (value >= 6 && value <= 11) || - (value >= 16 && value <= 17) || - value === 20 || - value === 24 || - (value >= 28 && value <= 31) || - value > 40 || - value < 0) - ) { - callback('Must be an valid GPIO port'); - } else { - callback(); - } + Object.assign( + schema, + createGPIOValidations( + PLATFORM_VALIDATORS[settings.platform as keyof typeof PLATFORM_VALIDATORS] + ) + ); } + + // Syslog validations + if (settings.syslog_enabled) { + schema.syslog_host = [ + { required: true, message: 'Host is required' }, + IP_OR_HOSTNAME_VALIDATOR + ]; + schema.syslog_port = [ + { required: true, message: 'Port is required' }, + { type: 'number', min: 0, max: 65535, message: 'Invalid Port' } + ]; + schema.syslog_mark_interval = [ + { required: true, message: 'Mark interval is required' }, + { type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' } + ]; + } + + // Modbus validations + if (settings.modbus_enabled) { + schema.modbus_max_clients = [ + { required: true, message: 'Max clients is required' }, + { type: 'number', min: 0, max: 50, message: 'Invalid number' } + ]; + schema.modbus_port = [ + { required: true, message: 'Port is required' }, + { type: 'number', min: 0, max: 65535, message: 'Invalid Port' } + ]; + schema.modbus_timeout = [ + { required: true, message: 'Timeout is required' }, + { + type: 'number', + min: 100, + max: 20000, + message: 'Must be between 100 and 20000' + } + ]; + } + + // Shower timer validations + if (settings.shower_timer) { + schema.shower_min_duration = [ + { + type: 'number', + min: 10, + max: 360, + message: 'Time must be between 10 and 360 seconds' + } + ]; + } + + // Shower alert validations + if (settings.shower_alert) { + schema.shower_alert_trigger = [ + { + type: 'number', + min: 1, + max: 20, + message: 'Time must be between 1 and 20 minutes' + } + ]; + schema.shower_alert_coldshot = [ + { + type: 'number', + min: 1, + max: 10, + message: 'Time must be between 1 and 10 seconds' + } + ]; + } + + // Remote timeout validations + if (settings.remote_timeout_en) { + schema.remote_timeout = [ + { + type: 'number', + min: 1, + max: 240, + message: 'Timeout must be between 1 and 240 hours' + } + ]; + } + + return new Schema(schema); }; -export const GPIO_VALIDATORC3 = { - validator( - _rule: InternalRuleItem, - value: number, - callback: (error?: string) => void - ) { - if (value && ((value >= 11 && value <= 19) || value > 21 || value < 0)) { - callback('Must be an valid GPIO port'); - } else { - callback(); - } - } -}; - -export const GPIO_VALIDATORS2 = { - validator( - _rule: InternalRuleItem, - value: number, - callback: (error?: string) => void - ) { - if ( - value && - ((value >= 19 && value <= 20) || - (value >= 22 && value <= 32) || - value > 40 || - value < 0) - ) { - callback('Must be an valid GPIO port'); - } else { - callback(); - } - } -}; - -export const GPIO_VALIDATORS3 = { - validator( - _rule: InternalRuleItem, - value: number, - callback: (error?: string) => void - ) { - if ( - value && - ((value >= 19 && value <= 20) || - (value >= 22 && value <= 37) || - (value >= 39 && value <= 42) || - value > 48 || - value < 0) - ) { - callback('Must be an valid GPIO port'); - } else { - callback(); - } - } -}; - -export const createSettingsValidator = (settings: Settings) => - new Schema({ - ...(settings.board_profile === 'CUSTOM' && - settings.platform === 'ESP32' && { - led_gpio: [ - { required: true, message: 'LED GPIO is required' }, - GPIO_VALIDATOR - ], - dallas_gpio: [ - { required: true, message: 'GPIO is required' }, - GPIO_VALIDATOR - ], - pbutton_gpio: [ - { required: true, message: 'Button GPIO is required' }, - GPIO_VALIDATOR - ], - tx_gpio: [ - { required: true, message: 'Tx GPIO is required' }, - GPIO_VALIDATOR - ], - rx_gpio: [{ required: true, message: 'Rx GPIO is required' }, GPIO_VALIDATOR] - }), - ...(settings.board_profile === 'CUSTOM' && - settings.platform === 'ESP32C3' && { - led_gpio: [ - { required: true, message: 'LED GPIO is required' }, - GPIO_VALIDATORC3 - ], - dallas_gpio: [ - { required: true, message: 'GPIO is required' }, - GPIO_VALIDATORC3 - ], - pbutton_gpio: [ - { required: true, message: 'Button GPIO is required' }, - GPIO_VALIDATORC3 - ], - tx_gpio: [ - { required: true, message: 'Tx GPIO is required' }, - GPIO_VALIDATORC3 - ], - rx_gpio: [ - { required: true, message: 'Rx GPIO is required' }, - GPIO_VALIDATORC3 - ] - }), - ...(settings.board_profile === 'CUSTOM' && - settings.platform === 'ESP32S2' && { - led_gpio: [ - { required: true, message: 'LED GPIO is required' }, - GPIO_VALIDATORS2 - ], - dallas_gpio: [ - { required: true, message: 'GPIO is required' }, - GPIO_VALIDATORS2 - ], - pbutton_gpio: [ - { required: true, message: 'Button GPIO is required' }, - GPIO_VALIDATORS2 - ], - tx_gpio: [ - { required: true, message: 'Tx GPIO is required' }, - GPIO_VALIDATORS2 - ], - rx_gpio: [ - { required: true, message: 'Rx GPIO is required' }, - GPIO_VALIDATORS2 - ] - }), - ...(settings.board_profile === 'CUSTOM' && - settings.platform === 'ESP32S3' && { - led_gpio: [ - { required: true, message: 'LED GPIO is required' }, - GPIO_VALIDATORS3 - ], - dallas_gpio: [ - { required: true, message: 'GPIO is required' }, - GPIO_VALIDATORS3 - ], - pbutton_gpio: [ - { required: true, message: 'Button GPIO is required' }, - GPIO_VALIDATORS3 - ], - tx_gpio: [ - { required: true, message: 'Tx GPIO is required' }, - GPIO_VALIDATORS3 - ], - rx_gpio: [ - { required: true, message: 'Rx GPIO is required' }, - GPIO_VALIDATORS3 - ] - }), - ...(settings.syslog_enabled && { - syslog_host: [ - { required: true, message: 'Host is required' }, - IP_OR_HOSTNAME_VALIDATOR - ], - syslog_port: [ - { required: true, message: 'Port is required' }, - { type: 'number', min: 0, max: 65535, message: 'Invalid Port' } - ], - syslog_mark_interval: [ - { required: true, message: 'Mark interval is required' }, - { type: 'number', min: 0, max: 10, message: 'Must be between 0 and 10' } - ] - }), - ...(settings.modbus_enabled && { - modbus_max_clients: [ - { required: true, message: 'Max clients is required' }, - { type: 'number', min: 0, max: 50, message: 'Invalid number' } - ], - modbus_port: [ - { required: true, message: 'Port is required' }, - { type: 'number', min: 0, max: 65535, message: 'Invalid Port' } - ], - modbus_timeout: [ - { required: true, message: 'Timeout is required' }, - { - type: 'number', - min: 100, - max: 20000, - message: 'Must be between 100 and 20000' - } - ] - }), - ...(settings.shower_timer && { - shower_min_duration: [ - { - type: 'number', - min: 10, - max: 360, - message: 'Time must be between 10 and 360 seconds' - } - ] - }), - ...(settings.shower_alert && { - shower_alert_trigger: [ - { - type: 'number', - min: 1, - max: 20, - message: 'Time must be between 1 and 20 minutes' - } - ], - shower_alert_coldshot: [ - { - type: 'number', - min: 1, - max: 10, - message: 'Time must be between 1 and 10 seconds' - } - ] - }), - ...(settings.remote_timeout_en && { - remote_timeout: [ - { - type: 'number', - min: 1, - max: 240, - message: 'Timeout must be between 1 and 240 hours' - } - ] - }) - }); - -export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => ({ +// Generic unique name validator factory +const createUniqueNameValidator = ( + items: T[], + originalName?: string +) => ({ validator( _rule: InternalRuleItem, name: string, @@ -285,8 +222,9 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) = ) { if ( name !== '' && - (o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) && - schedule.find((si) => si.name.toLowerCase() === name.toLowerCase()) + (originalName === undefined || + originalName.toLowerCase() !== name.toLowerCase()) && + items.find((item) => item.name.toLowerCase() === name.toLowerCase()) ) { callback('Name already in use'); } else { @@ -295,19 +233,51 @@ export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) = } }); +// Generic field name validator (for cases where the name field has different property names) +const createUniqueFieldNameValidator = ( + items: T[], + getName: (item: T) => string, + originalName?: string +) => ({ + validator( + _rule: InternalRuleItem, + name: string, + callback: (error?: string) => void + ) { + if ( + name !== '' && + (originalName === undefined || + originalName.toLowerCase() !== name.toLowerCase()) && + items.find((item) => getName(item).toLowerCase() === name.toLowerCase()) + ) { + callback('Name already in use'); + } else { + callback(); + } + } +}); + +const NAME_PATTERN = { + type: 'string' as const, + pattern: /^[a-zA-Z0-9_]{0,19}$/, + message: "Must be <20 characters: alphanumeric or '_'" +}; + +const NAME_PATTERN_REQUIRED = { + type: 'string' as const, + pattern: /^[a-zA-Z0-9_]{1,19}$/, + message: "Must be <20 characters: alphanumeric or '_'" +}; + +export const uniqueNameValidator = (schedule: ScheduleItem[], o_name?: string) => + createUniqueNameValidator(schedule, o_name); + export const schedulerItemValidation = ( schedule: ScheduleItem[], scheduleItem: ScheduleItem ) => new Schema({ - name: [ - { - type: 'string', - pattern: /^[a-zA-Z0-9_]{0,19}$/, - message: "Must be <20 characters: alphanumeric or '_'" - }, - ...[uniqueNameValidator(schedule, scheduleItem.o_name)] - ], + name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)], cmd: [ { required: true, message: 'Command is required' }, { @@ -319,65 +289,32 @@ export const schedulerItemValidation = ( ] }); -export const uniqueCustomNameValidator = ( - entity: EntityItem[], - o_name?: string -) => ({ +export const uniqueCustomNameValidator = (entity: EntityItem[], o_name?: string) => + createUniqueNameValidator(entity, o_name); + +const hexValidator = { validator( _rule: InternalRuleItem, - name: string, + value: string, callback: (error?: string) => void ) { - if ( - (o_name === undefined || o_name.toLowerCase() !== name.toLowerCase()) && - entity.find((ei) => ei.name.toLowerCase() === name.toLowerCase()) - ) { - callback('Name already in use'); + if (!value || isNaN(parseInt(value, 16))) { + callback('Is required and must be in hex format'); } else { callback(); } } -}); +}; export const entityItemValidation = (entity: EntityItem[], entityItem: EntityItem) => new Schema({ name: [ { required: true, message: 'Name is required' }, - { - type: 'string', - pattern: /^[a-zA-Z0-9_]{1,19}$/, - message: "Must be <20 characters: alphanumeric or '_'" - }, - ...[uniqueCustomNameValidator(entity, entityItem.o_name)] - ], - device_id: [ - { - validator( - _rule: InternalRuleItem, - value: string, - callback: (error?: string) => void - ) { - if (isNaN(parseInt(value, 16))) { - callback('Is required and must be in hex format'); - } - callback(); - } - } - ], - type_id: [ - { - validator( - _rule: InternalRuleItem, - value: string, - callback: (error?: string) => void - ) { - if (isNaN(parseInt(value, 16))) { - callback('Is required and must be in hex format'); - } - callback(); - } - } + NAME_PATTERN_REQUIRED, + uniqueCustomNameValidator(entity, entityItem.o_name) ], + device_id: [hexValidator], + type_id: [hexValidator], offset: [ { required: true, message: 'Offset is required' }, { type: 'number', min: 0, max: 255, message: 'Must be between 0 and 255' } @@ -388,33 +325,14 @@ export const entityItemValidation = (entity: EntityItem[], entityItem: EntityIte export const uniqueTemperatureNameValidator = ( sensors: TemperatureSensor[], o_name?: string -) => ({ - validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) { - if ( - (o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) && - n !== '' && - sensors.find((ts) => ts.n.toLowerCase() === n.toLowerCase()) - ) { - callback('Name already in use'); - } else { - callback(); - } - } -}); +) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name); export const temperatureSensorItemValidation = ( sensors: TemperatureSensor[], sensor: TemperatureSensor ) => new Schema({ - n: [ - { - type: 'string', - pattern: /^[a-zA-Z0-9_]{0,19}$/, - message: "Must be <20 characters: alphanumeric or '_'" - }, - ...[uniqueTemperatureNameValidator(sensors, sensor.o_n)] - ] + n: [NAME_PATTERN, uniqueTemperatureNameValidator(sensors, sensor.o_n)] }); export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({ @@ -434,47 +352,32 @@ export const isGPIOUniqueValidator = (sensors: AnalogSensor[]) => ({ export const uniqueAnalogNameValidator = ( sensors: AnalogSensor[], o_name?: string -) => ({ - validator(_rule: InternalRuleItem, n: string, callback: (error?: string) => void) { - if ( - (o_name === undefined || o_name.toLowerCase() !== n.toLowerCase()) && - n !== '' && - sensors.find((as) => as.n.toLowerCase() === n.toLowerCase()) - ) { - callback('Name already in use'); - } else { - callback(); - } - } -}); +) => createUniqueFieldNameValidator(sensors, (s) => s.n, o_name); export const analogSensorItemValidation = ( sensors: AnalogSensor[], sensor: AnalogSensor, creating: boolean, platform: string -) => - new Schema({ - n: [ - { - type: 'string', - pattern: /^[a-zA-Z0-9_]{0,19}$/, - message: "Must be <20 characters: alphanumeric or '_'" - }, - ...[uniqueAnalogNameValidator(sensors, sensor.o_n)] - ], +) => { + const gpioValidator = + platform === 'ESP32S3' + ? GPIO_VALIDATORS3 + : platform === 'ESP32S2' + ? GPIO_VALIDATORS2 + : platform === 'ESP32C3' + ? GPIO_VALIDATORC3 + : GPIO_VALIDATOR; + + return new Schema({ + n: [NAME_PATTERN, uniqueAnalogNameValidator(sensors, sensor.o_n)], g: [ { required: true, message: 'GPIO is required' }, - platform === 'ESP32S3' - ? GPIO_VALIDATORS3 - : platform === 'ESP32S2' - ? GPIO_VALIDATORS2 - : platform === 'ESP32C3' - ? GPIO_VALIDATORC3 - : GPIO_VALIDATOR, + gpioValidator, ...(creating ? [isGPIOUniqueValidator(sensors)] : []) ] }); +}; export const deviceValueItemValidation = (dv: DeviceValue) => new Schema({ @@ -488,13 +391,14 @@ export const deviceValueItemValidation = (dv: DeviceValue) => ) { if ( typeof value === 'number' && - dv.m && - dv.x && + dv.m !== undefined && + dv.x !== undefined && (value < dv.m || value > dv.x) ) { callback('Value out of range'); + } else { + callback(); } - callback(); } } ] diff --git a/interface/src/app/settings/APSettings.tsx b/interface/src/app/settings/APSettings.tsx index aa294bd15..593d0e45d 100644 --- a/interface/src/app/settings/APSettings.tsx +++ b/interface/src/app/settings/APSettings.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'; @@ -27,6 +27,19 @@ export const isAPEnabled = ({ provision_mode }: APSettingsType) => provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED; +// Efficient range function without recursion +const createRange = (start: number, end: number): number[] => { + const result: number[] = []; + for (let i = start; i <= end; i++) { + result.push(i); + } + return result; +}; + +// Pre-computed ranges for better performance +const CHANNEL_RANGE = createRange(1, 14); +const MAX_CLIENTS_RANGE = createRange(1, 9); + const APSettings = () => { const { loadData, @@ -50,33 +63,38 @@ const APSettings = () => { 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] ); + // Memoize AP enabled state + const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]); + + // Memoize validation and submit handler + const validateAndSubmit = useCallback(async () => { + if (!data) return; + + try { + setFieldErrors(undefined); + await validate(createAPSettingsValidator(data), data); + await saveData(); + } catch (error) { + setFieldErrors(error as ValidateFieldsError); + } + }, [data, saveData]); + const content = () => { if (!data) { return ; } - const validateAndSubmit = async () => { - try { - setFieldErrors(undefined); - await validate(createAPSettingsValidator(data), data); - await saveData(); - } catch (error) { - setFieldErrors(error as ValidateFieldsError); - } - }; - - // no lodash - https://asleepace.com/blog/typescript-range-without-a-loop/ - function range(a: number, b: number): number[] { - return a < b ? [a, ...range(a + 1, b)] : [b]; - } - return ( <> { {LL.AP_PROVIDE_TEXT_3()} - {isAPEnabled(data) && ( + {apEnabled && ( <> { onChange={updateFormValue} margin="normal" > - {range(1, 14).map((i) => ( + {CHANNEL_RANGE.map((i) => ( {i} @@ -162,7 +180,7 @@ const APSettings = () => { onChange={updateFormValue} margin="normal" > - {range(1, 9).map((i) => ( + {MAX_CLIENTS_RANGE.map((i) => ( {i} diff --git a/interface/src/app/settings/ApplicationSettings.tsx b/interface/src/app/settings/ApplicationSettings.tsx index 6ebc9867f..26692109d 100644 --- a/interface/src/app/settings/ApplicationSettings.tsx +++ b/interface/src/app/settings/ApplicationSettings.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import CancelIcon from '@mui/icons-material/Cancel'; @@ -72,7 +72,7 @@ const ApplicationSettings = () => { const { LL } = useI18nContext(); const updateFormValue = updateValueDirty( - origData, + origData as unknown as Record, dirtyFlags, setDirtyFlags, updateDataValue as (value: unknown) => void @@ -106,50 +106,61 @@ const ApplicationSettings = () => { }); }); - const doRestart = async () => { + // Memoized input props to prevent recreation on every render + const SecondsInputProps = useMemo( + () => ({ + endAdornment: {LL.SECONDS()} + }), + [LL] + ); + + const MinutesInputProps = useMemo( + () => ({ + endAdornment: {LL.MINUTES()} + }), + [LL] + ); + + const HoursInputProps = useMemo( + () => ({ + endAdornment: {LL.HOURS()} + }), + [LL] + ); + + const doRestart = useCallback(async () => { setRestarting(true); await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( (error: Error) => { toast.error(error.message); } ); - }; + }, [sendAPI]); - const updateBoardProfile = async (board_profile: string) => { - await readBoardProfile(board_profile).catch((error: Error) => { - toast.error(error.message); - }); - }; + const updateBoardProfile = useCallback( + async (board_profile: string) => { + await readBoardProfile(board_profile).catch((error: Error) => { + toast.error(error.message); + }); + }, + [readBoardProfile] + ); useLayoutTitle(LL.APPLICATION()); - const SecondsInputProps = { - endAdornment: {LL.SECONDS()} - }; - const MinutesInputProps = { - endAdornment: {LL.MINUTES()} - }; - const HoursInputProps = { - endAdornment: {LL.HOURS()} - }; - - const content = () => { - if (!data || !hardwareData) { - return ; + const validateAndSubmit = useCallback(async () => { + try { + setFieldErrors(undefined); + await validate(createSettingsValidator(data), data); + } catch (error) { + setFieldErrors(error as ValidateFieldsError); + } finally { + await saveData(); } + }, [data, saveData]); - const validateAndSubmit = async () => { - try { - setFieldErrors(undefined); - await validate(createSettingsValidator(data), data); - } catch (error) { - setFieldErrors(error as ValidateFieldsError); - } finally { - await saveData(); - } - }; - - const changeBoardProfile = (event: React.ChangeEvent) => { + const changeBoardProfile = useCallback( + (event: React.ChangeEvent) => { const boardProfile = event.target.value; updateFormValue(event); if (boardProfile === 'CUSTOM') { @@ -160,12 +171,22 @@ const ApplicationSettings = () => { } else { void updateBoardProfile(boardProfile); } - }; + }, + [data, updateBoardProfile, updateFormValue, updateDataValue] + ); - const restart = async () => { - await validateAndSubmit(); - await doRestart(); - }; + const restart = useCallback(async () => { + await validateAndSubmit(); + await doRestart(); + }, [validateAndSubmit, doRestart]); + + // Memoize board profile select items to prevent recreation + const boardProfileItems = useMemo(() => boardProfileSelectItems(), []); + + const content = () => { + if (!data || !hardwareData) { + return ; + } return ( <> @@ -474,7 +495,7 @@ const ApplicationSettings = () => { margin="normal" select > - {boardProfileSelectItems()} + {boardProfileItems} {LL.CUSTOM()}… diff --git a/interface/src/app/settings/DownloadUpload.tsx b/interface/src/app/settings/DownloadUpload.tsx index f68d0412a..4cd3840ef 100644 --- a/interface/src/app/settings/DownloadUpload.tsx +++ b/interface/src/app/settings/DownloadUpload.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -19,6 +19,13 @@ import { import { useI18nContext } from 'i18n/i18n-react'; import { saveFile } from 'utils'; +interface DownloadButton { + key: string; + type: string; + label: string | number; + isGridButton: boolean; +} + const DownloadUpload = () => { const { LL } = useI18nContext(); @@ -44,95 +51,127 @@ const DownloadUpload = () => { const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus); - const doRestart = async () => { + const doRestart = useCallback(async () => { setRestarting(true); - await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch( - (error: Error) => { - toast.error(error.message); - } - ); - }; + try { + await sendAPI({ device: 'system', cmd: 'restart', id: 0 }); + } catch (error) { + toast.error((error as Error).message); + setRestarting(false); + } + }, [sendAPI]); useLayoutTitle(LL.DOWNLOAD_UPLOAD()); - const content = () => { - if (!data) { - return ; - } + const downloadButtons: DownloadButton[] = useMemo( + () => [ + { + key: 'settings', + type: 'settings', + label: LL.SETTINGS_OF(LL.APPLICATION()), + isGridButton: true + }, + { + key: 'customizations', + type: 'customizations', + label: LL.CUSTOMIZATIONS(), + isGridButton: true + }, + { + key: 'entities', + type: 'entities', + label: LL.CUSTOM_ENTITIES(0), + isGridButton: true + }, + { + key: 'schedule', + type: 'schedule', + label: LL.SCHEDULE(0), + isGridButton: true + }, + { + key: 'allvalues', + type: 'allvalues', + label: LL.ALLVALUES(), + isGridButton: false + } + ], + [LL] + ); + const handleDownload = useCallback( + (type: string) => () => { + void sendExportData(type); + }, + [sendExportData] + ); + + if (restarting) { return ( - <> - - {LL.DOWNLOAD(0)} - + + + + ); + } - - {LL.DOWNLOAD_SETTINGS_TEXT()}. - - - + if (!data) { + return ( + + + + ); + } - - - - + 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) => ( + + + + ))} + + + {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 +307,7 @@ const SystemStatus = () => { icon={BuildIcon} bgcolor="#72caf9" label="EMS-ESP Firmware" - text={'v' + data.emsesp_version} + text={firmwareVersion} to="version" /> @@ -268,16 +317,13 @@ const SystemStatus = () => { - + {me.admin && ( @@ -289,29 +335,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 +362,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 +380,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 +394,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..d652883ab 100644 --- a/interface/src/app/status/SystemLog.tsx +++ b/interface/src/app/status/SystemLog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'react-toastify'; import DownloadIcon from '@mui/icons-material/GetApp'; @@ -31,6 +31,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 +49,6 @@ const LogEntryLine = styled('span')( }) ); -const topOffset = () => - document.getElementById('log-window')?.getBoundingClientRect().bottom || 0; -const leftOffset = () => - document.getElementById('log-window')?.getBoundingClientRect().left || 0; - const levelLabel = (level: LogLevel) => { switch (level) { case LogLevel.ERROR: @@ -71,6 +68,36 @@ const levelLabel = (level: LogLevel) => { } }; +// Memoized log entry component to prevent unnecessary re-renders +const LogEntryItem = memo( + ({ entry, compact }: { entry: LogEntry; compact: boolean }) => { + const paddedLevelLabel = (level: LogLevel) => { + const label = levelLabel(level); + return compact ? ' ' + label[0] : label.padStart(8, '\xa0'); + }; + + const paddedNameLabel = (name: string) => { + const label = '[' + name + ']'; + return compact ? label : label.padEnd(12, '\xa0'); + }; + + const paddedIDLabel = (id: number) => { + const label = id + ':'; + return compact ? label : label.padEnd(7, '\xa0'); + }; + + return ( +
+ {entry.t} + {paddedLevelLabel(entry.l)}  + {paddedIDLabel(entry.i)} + {paddedNameLabel(entry.n)} + {entry.m} +
+ ); + } +); + const SystemLog = () => { const { LL } = useI18nContext(); @@ -107,7 +134,7 @@ const SystemLog = () => { 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 @@ -121,7 +148,13 @@ const SystemLog = () => { const rawData = message.data; const logentry = JSON.parse(rawData) as LogEntry; if (lastId < logentry.i) { - setLogEntries((log) => [...log, logentry]); + setLogEntries((log) => { + const newLog = [...log, logentry]; + // Limit log entries to prevent memory issues + return newLog.length > MAX_LOG_ENTRIES + ? newLog.slice(-MAX_LOG_ENTRIES) + : newLog; + }); setLastId(logentry.i); } }) @@ -129,27 +162,11 @@ const SystemLog = () => { 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,11 +176,11 @@ 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 const ref = useRef(null); @@ -174,9 +191,9 @@ const SystemLog = () => { block: 'end' }); } - }, [logEntries.length]); + }, [logEntries.length, autoscroll]); - const sendReadCommand = () => { + const sendReadCommand = useCallback(() => { if (readValue === '') { setReadOpen(!readOpen); return; @@ -187,7 +204,17 @@ const SystemLog = () => { setReadOpen(false); setReadValue(''); } - }; + }, [readValue, readOpen, send]); + + // Memoize box positioning to avoid recalculating on every render + const boxPosition = useMemo(() => { + const logWindow = document.getElementById('log-window'); + if (!logWindow) { + return { top: 0, left: 0 }; + } + const rect = logWindow.getBoundingClientRect(); + return { top: rect.bottom, left: rect.left }; + }, [data]); // Recalculate only when data changes (settings may affect layout) const content = () => { if (!data) { @@ -332,21 +359,13 @@ const SystemLog = () => { position: 'absolute', right: 18, bottom: 18, - left: () => leftOffset(), - top: () => topOffset(), + left: boxPosition.left, + top: boxPosition.top - 110, 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..e23b274b7 100644 --- a/interface/src/app/status/SystemMonitor.tsx +++ b/interface/src/app/status/SystemMonitor.tsx @@ -1,4 +1,4 @@ -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'; @@ -17,11 +17,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 +30,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,13 +58,41 @@ 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 ( @@ -76,15 +104,7 @@ const SystemMonitor = () => { fontWeight={400} textAlign="center" > - {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,13 +125,9 @@ const SystemMonitor = () => { {LL.PLEASE_WAIT()}… - {data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && ( + {isUploading && ( - + )} 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..6ff6836e0 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,17 +19,54 @@ import { I18nContext } from 'i18n/i18n-react'; import type { Locales } from 'i18n/i18n-types'; import { loadLocaleAsync } from 'i18n/i18n-util.async'; +// Extract style to constant to prevent recreation +const flagStyle: CSSProperties = { width: 16, verticalAlign: 'middle' }; + +// Define language options outside component to prevent recreation +interface LanguageOption { + key: Locales; + flag: string; + label: string; +} + +const LANGUAGE_OPTIONS: LanguageOption[] = [ + { key: 'cz', flag: CZflag, label: 'CZ' }, + { key: 'de', flag: DEflag, label: 'DE' }, + { key: 'en', flag: GBflag, label: 'EN' }, + { key: 'fr', flag: FRflag, label: 'FR' }, + { key: 'it', flag: ITflag, label: 'IT' }, + { key: 'nl', flag: NLflag, label: 'NL' }, + { key: 'no', flag: NOflag, label: 'NO' }, + { key: 'pl', flag: PLflag, label: 'PL' }, + { key: 'sk', flag: SKflag, label: 'SK' }, + { key: 'sv', flag: SVflag, label: 'SV' }, + { key: 'tr', flag: TRflag, label: 'TR' } +]; + const LanguageSelector = () => { const { setLocale, locale } = useContext(I18nContext); - const onLocaleSelected: ChangeEventHandler = async ({ - target - }) => { - const loc = target.value as Locales; - localStorage.setItem('lang', loc); - await loadLocaleAsync(loc); - setLocale(loc); - }; + 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 ( { size="small" select > - - -  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..11a728798 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 +36,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..33f99454d 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'; @@ -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..64e5fcf21 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,7 +71,7 @@ const LayoutMenu = () => { > setMenuOpen(!menuOpen)} + onClick={handleMenuToggle} sx={{ pt: 2.5, pb: menuOpen ? 0 : 2.5, @@ -173,7 +180,7 @@ const LayoutMenu = () => { variant="outlined" fullWidth color="primary" - onClick={() => signOut(true)} + onClick={handleSignOut} > {LL.SIGN_OUT()} @@ -196,4 +203,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/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..d92aec919 100644 --- a/interface/vite.config.ts +++ b/interface/vite.config.ts @@ -95,12 +95,19 @@ export default defineConfig( preact({ // Keep dev tools enabled for development devToolsEnabled: true, - prefreshEnabled: true + prefreshEnabled: false }), viteTsconfigPaths(), bundleSizeReporter(), // Add bundle size reporting mockServer() ], + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime' + } + }, server: { open: true, port: mode == 'production' ? 4173 : 3000, @@ -135,6 +142,13 @@ export default defineConfig( viteTsconfigPaths(), bundleSizeReporter() // Add bundle size reporting ], + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime' + } + }, build: { target: 'es2020', chunkSizeWarningLimit: 512, @@ -226,6 +240,14 @@ export default defineConfig( bundleSizeReporter() // Add bundle size reporting ], + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react/jsx-runtime': 'preact/jsx-runtime' + } + }, + build: { // Target modern browsers for smaller bundles target: 'es2020',