mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-14 14:14:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a96352ec | ||
|
|
02caaef916 | ||
|
|
ba0f4cd008 | ||
|
|
45f7d91b43 | ||
|
|
e0f59a4c31 | ||
|
|
7eba8acbb4 | ||
|
|
d0e6a603bb | ||
|
|
36b4dd55a7 | ||
|
|
341a4d4e1d | ||
|
|
a61745b710 | ||
|
|
b1ebd81ca2 | ||
|
|
1ff53e6ac7 | ||
|
|
9cab3b480e | ||
|
|
bacd3faac2 | ||
|
|
ee3a5d231a | ||
|
|
e8f57b6343 | ||
|
|
a1ab81de66 | ||
|
|
28135c225b | ||
|
|
eaa277fef0 | ||
|
|
e418b7d8e7 | ||
|
|
a4e3be6a69 | ||
|
|
a1bc5bb055 | ||
|
|
f444ca31e0 | ||
|
|
1b70b55989 | ||
|
|
1487f30c43 | ||
|
|
e00eb8e64f | ||
|
|
f41bb3671c | ||
|
|
22c75e6df3 | ||
|
|
5ab10b7aa6 | ||
|
|
ee5fd4d0eb | ||
|
|
46f35bc67c | ||
|
|
ec85a7ec24 | ||
|
|
02f2389587 | ||
|
|
7f140021aa | ||
|
|
6796962c1e | ||
|
|
df6de21cf4 | ||
|
|
df9f75a5c9 | ||
|
|
7bd8710eb6 | ||
|
|
32f2c6d341 | ||
|
|
86919c1684 | ||
|
|
86e29515e7 | ||
|
|
46eb4185d7 | ||
|
|
8da6761a48 | ||
|
|
9233f0dfcc | ||
|
|
292f743b14 | ||
|
|
dd6dfffd57 | ||
|
|
ec705a5307 | ||
|
|
f45f071710 | ||
|
|
f3858546de | ||
|
|
d0ac0b7804 | ||
|
|
d8284ec09f | ||
|
|
6e982acde8 | ||
|
|
8c94ce99b2 | ||
|
|
fc057d18c9 | ||
|
|
18e9b99413 | ||
|
|
a47e0e8266 | ||
|
|
f412ddc716 | ||
|
|
29110e96e5 | ||
|
|
b65866217a | ||
|
|
611e3b1243 | ||
|
|
2ca0a0c634 | ||
|
|
7eb1f061b7 | ||
|
|
50459a23fe | ||
|
|
5bf53c3389 | ||
|
|
4b7aa95be3 | ||
|
|
70943f5758 | ||
|
|
3bc280b817 | ||
|
|
62b15a5319 | ||
|
|
8dd18802d6 | ||
|
|
57a516a83a | ||
|
|
a57fdaa4b3 | ||
|
|
4841e42286 | ||
|
|
8c2d2b06ed | ||
|
|
38c8b1b7f0 | ||
|
|
6fb5933a02 | ||
|
|
c0944433be | ||
|
|
478e6362c9 | ||
|
|
4d6354db78 | ||
|
|
beab0f0c77 | ||
|
|
c17749bd22 | ||
|
|
2bad769c5c | ||
|
|
8ad89ca64b | ||
|
|
9244d8daec | ||
|
|
02d01334b2 |
@@ -77,23 +77,3 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
CHANGELOG_LATEST.md
|
CHANGELOG_LATEST.md
|
||||||
./build/firmware/*.*
|
./build/firmware/*.*
|
||||||
|
|
||||||
- name: Update version in Cloudflare KV store
|
|
||||||
if: github.repository == 'emsesp/EMS-ESP32'
|
|
||||||
env:
|
|
||||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
|
||||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
|
||||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
||||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
JSON_DATA=$(jq -n \
|
|
||||||
--arg version "$VERSION" \
|
|
||||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
'{version: $version, date: $date}')
|
|
||||||
echo "JSON_DATA: $JSON_DATA"
|
|
||||||
curl -sS --fail-with-body \
|
|
||||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/dev" \
|
|
||||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$JSON_DATA"
|
|
||||||
echo
|
|
||||||
|
|||||||
@@ -31,13 +31,6 @@ jobs:
|
|||||||
- name: Enable Corepack
|
- name: Enable Corepack
|
||||||
run: corepack enable pnpm
|
run: corepack enable pnpm
|
||||||
|
|
||||||
|
|
||||||
- name: Get the EMS-ESP version
|
|
||||||
id: build_info
|
|
||||||
run: |
|
|
||||||
version=`grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}'`
|
|
||||||
echo "VERSION=$version" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Install PlatformIO
|
- name: Install PlatformIO
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
@@ -68,23 +61,3 @@ jobs:
|
|||||||
files: |
|
files: |
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
./build/firmware/*.*
|
./build/firmware/*.*
|
||||||
|
|
||||||
- name: Update version in Cloudflare KV store
|
|
||||||
if: github.repository == 'emsesp/EMS-ESP32'
|
|
||||||
env:
|
|
||||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
|
||||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
|
||||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
||||||
VERSION: ${{ steps.build_info.outputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
JSON_DATA=$(jq -n \
|
|
||||||
--arg version "$VERSION" \
|
|
||||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
'{version: $version, date: $date}')
|
|
||||||
echo "JSON_DATA: $JSON_DATA"
|
|
||||||
curl -sS --fail-with-body \
|
|
||||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/stable" \
|
|
||||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$JSON_DATA"
|
|
||||||
echo
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
name: 'Update versions'
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
update-version:
|
|
||||||
name: 'Update versions in Cloudflare KV store'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Get and Send EMS-ESP version to Cloudflare
|
|
||||||
env:
|
|
||||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
|
||||||
CF_NAMESPACE_ID: ${{ secrets.CF_NAMESPACE_ID }}
|
|
||||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
version=$(grep -E '^#define EMSESP_APP_VERSION' ./src/emsesp_version.h | awk -F'"' '{print $2}')
|
|
||||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
|
||||||
KV_ENV="stable"
|
|
||||||
else
|
|
||||||
KV_ENV="dev"
|
|
||||||
fi
|
|
||||||
JSON_DATA=$(jq -n \
|
|
||||||
--arg version "$version" \
|
|
||||||
--arg date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
||||||
'{version: $version, date: $date}')
|
|
||||||
echo "KV_ENV: $KV_ENV"
|
|
||||||
echo "JSON_DATA: $JSON_DATA"
|
|
||||||
curl -sS --fail-with-body \
|
|
||||||
-X PUT "https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/storage/kv/namespaces/${CF_NAMESPACE_ID}/values/${KV_ENV}" \
|
|
||||||
-H "Authorization: Bearer ${CF_API_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$JSON_DATA"
|
|
||||||
echo
|
|
||||||
+1
-5
@@ -2,7 +2,6 @@
|
|||||||
.vscode/c_cpp_properties.json
|
.vscode/c_cpp_properties.json
|
||||||
.vscode/extensions.json
|
.vscode/extensions.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
.vscode/settings.json
|
|
||||||
|
|
||||||
# c++ compiling
|
# c++ compiling
|
||||||
.clang_complete
|
.clang_complete
|
||||||
@@ -64,7 +63,7 @@ words-found-verbose.txt
|
|||||||
# sonarlint
|
# sonarlint
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|
||||||
# other files
|
# pioarduino + hybrid
|
||||||
managed_components
|
managed_components
|
||||||
dependencies.lock
|
dependencies.lock
|
||||||
CMakeLists.txt
|
CMakeLists.txt
|
||||||
@@ -76,6 +75,3 @@ pnpm-lock.yaml
|
|||||||
.cache/
|
.cache/
|
||||||
interface/.tsbuildinfo
|
interface/.tsbuildinfo
|
||||||
test/test_api/package-lock.json
|
test/test_api/package-lock.json
|
||||||
.clangd
|
|
||||||
mklittlefs
|
|
||||||
.cursor
|
|
||||||
|
|||||||
Vendored
+101
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"search.exclude": {
|
||||||
|
"**/.yarn": true,
|
||||||
|
"**/.pnp.*": true
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit"
|
||||||
|
},
|
||||||
|
"eslint.validate": [
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"eslint.codeActionsOnSave.rules": null,
|
||||||
|
"eslint.nodePath": "interface/.yarn/sdks",
|
||||||
|
"eslint.workingDirectories": ["interface"],
|
||||||
|
"prettier.prettierPath": "",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
|
"files.associations": {
|
||||||
|
"*.tsx": "typescriptreact",
|
||||||
|
"*.tcc": "cpp",
|
||||||
|
"optional": "cpp",
|
||||||
|
"istream": "cpp",
|
||||||
|
"ostream": "cpp",
|
||||||
|
"ratio": "cpp",
|
||||||
|
"system_error": "cpp",
|
||||||
|
"array": "cpp",
|
||||||
|
"functional": "cpp",
|
||||||
|
"regex": "cpp",
|
||||||
|
"tuple": "cpp",
|
||||||
|
"type_traits": "cpp",
|
||||||
|
"utility": "cpp",
|
||||||
|
"string": "cpp",
|
||||||
|
"string_view": "cpp",
|
||||||
|
"atomic": "cpp",
|
||||||
|
"bitset": "cpp",
|
||||||
|
"cctype": "cpp",
|
||||||
|
"chrono": "cpp",
|
||||||
|
"clocale": "cpp",
|
||||||
|
"cmath": "cpp",
|
||||||
|
"condition_variable": "cpp",
|
||||||
|
"cstdarg": "cpp",
|
||||||
|
"cstddef": "cpp",
|
||||||
|
"cstdint": "cpp",
|
||||||
|
"cstdio": "cpp",
|
||||||
|
"cstdlib": "cpp",
|
||||||
|
"cstring": "cpp",
|
||||||
|
"ctime": "cpp",
|
||||||
|
"cwchar": "cpp",
|
||||||
|
"cwctype": "cpp",
|
||||||
|
"deque": "cpp",
|
||||||
|
"list": "cpp",
|
||||||
|
"unordered_map": "cpp",
|
||||||
|
"unordered_set": "cpp",
|
||||||
|
"vector": "cpp",
|
||||||
|
"exception": "cpp",
|
||||||
|
"algorithm": "cpp",
|
||||||
|
"iterator": "cpp",
|
||||||
|
"map": "cpp",
|
||||||
|
"memory": "cpp",
|
||||||
|
"memory_resource": "cpp",
|
||||||
|
"numeric": "cpp",
|
||||||
|
"random": "cpp",
|
||||||
|
"set": "cpp",
|
||||||
|
"fstream": "cpp",
|
||||||
|
"initializer_list": "cpp",
|
||||||
|
"iomanip": "cpp",
|
||||||
|
"iosfwd": "cpp",
|
||||||
|
"iostream": "cpp",
|
||||||
|
"limits": "cpp",
|
||||||
|
"mutex": "cpp",
|
||||||
|
"new": "cpp",
|
||||||
|
"sstream": "cpp",
|
||||||
|
"stdexcept": "cpp",
|
||||||
|
"streambuf": "cpp",
|
||||||
|
"thread": "cpp",
|
||||||
|
"cinttypes": "cpp",
|
||||||
|
"typeinfo": "cpp"
|
||||||
|
},
|
||||||
|
"todo-tree.filtering.excludeGlobs": [
|
||||||
|
"**/vendor/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/bower_components/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/.vscode/**",
|
||||||
|
"**/.github/**",
|
||||||
|
"**/_output/**",
|
||||||
|
"**/*.min.*",
|
||||||
|
"**/*.map",
|
||||||
|
"**/ArduinoJson/**"
|
||||||
|
],
|
||||||
|
"cSpell.enableFiletypes": [
|
||||||
|
"ini",
|
||||||
|
"makefile"
|
||||||
|
],
|
||||||
|
"typescript.preferences.preferTypeOnlyAutoImports": true,
|
||||||
|
"sonarlint.pathToCompileCommands": "${workspaceFolder}/compile_commands.json",
|
||||||
|
"sonarlint.connectedMode.project": {
|
||||||
|
"connectionId": "emsesp",
|
||||||
|
"projectKey": "emsesp_EMS-ESP32"
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.8.4] 4 August 2026
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Allow auto-download and install of firmware versions which are smaller to allow update to the latest 3.9.0-development version.
|
||||||
|
|
||||||
## [3.8.3] 1 August 2026
|
## [3.8.3] 1 August 2026
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -35,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
||||||
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
|
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
|
||||||
|
|
||||||
|
|
||||||
## [3.8.2] 12 May 2026
|
## [3.8.2] 12 May 2026
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -101,7 +108,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- board profile `CUSTOM` can only be selected in developer mode
|
- board profile `CUSTOM` can only be selected in developer mode
|
||||||
- mqtt sends round values without decimals (`28` instead of `28.0`)
|
- mqtt sends round values without decimals (`28` instead of `28.0`)
|
||||||
|
|
||||||
|
|
||||||
## [3.8.0] 31 December 2025
|
## [3.8.0] 31 December 2025
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|||||||
@@ -1,27 +1,3 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
For more details go to [emsesp.org](https://emsesp.org/).
|
For more details go to [emsesp.org](https://emsesp.org/).
|
||||||
|
|
||||||
## [3.9.0]
|
|
||||||
|
|
||||||
This release is based on the latest Espressif/Arduino core version 3. It brings in many memory and performance optimizations. Note it does require the user to manually migrate settings from 3.8.x to 3.9.0.
|
|
||||||
|
|
||||||
## Added
|
|
||||||
|
|
||||||
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
|
|
||||||
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
|
|
||||||
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
|
|
||||||
- TLS support with 4MB boards without PSRAM
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- shunting yard show json
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
|
|
||||||
- Scheduler name is now mandatory
|
|
||||||
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
|
|
||||||
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
|
|
||||||
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ CXX_STANDARD := -std=gnu++20
|
|||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
DEFINES += -DARDUINOJSON_ENABLE -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0
|
||||||
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
DEFINES += -DEMSESP_STANDALONE -DEMSESP_TEST -DEMSESP_DEBUG -DEMC_RX_BUFFER_SIZE=1500
|
||||||
|
DEFINES += -DNO_TLS_SUPPORT
|
||||||
DEFINES += $(ARGS)
|
DEFINES += $(ARGS)
|
||||||
|
|
||||||
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
DEFAULTS = -DEMSESP_DEFAULT_LOCALE=\"en\" -DEMSESP_DEFAULT_BOARD_PROFILE=\"S32S3\"
|
||||||
@@ -112,8 +113,8 @@ CXX := /usr/bin/g++
|
|||||||
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
CPPFLAGS += $(DEFINES) $(DEFAULTS) $(INCLUDE)
|
||||||
CPPFLAGS += -ggdb -g3 -MMD
|
CPPFLAGS += -ggdb -g3 -MMD
|
||||||
CPPFLAGS += -flto=auto
|
CPPFLAGS += -flto=auto
|
||||||
CPPFLAGS += -Wall -Wextra -Werror
|
CPPFLAGS += -Wall -Wextra -Werror -Wswitch-enum
|
||||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
|
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
||||||
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
CPPFLAGS += -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti -fno-threadsafe-statics
|
||||||
CPPFLAGS += -Os -DNDEBUG
|
CPPFLAGS += -Os -DNDEBUG
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
},
|
},
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
|
"-DTASMOTA_SDK",
|
||||||
"-DARDUINO_LOLIN_C3_MINI",
|
"-DARDUINO_LOLIN_C3_MINI",
|
||||||
"-DARDUINO_USB_MODE=1",
|
"-DARDUINO_USB_MODE=1",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
"-DARDUINO_USB_CDC_ON_BOOT=1"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
"extra_flags": [
|
"extra_flags": [
|
||||||
"-DBOARD_HAS_PSRAM",
|
"-DBOARD_HAS_PSRAM",
|
||||||
|
"-DTASMOTA_SDK",
|
||||||
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
"-DARDUINO_USB_CDC_ON_BOOT=1",
|
||||||
"-DARDUINO_USB_MODE=0"
|
"-DARDUINO_USB_MODE=0"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Tasmota ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "32MB",
|
"flash_size": "32MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
|
"extra_flags": "-DTASMOTA_SDK",
|
||||||
"f_cpu": "240000000L",
|
"f_cpu": "240000000L",
|
||||||
"f_flash": "40000000L",
|
"f_flash": "40000000L",
|
||||||
"flash_mode": "dio",
|
"flash_mode": "dio",
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Tasmota ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Tasmota ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
||||||
"upload": {
|
"upload": {
|
||||||
"flash_size": "16MB",
|
"flash_size": "16MB",
|
||||||
"maximum_ram_size": 327680,
|
"maximum_ram_size": 327680,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"build": {
|
"build": {
|
||||||
"core": "esp32",
|
"core": "esp32",
|
||||||
|
"extra_flags": "-DTASMOTA_SDK",
|
||||||
"f_cpu": "240000000L",
|
"f_cpu": "240000000L",
|
||||||
"f_flash": "40000000L",
|
"f_flash": "40000000L",
|
||||||
"flash_mode": "dio",
|
"flash_mode": "dio",
|
||||||
|
|||||||
+25
-159
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"type": "systembackup",
|
"type": "systembackup",
|
||||||
"version": "3.9.0",
|
"version": "3.8.2",
|
||||||
"date": "2026-06-14T10:59:16",
|
|
||||||
"systembackup": [
|
"systembackup": [
|
||||||
{
|
{
|
||||||
"type": "settings",
|
"type": "settings",
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"tx_power": 0
|
"tx_power": 0
|
||||||
},
|
},
|
||||||
"AP": {
|
"AP": {
|
||||||
"provision_mode": 2,
|
"provision_mode": 1,
|
||||||
"ssid": "ems-esp",
|
"ssid": "ems-esp",
|
||||||
"password": "ems-esp-neo",
|
"password": "ems-esp-neo",
|
||||||
"channel": 1,
|
"channel": 1,
|
||||||
@@ -32,13 +31,13 @@
|
|||||||
"MQTT": {
|
"MQTT": {
|
||||||
"enableTLS": false,
|
"enableTLS": false,
|
||||||
"rootCA": "",
|
"rootCA": "",
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"host": "192.168.1.2",
|
"host": "",
|
||||||
"port": 1883,
|
"port": 1883,
|
||||||
"base": "ems-esp",
|
"base": "ems-esp",
|
||||||
"username": "username",
|
"username": "",
|
||||||
"password": "password",
|
"password": "",
|
||||||
"client_id": "esp32",
|
"client_id": "esp32-b8ffc9ec",
|
||||||
"keep_alive": 60,
|
"keep_alive": 60,
|
||||||
"clean_session": false,
|
"clean_session": false,
|
||||||
"entity_format": 1,
|
"entity_format": 1,
|
||||||
@@ -52,17 +51,17 @@
|
|||||||
"publish_time_heartbeat": 60,
|
"publish_time_heartbeat": 60,
|
||||||
"mqtt_qos": 0,
|
"mqtt_qos": 0,
|
||||||
"mqtt_retain": false,
|
"mqtt_retain": false,
|
||||||
"ha_enabled": true,
|
"ha_enabled": false,
|
||||||
"nested_format": 1,
|
"nested_format": 1,
|
||||||
"discovery_prefix": "homeassistant",
|
"discovery_prefix": "homeassistant",
|
||||||
"discovery_type": 0,
|
"discovery_type": 0,
|
||||||
"ha_number_mode": 1,
|
"ha_number_mode": 0,
|
||||||
"publish_single": false,
|
"publish_single": false,
|
||||||
"publish_single2cmd": false,
|
"publish_single2cmd": false,
|
||||||
"send_response": false
|
"send_response": false
|
||||||
},
|
},
|
||||||
"NTP": {
|
"NTP": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"server": "time.google.com",
|
"server": "time.google.com",
|
||||||
"tz_label": "Europe/Amsterdam",
|
"tz_label": "Europe/Amsterdam",
|
||||||
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
"tz_format": "CET-1CEST,M3.5.0,M10.5.0/3"
|
||||||
@@ -83,12 +82,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"version": "3.9.0",
|
"version": "3.8.2-dev.22",
|
||||||
"board_profile": "E32V2_2",
|
"board_profile": "E32V2_2",
|
||||||
"platform": "ESP32",
|
"platform": "ESP32",
|
||||||
"system_name": "prod",
|
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"tx_mode": 1,
|
"tx_mode": 5,
|
||||||
"ems_bus_id": 73,
|
"ems_bus_id": 73,
|
||||||
"syslog_enabled": false,
|
"syslog_enabled": false,
|
||||||
"syslog_level": 3,
|
"syslog_level": 3,
|
||||||
@@ -99,7 +97,7 @@
|
|||||||
"boiler_heatingoff": false,
|
"boiler_heatingoff": false,
|
||||||
"remote_timeout": 24,
|
"remote_timeout": 24,
|
||||||
"remote_timeout_en": false,
|
"remote_timeout_en": false,
|
||||||
"shower_timer": true,
|
"shower_timer": false,
|
||||||
"shower_alert": false,
|
"shower_alert": false,
|
||||||
"shower_alert_coldshot": 10,
|
"shower_alert_coldshot": 10,
|
||||||
"shower_alert_trigger": 7,
|
"shower_alert_trigger": 7,
|
||||||
@@ -109,7 +107,7 @@
|
|||||||
"dallas_gpio": 14,
|
"dallas_gpio": 14,
|
||||||
"dallas_parasite": false,
|
"dallas_parasite": false,
|
||||||
"led_gpio": 32,
|
"led_gpio": 32,
|
||||||
"hide_led": true,
|
"hide_led": false,
|
||||||
"led_type": 1,
|
"led_type": 1,
|
||||||
"low_clock": false,
|
"low_clock": false,
|
||||||
"telnet_enabled": true,
|
"telnet_enabled": true,
|
||||||
@@ -133,74 +131,26 @@
|
|||||||
"modbus_port": 502,
|
"modbus_port": 502,
|
||||||
"modbus_max_clients": 10,
|
"modbus_max_clients": 10,
|
||||||
"modbus_timeout": 300,
|
"modbus_timeout": 300,
|
||||||
"developer_mode": true,
|
"developer_mode": false
|
||||||
"disable_reset": false,
|
|
||||||
"email_enabled": true,
|
|
||||||
"email_security": 2,
|
|
||||||
"email_server": "smtp.gmail.com",
|
|
||||||
"email_port": 587,
|
|
||||||
"email_login": "test@emsesp.org",
|
|
||||||
"email_pass": "password",
|
|
||||||
"email_sender": "test@emsesp.org",
|
|
||||||
"email_recp": "test@emsesp.org",
|
|
||||||
"email_subject": "ems-esp notification"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "commands",
|
|
||||||
"Commands": {
|
|
||||||
"commands": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"cmd": "{\"url\":\"https://api.pushover.net/1/messages.json\"}",
|
|
||||||
"value": "{\"user\":\"userkey\", \"token\":\"tokenkey\", \"message\":\"custom/message2\"}",
|
|
||||||
"name": "push_over"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"cmd": "system/message",
|
|
||||||
"value": "hello",
|
|
||||||
"name": "sendmessage"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 3,
|
|
||||||
"cmd": "system/message",
|
|
||||||
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
|
|
||||||
"name": "sendversions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 4,
|
|
||||||
"cmd": "custom/message",
|
|
||||||
"value": "{\"url\":\"http://emsesp.org/versions.json\"}",
|
|
||||||
"name": "getversions"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 5,
|
|
||||||
"cmd": "system/sendmail",
|
|
||||||
"value": "\"test email with \" + custom/message2",
|
|
||||||
"name": "sendmail"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "schedule",
|
"type": "schedule",
|
||||||
"Schedule": {
|
"Schedule": {
|
||||||
"schedule": [
|
"schedule": []
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"flags": 128,
|
|
||||||
"active": false,
|
|
||||||
"time": "",
|
|
||||||
"cmd_name": "sendmail",
|
|
||||||
"name": "sendmailtest"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "customizations",
|
"type": "customizations",
|
||||||
"Customizations": {
|
"Customizations": {
|
||||||
|
"ts": [
|
||||||
|
{
|
||||||
|
"id": "28_1767_7B13_2502",
|
||||||
|
"name": "gateway_temperature",
|
||||||
|
"offset": 0,
|
||||||
|
"is_system": true
|
||||||
|
}
|
||||||
|
],
|
||||||
"as": [
|
"as": [
|
||||||
{
|
{
|
||||||
"gpio": 39,
|
"gpio": 39,
|
||||||
@@ -236,36 +186,7 @@
|
|||||||
{
|
{
|
||||||
"type": "entities",
|
"type": "entities",
|
||||||
"Entities": {
|
"Entities": {
|
||||||
"entities": [
|
"entities": []
|
||||||
{
|
|
||||||
"id": 0,
|
|
||||||
"ram": 1,
|
|
||||||
"device_id": 0,
|
|
||||||
"type_id": 0,
|
|
||||||
"offset": 0,
|
|
||||||
"factor": 1,
|
|
||||||
"name": "message",
|
|
||||||
"uom": 0,
|
|
||||||
"value_type": 9,
|
|
||||||
"writeable": true,
|
|
||||||
"hide": false,
|
|
||||||
"value": "{\"stable\":{\"version\":\"3.8.3\",\"date\":\"2026-08-01\"},\"dev\":{\"version\":\"3.9.0-dev.0\",\"date\":\"2026-08-01T09:55:29Z\"}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"ram": 1,
|
|
||||||
"device_id": 0,
|
|
||||||
"type_id": 0,
|
|
||||||
"offset": 0,
|
|
||||||
"factor": 1,
|
|
||||||
"name": "message2",
|
|
||||||
"uom": 0,
|
|
||||||
"value_type": 9,
|
|
||||||
"writeable": true,
|
|
||||||
"hide": false,
|
|
||||||
"value": "testing"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -277,65 +198,10 @@
|
|||||||
{
|
{
|
||||||
"type": "nvs",
|
"type": "nvs",
|
||||||
"nvs": [
|
"nvs": [
|
||||||
{
|
|
||||||
"type": 1,
|
|
||||||
"key": "nompower",
|
|
||||||
"value": 30
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 33,
|
|
||||||
"key": "boot",
|
|
||||||
"value": "3.9.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 17,
|
|
||||||
"key": "led",
|
|
||||||
"value": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 33,
|
|
||||||
"key": "intTemp",
|
|
||||||
"value": "28_1767_7B13_2502"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 4,
|
|
||||||
"key": "d_boot",
|
|
||||||
"value": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 33,
|
|
||||||
"key": "app1",
|
|
||||||
"value": "3.9.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 33,
|
|
||||||
"key": "app0",
|
|
||||||
"value": "3.9.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 66,
|
|
||||||
"key": "nrgheat",
|
|
||||||
"value": 49355.29793
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 4,
|
|
||||||
"key": "d_app1",
|
|
||||||
"value": 1781336521
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": 1,
|
"type": 1,
|
||||||
"key": "fresh_firmware",
|
"key": "fresh_firmware",
|
||||||
"value": 0
|
"value": 0
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 4,
|
|
||||||
"key": "d_app0",
|
|
||||||
"value": 1781363944
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": 66,
|
|
||||||
"key": "nrgww",
|
|
||||||
"value": 41852.79107
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5998
-5998
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ build_flags =
|
|||||||
; WiFi settings
|
; WiFi settings
|
||||||
-D FACTORY_WIFI_SSID=\"\"
|
-D FACTORY_WIFI_SSID=\"\"
|
||||||
-D FACTORY_WIFI_PASSWORD=\"\"
|
-D FACTORY_WIFI_PASSWORD=\"\"
|
||||||
-D FACTORY_NETWORK_HOSTNAME=\"ems-esp\"
|
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
|
||||||
|
|
||||||
; Access point settings
|
; Access point settings
|
||||||
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
|
-D FACTORY_AP_PROVISION_MODE=AP_MODE_DISCONNECTED
|
||||||
@@ -24,7 +24,7 @@ build_flags =
|
|||||||
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
|
-D FACTORY_NTP_TIME_ZONE_LABEL=\"Europe/Amsterdam\"
|
||||||
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
-D FACTORY_NTP_TIME_ZONE_FORMAT=\"CET-1CEST,M3.5.0,M10.5.0/3\"
|
||||||
-D FACTORY_NTP_SERVER=\"time.google.com\"
|
-D FACTORY_NTP_SERVER=\"time.google.com\"
|
||||||
-D FACTORY_NTP_THERMOSTAT_SYNC=1
|
-D FACTORY_NTP_THERMOSTAT_SYNC=0
|
||||||
|
|
||||||
; MQTT settings
|
; MQTT settings
|
||||||
-D FACTORY_MQTT_ENABLED=false
|
-D FACTORY_MQTT_ENABLED=false
|
||||||
|
|||||||
+32
-23
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "EMS-ESP",
|
"name": "EMS-ESP",
|
||||||
"version": "3.9.0",
|
"version": "3.8.2",
|
||||||
"description": "EMS-ESP WebUI",
|
"description": "EMS-ESP WebUI",
|
||||||
"homepage": "https://emsesp.org",
|
"homepage": "https://emsesp.org",
|
||||||
"author": "emsesp.org",
|
"author": "proddy, emsesp.org",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -28,35 +28,44 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
|
"@preact/compat": "^18.3.2",
|
||||||
"@table-library/react-table-library": "4.1.15",
|
"@table-library/react-table-library": "4.1.15",
|
||||||
"alova": "^3.5.3",
|
"alova": "3.5.1",
|
||||||
|
"async-validator": "^4.2.5",
|
||||||
|
"etag": "^1.8.1",
|
||||||
|
"formidable": "^3.5.4",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"preact": "^10.29.8",
|
"magic-string": "^0.30.21",
|
||||||
"react": "^19.2.8",
|
"mime-types": "^3.0.2",
|
||||||
"react-dom": "^19.2.8",
|
"preact": "^10.29.7",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^7.18.1",
|
||||||
"typesafe-i18n": "^5.27.1"
|
"react-toastify": "^11.1.0",
|
||||||
|
"typesafe-i18n": "^5.27.1",
|
||||||
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.7",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@preact/preset-vite": "^2.10.6",
|
"@preact/compat": "^18.3.2",
|
||||||
|
"@preact/preset-vite": "^2.10.5",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^25.7.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.3",
|
||||||
"concurrently": "^10.0.4",
|
"axe-core": "^4.12.1",
|
||||||
"eslint": "^10.8.0",
|
"concurrently": "^9.2.4",
|
||||||
|
"eslint": "^10.7.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"etag": "^1.8.1",
|
"prettier": "^3.9.5",
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"prettier": "^3.9.6",
|
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"terser": "^5.49.0",
|
"terser": "^5.47.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript-eslint": "^8.59.3",
|
||||||
"typescript-eslint": "^8.65.0",
|
"vite": "^8.0.12",
|
||||||
"vite": "^8.2.0",
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
"vite-plugin-imagemin": "^0.6.1"
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+418
-291
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,33 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
cwebp-bin: false
|
cwebp-bin: set this to true or false
|
||||||
esbuild: false
|
esbuild: set this to true or false
|
||||||
gifsicle: false
|
gifsicle: set this to true or false
|
||||||
jpegtran-bin: false
|
jpegtran-bin: set this to true or false
|
||||||
mozjpeg: false
|
mozjpeg: set this to true or false
|
||||||
optipng-bin: false
|
optipng-bin: set this to true or false
|
||||||
pngquant-bin: false
|
pngquant-bin: set this to true or false
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@babel/code-frame@7.29.7'
|
||||||
|
- '@babel/compat-data@7.29.7'
|
||||||
|
- '@babel/core@7.29.7'
|
||||||
|
- '@babel/generator@7.29.7'
|
||||||
|
- '@babel/helper-compilation-targets@7.29.7'
|
||||||
|
- '@babel/helper-globals@7.29.7'
|
||||||
|
- '@babel/helper-module-imports@7.29.7'
|
||||||
|
- '@babel/helper-module-transforms@7.29.7'
|
||||||
|
- '@babel/helper-string-parser@7.29.7'
|
||||||
|
- '@babel/helper-validator-identifier@7.29.7'
|
||||||
|
- '@babel/helper-validator-option@7.29.7'
|
||||||
|
- '@babel/helpers@7.29.7'
|
||||||
|
- '@babel/parser@7.29.7'
|
||||||
|
- '@babel/template@7.29.7'
|
||||||
|
- '@babel/traverse@7.29.7'
|
||||||
|
- '@babel/types@7.29.7'
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- cwebp-bin
|
||||||
|
- esbuild
|
||||||
|
- gifsicle
|
||||||
|
- jpegtran-bin
|
||||||
|
- mozjpeg
|
||||||
|
- optipng-bin
|
||||||
|
- pngquant-bin
|
||||||
|
|||||||
@@ -46,38 +46,6 @@ ${fileInfo.map((f) => `${INDENT}{"${f.uri}", "${f.mimeType}", ${f.variable}, ${f
|
|||||||
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
|
static constexpr size_t WWW_ASSETS_COUNT = sizeof(WWW_ASSETS) / sizeof(WWW_ASSETS[0]);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Optional locale allow-list, shared with the Vite build via VITE_APP_LOCALES
|
|
||||||
// (e.g. "en,de,nl"). When set, locale chunks outside the list are NOT embedded
|
|
||||||
// into firmware flash. `en` is always kept as the fallback. Unset => embed all.
|
|
||||||
const ALL_LOCALES = [
|
|
||||||
'cs',
|
|
||||||
'de',
|
|
||||||
'en',
|
|
||||||
'fr',
|
|
||||||
'it',
|
|
||||||
'nl',
|
|
||||||
'no',
|
|
||||||
'pl',
|
|
||||||
'sk',
|
|
||||||
'sv',
|
|
||||||
'tr'
|
|
||||||
];
|
|
||||||
const localeAllowList = (process.env.VITE_APP_LOCALES || '')
|
|
||||||
.split(',')
|
|
||||||
.map((locale) => locale.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const isExcludedLocaleChunk = (relativeFilePath) => {
|
|
||||||
if (localeAllowList.length === 0) return false;
|
|
||||||
const base = relativeFilePath.split(sep).pop();
|
|
||||||
const match = /^([a-z]{2})-[A-Za-z0-9_-]+\.js$/.exec(base);
|
|
||||||
if (!match) return false;
|
|
||||||
const code = match[1];
|
|
||||||
// Only treat known locale codes as locale chunks; never drop the en fallback.
|
|
||||||
if (!ALL_LOCALES.includes(code) || code === 'en') return false;
|
|
||||||
return !localeAllowList.includes(code);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFilesSync = (dir, files = []) => {
|
const getFilesSync = (dir, files = []) => {
|
||||||
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
|
||||||
const entryPath = resolve(dir, entry.name);
|
const entryPath = resolve(dir, entry.name);
|
||||||
@@ -148,12 +116,7 @@ writeStream.write(ARDUINO_INCLUDES);
|
|||||||
|
|
||||||
const buildPath = resolve(sourcePath);
|
const buildPath = resolve(sourcePath);
|
||||||
for (const filePath of getFilesSync(buildPath)) {
|
for (const filePath of getFilesSync(buildPath)) {
|
||||||
const relativeFilePath = relative(buildPath, filePath);
|
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
||||||
if (isExcludedLocaleChunk(relativeFilePath)) {
|
|
||||||
console.log(`Skipping locale (not in VITE_APP_LOCALES): ${relativeFilePath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
writeFile(relativeFilePath, readFileSync(filePath));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStream.write(generateWWWClass());
|
writeStream.write(generateWWWClass());
|
||||||
|
|||||||
+32
-32
@@ -1,15 +1,14 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import { Outlet } from 'react-router';
|
import { ToastContainer, Zoom } from 'react-toastify';
|
||||||
|
|
||||||
|
import AppRouting from 'AppRouting';
|
||||||
import CustomTheme from 'CustomTheme';
|
import CustomTheme from 'CustomTheme';
|
||||||
import { Toaster } from 'components/toast';
|
|
||||||
import { Authentication } from 'contexts/authentication';
|
|
||||||
import TypesafeI18n from 'i18n/i18n-react';
|
import TypesafeI18n from 'i18n/i18n-react';
|
||||||
import type { Locales } from 'i18n/i18n-types';
|
import type { Locales } from 'i18n/i18n-types';
|
||||||
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
import { loadLocaleAsync } from 'i18n/i18n-util.async';
|
||||||
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
import { detectLocale, navigatorDetector } from 'typesafe-i18n/detectors';
|
||||||
|
|
||||||
const ALL_LOCALES = [
|
const AVAILABLE_LOCALES = [
|
||||||
'de',
|
'de',
|
||||||
'en',
|
'en',
|
||||||
'it',
|
'it',
|
||||||
@@ -23,50 +22,51 @@ const ALL_LOCALES = [
|
|||||||
'cs'
|
'cs'
|
||||||
] as Locales[];
|
] as Locales[];
|
||||||
|
|
||||||
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
|
// Static toast configuration - no need to recreate on every render
|
||||||
// every locale is available. `en` is always kept as the fallback locale, and the
|
const TOAST_CONTAINER_PROPS = {
|
||||||
// progmem generator embeds the matching subset into firmware flash.
|
position: 'bottom-left' as const,
|
||||||
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
|
autoClose: 3000,
|
||||||
.split(',')
|
hideProgressBar: false,
|
||||||
.map((locale) => locale.trim())
|
newestOnTop: false,
|
||||||
.filter(Boolean);
|
closeOnClick: true,
|
||||||
|
rtl: false,
|
||||||
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
|
pauseOnFocusLoss: true,
|
||||||
? ALL_LOCALES.filter(
|
draggable: false,
|
||||||
(locale) => locale === 'en' || localeAllowList.includes(locale)
|
pauseOnHover: false,
|
||||||
)
|
transition: Zoom,
|
||||||
: ALL_LOCALES;
|
closeButton: false,
|
||||||
|
theme: 'dark' as const,
|
||||||
|
toastStyle: {
|
||||||
|
border: '1px solid #177ac9',
|
||||||
|
width: 'fit-content'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const App = memo(() => {
|
const App = memo(() => {
|
||||||
const [wasLoaded, setWasLoaded] = useState(false);
|
const [wasLoaded, setWasLoaded] = useState(false);
|
||||||
const [locale, setLocale] = useState<Locales>('en');
|
const [locale, setLocale] = useState<Locales>('en');
|
||||||
|
|
||||||
useEffect(() => {
|
// Memoize locale initialization to prevent unnecessary re-runs
|
||||||
const initializeLocale = async () => {
|
const initializeLocale = useCallback(async () => {
|
||||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||||
const stored = localStorage.getItem('lang');
|
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
||||||
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
|
|
||||||
const newLocale =
|
|
||||||
stored && AVAILABLE_LOCALES.includes(stored as Locales)
|
|
||||||
? (stored as Locales)
|
|
||||||
: browserLocale;
|
|
||||||
localStorage.setItem('lang', newLocale);
|
localStorage.setItem('lang', newLocale);
|
||||||
setLocale(newLocale);
|
setLocale(newLocale);
|
||||||
await loadLocaleAsync(newLocale);
|
await loadLocaleAsync(newLocale);
|
||||||
setWasLoaded(true);
|
setWasLoaded(true);
|
||||||
};
|
|
||||||
void initializeLocale();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void initializeLocale();
|
||||||
|
}, [initializeLocale]);
|
||||||
|
|
||||||
if (!wasLoaded) return null;
|
if (!wasLoaded) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypesafeI18n locale={locale}>
|
<TypesafeI18n locale={locale}>
|
||||||
<CustomTheme>
|
<CustomTheme>
|
||||||
<Authentication>
|
<AppRouting />
|
||||||
<Outlet />
|
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
||||||
</Authentication>
|
|
||||||
<Toaster />
|
|
||||||
</CustomTheme>
|
</CustomTheme>
|
||||||
</TypesafeI18n>
|
</TypesafeI18n>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { type FC, 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 { Authentication, AuthenticationContext } from 'contexts/authentication';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
|
interface SecurityRedirectProps {
|
||||||
|
readonly message: string;
|
||||||
|
readonly signOut?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RootRedirect: FC<SecurityRedirectProps> = memo(
|
||||||
|
({ message, signOut = false }) => {
|
||||||
|
const { signOut: contextSignOut } = useContext(AuthenticationContext);
|
||||||
|
const hasShownToast = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Prevent duplicate toasts on strict mode or re-renders
|
||||||
|
if (!hasShownToast.current) {
|
||||||
|
hasShownToast.current = true;
|
||||||
|
if (signOut) {
|
||||||
|
contextSignOut(false);
|
||||||
|
}
|
||||||
|
toast.success(message);
|
||||||
|
}
|
||||||
|
// Only run once on mount - using ref to track execution
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const AppRouting: FC = memo(() => {
|
||||||
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Authentication>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/unauthorized"
|
||||||
|
element={<RootRedirect message={LL.PLEASE_SIGNIN()} signOut />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/fileUpdated"
|
||||||
|
element={<RootRedirect message={LL.UPLOAD_SUCCESSFUL()} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireUnauthenticated>
|
||||||
|
<SignIn />
|
||||||
|
</RequireUnauthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<RequireAuthenticated>
|
||||||
|
<AuthenticatedRouting />
|
||||||
|
</RequireAuthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Authentication>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AppRouting;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { 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 UserProfile from 'app/main/UserProfile';
|
||||||
|
import APSettings from 'app/settings/APSettings';
|
||||||
|
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
||||||
|
import DownloadUpload from 'app/settings/DownloadUpload';
|
||||||
|
import MqttSettings from 'app/settings/MqttSettings';
|
||||||
|
import NTPSettings from 'app/settings/NTPSettings';
|
||||||
|
import Settings from 'app/settings/Settings';
|
||||||
|
import Network from 'app/settings/network/Network';
|
||||||
|
import Security from 'app/settings/security/Security';
|
||||||
|
import APStatus from 'app/status/APStatus';
|
||||||
|
import Activity from 'app/status/Activity';
|
||||||
|
import HardwareStatus from 'app/status/HardwareStatus';
|
||||||
|
import MqttStatus from 'app/status/MqttStatus';
|
||||||
|
import NTPStatus from 'app/status/NTPStatus';
|
||||||
|
import NetworkStatus from 'app/status/NetworkStatus';
|
||||||
|
import Status from 'app/status/Status';
|
||||||
|
import SystemLog from 'app/status/SystemLog';
|
||||||
|
import Version from 'app/status/Version';
|
||||||
|
import { Layout } from 'components';
|
||||||
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
|
||||||
|
const AuthenticatedRouting = memo(() => {
|
||||||
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/dashboard/*" element={<Dashboard />} />
|
||||||
|
<Route path="/devices/*" element={<Devices />} />
|
||||||
|
<Route path="/sensors/*" element={<Sensors />} />
|
||||||
|
<Route path="/help/*" element={<Help />} />
|
||||||
|
<Route path="/user/*" element={<UserProfile />} />
|
||||||
|
|
||||||
|
<Route path="/status/*" element={<Status />} />
|
||||||
|
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
||||||
|
<Route path="/status/activity" element={<Activity />} />
|
||||||
|
<Route path="/status/log" element={<SystemLog />} />
|
||||||
|
<Route path="/status/mqtt" element={<MqttStatus />} />
|
||||||
|
<Route path="/status/ntp" element={<NTPStatus />} />
|
||||||
|
<Route path="/status/ap" element={<APStatus />} />
|
||||||
|
<Route path="/status/network" element={<NetworkStatus />} />
|
||||||
|
<Route path="/status/version" element={<Version />} />
|
||||||
|
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/settings/application" element={<ApplicationSettings />} />
|
||||||
|
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
||||||
|
<Route path="/settings/ntp" element={<NTPSettings />} />
|
||||||
|
<Route path="/settings/ap" element={<APSettings />} />
|
||||||
|
<Route path="/settings/modules" element={<Modules />} />
|
||||||
|
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
||||||
|
|
||||||
|
<Route path="/settings/network/*" element={<Network />} />
|
||||||
|
<Route path="/settings/security/*" element={<Security />} />
|
||||||
|
|
||||||
|
<Route path="/customizations" element={<Customizations />} />
|
||||||
|
<Route path="/scheduler" element={<Scheduler />} />
|
||||||
|
<Route path="/customentities" element={<CustomEntities />} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Route path="/*" element={<Navigate to="/" />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AuthenticatedRouting;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import ForwardIcon from '@mui/icons-material/Forward';
|
import ForwardIcon from '@mui/icons-material/Forward';
|
||||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
import { Box, Button, Paper, Typography } from '@mui/material';
|
||||||
@@ -6,19 +7,18 @@ import type { Theme } from '@mui/material/styles';
|
|||||||
|
|
||||||
import * as AuthenticationApi from 'components/routing/authentication';
|
import * as AuthenticationApi from 'components/routing/authentication';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
LanguageSelector,
|
LanguageSelector,
|
||||||
ValidatedPasswordField,
|
ValidatedPasswordField,
|
||||||
ValidatedTextField
|
ValidatedTextField
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticationContext } from 'contexts/authentication';
|
import { AuthenticationContext } from 'contexts/authentication';
|
||||||
import { PROJECT_NAME } from 'env';
|
import { PROJECT_NAME } from 'env';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { SignInRequest } from 'types';
|
import type { SignInRequest } from 'types';
|
||||||
import { onEnterCallback, updateValue } from 'utils';
|
import { onEnterCallback, updateValue } from 'utils';
|
||||||
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
|
import { SIGN_IN_REQUEST_VALIDATOR, ValidationError, validate } from 'validators';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
const SignIn = memo(() => {
|
const SignIn = memo(() => {
|
||||||
const authenticationContext = useContext(AuthenticationContext);
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
@@ -43,6 +43,7 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Memoize callback to prevent recreation on every render
|
||||||
const updateLoginRequestValue = useMemo(
|
const updateLoginRequestValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
updateValue((updater) =>
|
updateValue((updater) =>
|
||||||
@@ -64,7 +65,7 @@ const SignIn = memo(() => {
|
|||||||
});
|
});
|
||||||
}, [callSignIn, signInRequest, LL]);
|
}, [callSignIn, signInRequest, LL]);
|
||||||
|
|
||||||
const validateAndSignIn = async () => {
|
const validateAndSignIn = useCallback(async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||||
required: LL.IS_REQUIRED('%s')
|
required: LL.IS_REQUIRED('%s')
|
||||||
@@ -76,7 +77,7 @@ const SignIn = memo(() => {
|
|||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [signInRequest, signIn, LL]);
|
||||||
|
|
||||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type {
|
|||||||
APIcall,
|
APIcall,
|
||||||
Action,
|
Action,
|
||||||
Activity,
|
Activity,
|
||||||
CommandItem,
|
|
||||||
Commands,
|
|
||||||
CoreData,
|
CoreData,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
DeviceData,
|
DeviceData,
|
||||||
@@ -104,7 +102,8 @@ export const readSchedule = () =>
|
|||||||
o_deleted: si.deleted,
|
o_deleted: si.deleted,
|
||||||
o_flags: si.flags,
|
o_flags: si.flags,
|
||||||
o_time: si.time,
|
o_time: si.time,
|
||||||
o_cmd_name: si.cmd_name,
|
o_cmd: si.cmd,
|
||||||
|
o_value: si.value,
|
||||||
o_name: si.name
|
o_name: si.name
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -112,24 +111,6 @@ export const readSchedule = () =>
|
|||||||
export const writeSchedule = (data: Schedule) =>
|
export const writeSchedule = (data: Schedule) =>
|
||||||
alovaInstance.Post('/rest/schedule', data);
|
alovaInstance.Post('/rest/schedule', data);
|
||||||
|
|
||||||
// Commands
|
|
||||||
export const readCommands = () =>
|
|
||||||
alovaInstance.Get<CommandItem[]>('/rest/commands', {
|
|
||||||
// @ts-expect-error - exactOptionalPropertyTypes compatibility issue
|
|
||||||
transform(data) {
|
|
||||||
const commands = (data as Commands).commands;
|
|
||||||
return commands.map((ci) => ({
|
|
||||||
...ci,
|
|
||||||
o_id: ci.id,
|
|
||||||
o_cmd: ci.cmd,
|
|
||||||
o_value: ci.value,
|
|
||||||
o_name: ci.name
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
export const writeCommands = (data: Commands) =>
|
|
||||||
alovaInstance.Post('/rest/commands', data);
|
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
export const readModules = () =>
|
export const readModules = () =>
|
||||||
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
alovaInstance.Get<ModuleItem[]>('/rest/modules', {
|
||||||
|
|||||||
@@ -57,3 +57,12 @@ export const alovaInstance = createAlova({
|
|||||||
onSuccess: handleResponse
|
onSuccess: handleResponse
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const alovaInstanceGH = createAlova({
|
||||||
|
baseURL:
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? '/gh'
|
||||||
|
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
||||||
|
statesHook: ReactHook,
|
||||||
|
requestAdapter: xhrRequestAdapter()
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LogSettings, SystemStatus } from 'types';
|
import type { LogSettings, SystemStatus } from 'types';
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||||
|
|
||||||
// systemStatus - also used to ping in System Monitor for pinging
|
// systemStatus - also used to ping in System Monitor for pinging
|
||||||
export const readSystemStatus = () =>
|
export const readSystemStatus = () =>
|
||||||
@@ -13,6 +13,29 @@ export const updateLogSettings = (data: LogSettings) =>
|
|||||||
alovaInstance.Post('/rest/logSettings', data);
|
alovaInstance.Post('/rest/logSettings', data);
|
||||||
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
export const fetchLogES = () => alovaInstance.Get('/es/log');
|
||||||
|
|
||||||
|
// Get versions from GitHub
|
||||||
|
// cache for 10 minutes to stop getting the IP blocked by GitHub
|
||||||
|
export const getStableVersion = () =>
|
||||||
|
alovaInstanceGH.Get('latest', {
|
||||||
|
cacheFor: 60 * 10 * 1000,
|
||||||
|
transform(response: { data: { name: string; published_at: string } }) {
|
||||||
|
return {
|
||||||
|
name: response.data.name.substring(1),
|
||||||
|
published_at: response.data.published_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const getDevVersion = () =>
|
||||||
|
alovaInstanceGH.Get('tags/latest', {
|
||||||
|
cacheFor: 60 * 10 * 1000,
|
||||||
|
transform(response: { data: { name: string; published_at: string } }) {
|
||||||
|
return {
|
||||||
|
name: response.data.name.split(/\s+/).splice(-1)[0]?.substring(1) || '',
|
||||||
|
published_at: response.data.published_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
const UPLOAD_TIMEOUT = 60000; // 1 minute
|
||||||
|
|
||||||
export const uploadFile = (file: File) => {
|
export const uploadFile = (file: File) => {
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useBlocker } from 'react-router';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { Box, Button, IconButton, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
import { callAction } from '@/api/app';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Cell,
|
|
||||||
Header,
|
|
||||||
HeaderCell,
|
|
||||||
HeaderRow,
|
|
||||||
Row,
|
|
||||||
Table
|
|
||||||
} from '@table-library/react-table-library/table';
|
|
||||||
import { useTheme } from '@table-library/react-table-library/theme';
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
|
||||||
import {
|
|
||||||
BlockNavigation,
|
|
||||||
ButtonRow,
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { useInterval } from 'utils';
|
|
||||||
|
|
||||||
import { readCommands, writeCommands } from '../../api/app';
|
|
||||||
import CommandsDialog from './CommandsDialog';
|
|
||||||
import type { CommandItem, Commands as CommandsType } from './types';
|
|
||||||
import { commandItemValidation } from './validators';
|
|
||||||
|
|
||||||
const INTERVAL_DELAY = 30000;
|
|
||||||
const MIN_ID = -100;
|
|
||||||
const MAX_ID = 100;
|
|
||||||
|
|
||||||
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
|
|
||||||
cmd: '',
|
|
||||||
value: '',
|
|
||||||
name: '',
|
|
||||||
deleted: false
|
|
||||||
};
|
|
||||||
|
|
||||||
const commandsTheme = {
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px 32px;
|
|
||||||
`,
|
|
||||||
BaseRow: `
|
|
||||||
font-size: 14px;
|
|
||||||
.td {
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
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-bottom: 1px solid #565656;
|
|
||||||
}
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
};
|
|
||||||
|
|
||||||
const Commands = () => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const [numChanges, setNumChanges] = useState<number>(0);
|
|
||||||
const blocker = useBlocker(numChanges !== 0);
|
|
||||||
const [selectedItem, setSelectedItem] = useState<CommandItem>();
|
|
||||||
const [creating, setCreating] = useState<boolean>(false);
|
|
||||||
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.COMMANDS());
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: commands,
|
|
||||||
send: fetchCommands,
|
|
||||||
error
|
|
||||||
} = useRequest(readCommands, {
|
|
||||||
initialData: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: updateCommands } = useRequest(
|
|
||||||
(data: CommandsType) => writeCommands(data),
|
|
||||||
{ immediate: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasChanged = (ci: CommandItem) =>
|
|
||||||
ci.id !== ci.o_id ||
|
|
||||||
(ci.name || '') !== (ci.o_name || '') ||
|
|
||||||
ci.cmd !== ci.o_cmd ||
|
|
||||||
ci.value !== ci.o_value ||
|
|
||||||
ci.deleted !== ci.o_deleted;
|
|
||||||
|
|
||||||
useInterval(() => {
|
|
||||||
if (numChanges === 0) {
|
|
||||||
void fetchCommands();
|
|
||||||
}
|
|
||||||
}, INTERVAL_DELAY);
|
|
||||||
|
|
||||||
const theme = useTheme(commandsTheme);
|
|
||||||
|
|
||||||
const saveCommands = async () => {
|
|
||||||
try {
|
|
||||||
await updateCommands({
|
|
||||||
commands: commands
|
|
||||||
.filter((ci: CommandItem) => !ci.deleted)
|
|
||||||
.map((ci: CommandItem) => ({
|
|
||||||
id: ci.id,
|
|
||||||
cmd: ci.cmd,
|
|
||||||
value: ci.value,
|
|
||||||
name: ci.name
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
toast.error(message);
|
|
||||||
} finally {
|
|
||||||
await fetchCommands();
|
|
||||||
setNumChanges(0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const { send: executeCommand } = useRequest(
|
|
||||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
|
||||||
{ immediate: false }
|
|
||||||
)
|
|
||||||
.onSuccess(() => {
|
|
||||||
toast.success(LL.EXECUTE_COMMAND_SENT());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const execute = async (name: string) => {
|
|
||||||
await executeCommand(name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const editItem = (ci: CommandItem) => {
|
|
||||||
setCreating(false);
|
|
||||||
setSelectedItem(ci);
|
|
||||||
setDialogOpen(true);
|
|
||||||
if (ci.o_name === undefined) {
|
|
||||||
ci.o_name = ci.name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogClose = () => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogCancel = async () => {
|
|
||||||
await fetchCommands().then(() => {
|
|
||||||
setNumChanges(0);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: CommandItem) => {
|
|
||||||
setDialogOpen(false);
|
|
||||||
void updateState(readCommands(), (data: CommandItem[]) => {
|
|
||||||
const new_data = creating
|
|
||||||
? [...data, updatedItem]
|
|
||||||
: data.map((ci) =>
|
|
||||||
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
|
|
||||||
);
|
|
||||||
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
|
|
||||||
return new_data;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const addItem = () => {
|
|
||||||
setCreating(true);
|
|
||||||
const newItem: CommandItem = {
|
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
|
||||||
...DEFAULT_COMMAND_ITEM
|
|
||||||
};
|
|
||||||
setSelectedItem(newItem);
|
|
||||||
setDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
|
|
||||||
|
|
||||||
const renderCommands = () => {
|
|
||||||
if (!commands) {
|
|
||||||
return (
|
|
||||||
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table
|
|
||||||
data={{ nodes: filteredCommands }}
|
|
||||||
theme={theme}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: CommandItem[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
|
||||||
<HeaderCell></HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((ci: CommandItem) => (
|
|
||||||
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
|
|
||||||
<Cell>{ci.cmd}</Cell>
|
|
||||||
<Cell>{ci.value}</Cell>
|
|
||||||
<Cell>{ci.name}</Cell>
|
|
||||||
<Cell>
|
|
||||||
{numChanges === 0 && (
|
|
||||||
<IconButton
|
|
||||||
onClick={() => execute(ci.name)}
|
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
|
||||||
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
|
||||||
{LL.COMMANDS_HELP_1()}.
|
|
||||||
</Typography>
|
|
||||||
{renderCommands()}
|
|
||||||
|
|
||||||
{selectedItem && (
|
|
||||||
<CommandsDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
creating={creating}
|
|
||||||
onClose={onDialogClose}
|
|
||||||
onSave={onDialogSave}
|
|
||||||
selectedItem={selectedItem}
|
|
||||||
validator={commandItemValidation(commands, selectedItem)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
{numChanges !== 0 && (
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onDialogCancel}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="contained"
|
|
||||||
color="info"
|
|
||||||
onClick={saveCommands}
|
|
||||||
>
|
|
||||||
{LL.APPLY_CHANGES(numChanges)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
|
||||||
<ButtonRow>
|
|
||||||
<Button
|
|
||||||
startIcon={<AddIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={addItem}
|
|
||||||
>
|
|
||||||
{LL.ADD(0)}
|
|
||||||
</Button>
|
|
||||||
</ButtonRow>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Commands;
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
TextField
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import { callAction } from '@/api/app';
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import { ValidatedTextField } from 'components';
|
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import { updateValue } from 'utils';
|
|
||||||
import { ValidationError, validate } from 'validators';
|
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import type { CommandItem } from './types';
|
|
||||||
|
|
||||||
interface CommandsDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
creating: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (ci: CommandItem) => void;
|
|
||||||
selectedItem: CommandItem;
|
|
||||||
validator: Schema;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandsDialog = ({
|
|
||||||
open,
|
|
||||||
creating,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
selectedItem,
|
|
||||||
validator
|
|
||||||
}: CommandsDialogProps) => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
|
||||||
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
|
||||||
|
|
||||||
const updateFormValue = updateValue(
|
|
||||||
setEditItem as unknown as React.Dispatch<
|
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
setEditItem(selectedItem);
|
|
||||||
}
|
|
||||||
}, [open, selectedItem]);
|
|
||||||
|
|
||||||
const hasChanged = (ci: CommandItem) =>
|
|
||||||
ci.id !== ci.o_id ||
|
|
||||||
(ci.name || '') !== (ci.o_name || '') ||
|
|
||||||
ci.cmd !== ci.o_cmd ||
|
|
||||||
ci.value !== ci.o_value ||
|
|
||||||
ci.deleted !== ci.o_deleted;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasChanges(hasChanged(editItem));
|
|
||||||
}, [editItem]);
|
|
||||||
|
|
||||||
const handleSave = async (itemToSave: CommandItem) => {
|
|
||||||
try {
|
|
||||||
setFieldErrors(undefined);
|
|
||||||
await validate(validator, itemToSave);
|
|
||||||
onSave(itemToSave);
|
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
|
||||||
} finally {
|
|
||||||
// setHasChanges(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
await handleSave(editItem);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { send: executeCommand } = useRequest(
|
|
||||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
|
||||||
{ immediate: false }
|
|
||||||
)
|
|
||||||
.onSuccess(() => {
|
|
||||||
toast.success(LL.EXECUTE_COMMAND_SENT());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const execute = async () => {
|
|
||||||
await executeCommand(editItem.name);
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = () => {
|
|
||||||
onSave({ ...editItem, deleted: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (
|
|
||||||
_event: React.SyntheticEvent,
|
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
|
||||||
{LL.COMMAND(1)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="cmd"
|
|
||||||
label={LL.COMMAND(0)}
|
|
||||||
multiline
|
|
||||||
fullWidth
|
|
||||||
value={editItem.cmd}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
name="value"
|
|
||||||
label={LL.VALUE(0)}
|
|
||||||
multiline
|
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
|
||||||
value={editItem.value}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="name"
|
|
||||||
label={LL.NAME(0)}
|
|
||||||
value={editItem.name}
|
|
||||||
fullWidth
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
</DialogContent>
|
|
||||||
|
|
||||||
<DialogActions>
|
|
||||||
{!creating && (
|
|
||||||
<Box sx={{ flexGrow: 1 }}>
|
|
||||||
<Button
|
|
||||||
startIcon={<RemoveIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="warning"
|
|
||||||
onClick={remove}
|
|
||||||
>
|
|
||||||
{LL.REMOVE()}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{hasChanges && (
|
|
||||||
<Button
|
|
||||||
startIcon={creating ? <AddIcon /> : <DoneIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={save}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!creating && !hasChanges && editItem.cmd !== '' && (
|
|
||||||
<Button
|
|
||||||
startIcon={<PlayArrowIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={execute}
|
|
||||||
color="success"
|
|
||||||
>
|
|
||||||
{LL.EXECUTE()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CommandsDialog;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
|
|
||||||
@@ -57,18 +57,20 @@ const CustomEntities = () => {
|
|||||||
initialData: []
|
initialData: []
|
||||||
});
|
});
|
||||||
|
|
||||||
useInterval(() => {
|
const intervalCallback = useCallback(() => {
|
||||||
if (!dialogOpen && !numChanges) {
|
if (!dialogOpen && !numChanges) {
|
||||||
void fetchEntities();
|
void fetchEntities();
|
||||||
}
|
}
|
||||||
});
|
}, [dialogOpen, numChanges, fetchEntities]);
|
||||||
|
|
||||||
|
useInterval(intervalCallback);
|
||||||
|
|
||||||
const { send: writeEntities } = useRequest(
|
const { send: writeEntities } = useRequest(
|
||||||
(data: Entities) => writeCustomEntities(data),
|
(data: Entities) => writeCustomEntities(data),
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasEntityChanged = (ei: EntityItem) => {
|
const hasEntityChanged = useCallback((ei: EntityItem) => {
|
||||||
return (
|
return (
|
||||||
ei.id !== ei.o_id ||
|
ei.id !== ei.o_id ||
|
||||||
ei.ram !== ei.o_ram ||
|
ei.ram !== ei.o_ram ||
|
||||||
@@ -84,9 +86,11 @@ const CustomEntities = () => {
|
|||||||
ei.deleted !== ei.o_deleted ||
|
ei.deleted !== ei.o_deleted ||
|
||||||
(ei.value || '') !== (ei.o_value || '')
|
(ei.value || '') !== (ei.o_value || '')
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const entity_theme = useTheme({
|
const entity_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(60px, 1fr)) minmax(80px, auto) 80px 80px 80px 120px;
|
||||||
`,
|
`,
|
||||||
@@ -136,9 +140,11 @@ const CustomEntities = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const saveEntities = async () => {
|
const saveEntities = useCallback(async () => {
|
||||||
await writeEntities({
|
await writeEntities({
|
||||||
entities: entities
|
entities: entities
|
||||||
.filter((ei: EntityItem) => !ei.deleted)
|
.filter((ei: EntityItem) => !ei.deleted)
|
||||||
@@ -167,25 +173,26 @@ const CustomEntities = () => {
|
|||||||
await fetchEntities();
|
await fetchEntities();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
};
|
}, [entities, writeEntities, LL, fetchEntities]);
|
||||||
|
|
||||||
const editEntityItem = (ei: EntityItem) => {
|
const editEntityItem = useCallback((ei: EntityItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setSelectedEntityItem(ei);
|
setSelectedEntityItem(ei);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = useCallback(() => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogCancel = async () => {
|
const onDialogCancel = useCallback(async () => {
|
||||||
await fetchEntities().then(() => {
|
await fetchEntities().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
};
|
}, [fetchEntities]);
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: EntityItem) => {
|
const onDialogSave = useCallback(
|
||||||
|
(updatedItem: EntityItem) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||||
const new_data = creating
|
const new_data = creating
|
||||||
@@ -199,9 +206,11 @@ const CustomEntities = () => {
|
|||||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[creating, hasEntityChanged]
|
||||||
|
);
|
||||||
|
|
||||||
const onDialogDup = (item: EntityItem) => {
|
const onDialogDup = useCallback((item: EntityItem) => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
setSelectedEntityItem({
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
@@ -219,9 +228,9 @@ const CustomEntities = () => {
|
|||||||
value: item.value
|
value: item.value
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const addEntityItem = () => {
|
const addEntityItem = useCallback(() => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setSelectedEntityItem({
|
setSelectedEntityItem({
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
@@ -239,27 +248,30 @@ const CustomEntities = () => {
|
|||||||
value: ''
|
value: ''
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const formatValue = (value: unknown, uom: number) => {
|
const formatValue = useCallback((value: unknown, uom: number) => {
|
||||||
return value === undefined
|
return value === undefined
|
||||||
? ''
|
? ''
|
||||||
: typeof value === 'number'
|
: typeof value === 'number'
|
||||||
? new Intl.NumberFormat().format(value) +
|
? new Intl.NumberFormat().format(value) +
|
||||||
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
(uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`)
|
||||||
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
: `${value as string}${uom === 0 ? '' : ` ${DeviceValueUOM_s[uom]}`}`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const showHex = (value: number, digit: number) => {
|
const showHex = useCallback((value: number, digit: number) => {
|
||||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const filteredAndSortedEntities =
|
const filteredAndSortedEntities = useMemo(
|
||||||
|
() =>
|
||||||
entities
|
entities
|
||||||
?.filter((ei: EntityItem) => !ei.deleted)
|
?.filter((ei: EntityItem) => !ei.deleted)
|
||||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
||||||
|
[entities]
|
||||||
|
);
|
||||||
|
|
||||||
const renderEntity = () => {
|
const renderEntity = useCallback(() => {
|
||||||
if (!entities) {
|
if (!entities) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||||
@@ -316,7 +328,17 @@ const CustomEntities = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
entities,
|
||||||
|
error,
|
||||||
|
fetchEntities,
|
||||||
|
entity_theme,
|
||||||
|
editEntityItem,
|
||||||
|
LL,
|
||||||
|
filteredAndSortedEntities,
|
||||||
|
showHex,
|
||||||
|
formatValue
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -23,12 +23,12 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
import { DeviceValueType, DeviceValueTypeNames, DeviceValueUOM_s } from './types';
|
||||||
import type { EntityItem } from './types';
|
import type { EntityItem } from './types';
|
||||||
@@ -68,10 +68,14 @@ const CustomEntitiesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
const [editItem, setEditItem] = useState<EntityItem>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const updateFormValue = updateValue(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue(
|
||||||
setEditItem as unknown as React.Dispatch<
|
setEditItem as unknown as React.Dispatch<
|
||||||
React.SetStateAction<Record<string, unknown>>
|
React.SetStateAction<Record<string, unknown>>
|
||||||
>
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -101,16 +105,16 @@ const CustomEntitiesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = useCallback(
|
||||||
_event: React.SyntheticEvent,
|
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
const save = async () => {
|
const save = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -134,21 +138,27 @@ const CustomEntitiesDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [validator, editItem, onSave]);
|
||||||
|
|
||||||
const remove = () => {
|
const remove = useCallback(() => {
|
||||||
onSave({ ...editItem, deleted: true });
|
const itemWithDeleted = { ...editItem, deleted: true };
|
||||||
};
|
onSave(itemWithDeleted);
|
||||||
|
}, [editItem, onSave]);
|
||||||
|
|
||||||
const dup = () => {
|
const dup = useCallback(() => {
|
||||||
onDup(editItem);
|
onDup(editItem);
|
||||||
};
|
}, [editItem, onDup]);
|
||||||
|
|
||||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
// Memoize UOM menu items to avoid recreating on every render
|
||||||
|
const uomMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
DeviceValueUOM_s.map((val, i) => (
|
||||||
<MenuItem key={val} value={i}>
|
<MenuItem key={val} value={i}>
|
||||||
{val}
|
{val}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useBlocker, useLocation } from 'react-router';
|
import { useBlocker, useLocation } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
@@ -45,7 +46,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -171,7 +171,9 @@ const Customizations = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const entities_theme = useTheme({
|
const entities_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
--data-table-library_grid-template-columns: 156px repeat(1, minmax(80px, 1fr)) 45px minmax(45px, auto) minmax(120px, auto);
|
||||||
`,
|
`,
|
||||||
@@ -234,7 +236,9 @@ const Customizations = () => {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
function hasEntityChanged(de: DeviceEntity) {
|
||||||
return (
|
return (
|
||||||
@@ -283,11 +287,12 @@ const Customizations = () => {
|
|||||||
return value as string;
|
return value as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCommand = (de: DeviceEntity) => {
|
const isCommand = useCallback((de: DeviceEntity) => {
|
||||||
return de.n && de.n[0] === '!';
|
return de.n && de.n[0] === '!';
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const formatName = (de: DeviceEntity, withShortname: boolean) => {
|
const formatName = useCallback(
|
||||||
|
(de: DeviceEntity, withShortname: boolean) => {
|
||||||
let name: string;
|
let name: string;
|
||||||
if (isCommand(de)) {
|
if (isCommand(de)) {
|
||||||
name = de.t
|
name = de.t
|
||||||
@@ -299,7 +304,9 @@ const Customizations = () => {
|
|||||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||||
}
|
}
|
||||||
return withShortname ? `${name} ${de.id}` : name;
|
return withShortname ? `${name} ${de.id}` : name;
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
const getMaskNumber = (newMask: string[]) => {
|
||||||
let new_mask = 0;
|
let new_mask = 0;
|
||||||
@@ -329,11 +336,15 @@ const Customizations = () => {
|
|||||||
return new_masks;
|
return new_masks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter_entity = (de: DeviceEntity) =>
|
const filter_entity = useCallback(
|
||||||
|
(de: DeviceEntity) =>
|
||||||
(de.m & selectedFilters || !selectedFilters) &&
|
(de.m & selectedFilters || !selectedFilters) &&
|
||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
||||||
|
[selectedFilters, search, formatName]
|
||||||
|
);
|
||||||
|
|
||||||
const maskDisabled = (set: boolean) => {
|
const maskDisabled = useCallback(
|
||||||
|
(set: boolean) => {
|
||||||
setDeviceEntities((prev) =>
|
setDeviceEntities((prev) =>
|
||||||
prev.map((de) => {
|
prev.map((de) => {
|
||||||
if (filter_entity(de)) {
|
if (filter_entity(de)) {
|
||||||
@@ -347,9 +358,11 @@ const Customizations = () => {
|
|||||||
return de;
|
return de;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[filter_entity]
|
||||||
|
);
|
||||||
|
|
||||||
const resetCustomization = async () => {
|
const resetCustomization = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await sendResetCustomizations();
|
await sendResetCustomizations();
|
||||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||||
@@ -359,27 +372,30 @@ const Customizations = () => {
|
|||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
}
|
}
|
||||||
};
|
}, [sendResetCustomizations, LL]);
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||||
setDeviceEntities(
|
setDeviceEntities(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
prev?.map((de) =>
|
prev?.map((de) =>
|
||||||
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
de.id === updatedItem.id ? { ...de, ...updatedItem } : de
|
||||||
) ?? []
|
) ?? []
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: DeviceEntity) => {
|
const onDialogSave = useCallback(
|
||||||
|
(updatedItem: DeviceEntity) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
updateDeviceEntity(updatedItem);
|
updateDeviceEntity(updatedItem);
|
||||||
};
|
},
|
||||||
|
[updateDeviceEntity]
|
||||||
|
);
|
||||||
|
|
||||||
const editDeviceEntity = (de: DeviceEntity) => {
|
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
||||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -390,9 +406,9 @@ const Customizations = () => {
|
|||||||
|
|
||||||
setSelectedDeviceEntity(de);
|
setSelectedDeviceEntity(de);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const saveCustomization = async () => {
|
const saveCustomization = useCallback(async () => {
|
||||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -425,9 +441,9 @@ const Customizations = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setOriginalSettings(deviceEntities);
|
setOriginalSettings(deviceEntities);
|
||||||
});
|
});
|
||||||
};
|
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
||||||
|
|
||||||
const renameDevice = async () => {
|
const renameDevice = useCallback(async () => {
|
||||||
await sendDeviceName({
|
await sendDeviceName({
|
||||||
id: selectedDevice,
|
id: selectedDevice,
|
||||||
name: selectedDeviceName,
|
name: selectedDeviceName,
|
||||||
@@ -443,7 +459,14 @@ const Customizations = () => {
|
|||||||
setRename(false);
|
setRename(false);
|
||||||
await fetchCoreData();
|
await fetchCoreData();
|
||||||
});
|
});
|
||||||
};
|
}, [
|
||||||
|
selectedDevice,
|
||||||
|
selectedDeviceName,
|
||||||
|
selectedDeviceBrand,
|
||||||
|
sendDeviceName,
|
||||||
|
LL,
|
||||||
|
fetchCoreData
|
||||||
|
]);
|
||||||
|
|
||||||
const renderDeviceList = () => (
|
const renderDeviceList = () => (
|
||||||
<>
|
<>
|
||||||
@@ -489,8 +512,7 @@ const Customizations = () => {
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
{devices.devices.map(
|
{devices.devices.map(
|
||||||
(device: Device) =>
|
(device: Device) =>
|
||||||
device.id < 90 &&
|
device.id < 90 && (
|
||||||
device.e !== 0 && (
|
|
||||||
<MenuItem key={device.id} value={device.id}>
|
<MenuItem key={device.id} value={device.id}>
|
||||||
{device.n} ({device.tn})
|
{device.n} ({device.tn})
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
@@ -540,7 +562,10 @@ const Customizations = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
const filteredEntities = useMemo(
|
||||||
|
() => deviceEntities.filter((de) => filter_entity(de)),
|
||||||
|
[deviceEntities, filter_entity]
|
||||||
|
);
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
const renderDeviceData = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
@@ -57,16 +57,23 @@ const CustomizationsDialog = ({
|
|||||||
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
const [editItem, setEditItem] = useState<DeviceEntity>(selectedItem);
|
||||||
const [error, setError] = useState<boolean>(false);
|
const [error, setError] = useState<boolean>(false);
|
||||||
|
|
||||||
const updateFormValue = updateValue(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue(
|
||||||
setEditItem as unknown as React.Dispatch<
|
setEditItem as unknown as React.Dispatch<
|
||||||
React.SetStateAction<Record<string, unknown>>
|
React.SetStateAction<Record<string, unknown>>
|
||||||
>
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isWriteableNumber =
|
const isWriteableNumber = useMemo(
|
||||||
|
() =>
|
||||||
typeof editItem.v === 'number' &&
|
typeof editItem.v === 'number' &&
|
||||||
editItem.w &&
|
editItem.w &&
|
||||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
||||||
|
[editItem.v, editItem.w, editItem.m]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -75,16 +82,16 @@ const CustomizationsDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = useCallback(
|
||||||
_event: React.SyntheticEvent,
|
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
const save = () => {
|
const save = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
isWriteableNumber &&
|
isWriteableNumber &&
|
||||||
editItem.mi &&
|
editItem.mi &&
|
||||||
@@ -95,31 +102,34 @@ const CustomizationsDialog = ({
|
|||||||
} else {
|
} else {
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
}
|
}
|
||||||
};
|
}, [isWriteableNumber, editItem, onSave]);
|
||||||
|
|
||||||
const updateDeviceEntity = (updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
||||||
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
setEditItem((prev) => ({ ...prev, m: updatedItem.m }));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.ENTITY()}`, [LL]);
|
||||||
|
|
||||||
|
const writeableIcon = useMemo(
|
||||||
|
() =>
|
||||||
|
editItem.w ? (
|
||||||
|
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
||||||
|
) : (
|
||||||
|
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
||||||
|
),
|
||||||
|
[editItem.w]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
<LabelValue label={LL.ID_OF(LL.ENTITY())} value={editItem.id} />
|
||||||
<LabelValue
|
<LabelValue
|
||||||
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
label={`${LL.DEFAULT(1)} ${LL.ENTITY_NAME(1)}`}
|
||||||
value={editItem.n}
|
value={editItem.n}
|
||||||
/>
|
/>
|
||||||
<LabelValue
|
<LabelValue label={LL.WRITEABLE()} value={writeableIcon} />
|
||||||
label={LL.WRITEABLE()}
|
|
||||||
value={
|
|
||||||
editItem.w ? (
|
|
||||||
<DoneIcon color="success" sx={{ fontSize: ICON_SIZE }} />
|
|
||||||
) : (
|
|
||||||
<CloseIcon color="error" sx={{ fontSize: ICON_SIZE }} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 1, mb: 2 }}>
|
<Box sx={{ mt: 1, mb: 2 }}>
|
||||||
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
<EntityMaskToggle onUpdate={updateDeviceEntity} de={editItem} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { memo, useContext, useEffect, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { IconContext } from 'react-icons/lib';
|
import { IconContext } from 'react-icons/lib';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
|
import HelpOutlineIcon from '@mui/icons-material/HelpOutlined';
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||||
import {
|
import {
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { useInterval, usePersistState } from 'utils';
|
import { useInterval, usePersistState } from 'utils';
|
||||||
@@ -76,28 +75,32 @@ const Dashboard = memo(() => {
|
|||||||
{
|
{
|
||||||
immediate: false
|
immediate: false
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.onSuccess(() => {
|
|
||||||
toast.success(LL.WRITE_CMD_SENT());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
const deviceValueDialogSave = useCallback(
|
||||||
|
async (devicevalue: DeviceValue) => {
|
||||||
if (!selectedDashboardItem) {
|
if (!selectedDashboardItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
|
|
||||||
if (devicevalue.v !== undefined) {
|
|
||||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
|
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
||||||
}
|
.then(() => {
|
||||||
|
toast.success(LL.WRITE_CMD_SENT());
|
||||||
|
})
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
setDeviceValueDialogOpen(false);
|
setDeviceValueDialogOpen(false);
|
||||||
setSelectedDashboardItem(undefined);
|
setSelectedDashboardItem(undefined);
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
[selectedDashboardItem, sendDeviceValue, LL]
|
||||||
|
);
|
||||||
|
|
||||||
const dashboard_theme = useTheme({
|
const dashboard_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||||
`,
|
`,
|
||||||
@@ -125,7 +128,9 @@ const Dashboard = memo(() => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const tree = useTree(
|
const tree = useTree(
|
||||||
{ nodes: [...data.nodes] },
|
{ nodes: [...data.nodes] },
|
||||||
@@ -159,14 +164,19 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const nodeIds = useMemo(
|
||||||
|
() => data.nodes.map((item: DashboardItem) => item.id),
|
||||||
|
[data.nodes]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nodeIds = data.nodes.map((item: DashboardItem) => item.id);
|
|
||||||
showAll
|
showAll
|
||||||
? tree.fns.onAddAll(nodeIds) // expand tree
|
? tree.fns.onAddAll(nodeIds) // expand tree
|
||||||
: tree.fns.onRemoveAll(); // collapse tree
|
: tree.fns.onRemoveAll(); // collapse tree
|
||||||
}, [parentNodes]);
|
}, [parentNodes]);
|
||||||
|
|
||||||
const showType = (n?: string, t?: number) => {
|
const showType = useCallback(
|
||||||
|
(n?: string, t?: number) => {
|
||||||
// if we have a name show it
|
// if we have a name show it
|
||||||
if (n) {
|
if (n) {
|
||||||
return n;
|
return n;
|
||||||
@@ -180,8 +190,6 @@ const Dashboard = memo(() => {
|
|||||||
return LL.ANALOG_SENSORS();
|
return LL.ANALOG_SENSORS();
|
||||||
case DeviceType.TEMPERATURESENSOR:
|
case DeviceType.TEMPERATURESENSOR:
|
||||||
return LL.TEMP_SENSORS();
|
return LL.TEMP_SENSORS();
|
||||||
case DeviceType.COMMAND:
|
|
||||||
return LL.COMMANDS();
|
|
||||||
case DeviceType.SCHEDULER:
|
case DeviceType.SCHEDULER:
|
||||||
return LL.SCHEDULER();
|
return LL.SCHEDULER();
|
||||||
default:
|
default:
|
||||||
@@ -189,9 +197,12 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const showName = (di: DashboardItem) => {
|
const showName = useCallback(
|
||||||
|
(di: DashboardItem) => {
|
||||||
if (di.id < 100) {
|
if (di.id < 100) {
|
||||||
// if its a device (parent node) and has entities
|
// if its a device (parent node) and has entities
|
||||||
if (di.nodes?.length) {
|
if (di.nodes?.length) {
|
||||||
@@ -208,22 +219,24 @@ const Dashboard = memo(() => {
|
|||||||
return <span>{di.dv.id.slice(2)}</span>;
|
return <span>{di.dv.id.slice(2)}</span>;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
},
|
||||||
|
[showType]
|
||||||
|
);
|
||||||
|
|
||||||
const hasMask = (id: string, mask: number) =>
|
const hasMask = useCallback(
|
||||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const editDashboardValue = (di: DashboardItem) => {
|
const editDashboardValue = useCallback(
|
||||||
// don't execute on parent nodes
|
(di: DashboardItem) => {
|
||||||
if (!me.admin || di.id <= 99) {
|
if (me.admin && di.dv?.c) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (di.dv?.c) {
|
|
||||||
setSelectedDashboardItem(di);
|
setSelectedDashboardItem(di);
|
||||||
setDeviceValueDialogOpen(true);
|
setDeviceValueDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[me.admin]
|
||||||
|
);
|
||||||
|
|
||||||
const handleShowAll = (
|
const handleShowAll = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
@@ -235,9 +248,10 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFavEntities = data.nodes.filter(
|
const hasFavEntities = useMemo(
|
||||||
(item: DashboardItem) => item.id <= 90
|
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
||||||
).length;
|
[data.nodes]
|
||||||
|
);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -330,34 +344,20 @@ const Dashboard = memo(() => {
|
|||||||
<Cell>
|
<Cell>
|
||||||
{me.admin &&
|
{me.admin &&
|
||||||
di.dv?.c &&
|
di.dv?.c &&
|
||||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) &&
|
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||||
(di.dv.v === '' || di.dv.v === undefined ? (
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={LL.RUN_COMMAND()}
|
|
||||||
onClick={() => editDashboardValue(di)}
|
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
>
|
|
||||||
<PlayArrowIcon
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontSize: 16 }}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
) : (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={
|
aria-label={
|
||||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||||
}
|
}
|
||||||
onClick={() => editDashboardValue(di)}
|
onClick={() => editDashboardValue(di)}
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
<EditIcon
|
<EditIcon
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ fontSize: 16 }}
|
sx={{ fontSize: 16 }}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
))}
|
)}
|
||||||
</Cell>
|
</Cell>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const deviceIconLookup: Record<DeviceType, IconType | null> = {
|
|||||||
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
[DeviceType.CUSTOM]: MdPlaylistAdd,
|
||||||
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
[DeviceType.UNKNOWN]: MdOutlineSensors,
|
||||||
[DeviceType.SYSTEM]: null,
|
[DeviceType.SYSTEM]: null,
|
||||||
[DeviceType.COMMAND]: MdPlaylistAdd,
|
|
||||||
[DeviceType.SCHEDULER]: MdMoreTime,
|
[DeviceType.SCHEDULER]: MdMoreTime,
|
||||||
[DeviceType.GENERIC]: MdOutlineSensors,
|
[DeviceType.GENERIC]: MdOutlineSensors,
|
||||||
[DeviceType.VENTILATION]: PiFan
|
[DeviceType.VENTILATION]: PiFan
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
useState
|
useState
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { IconContext } from 'react-icons';
|
import { IconContext } from 'react-icons';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
import CommentsDisabledOutlinedIcon from '@mui/icons-material/CommentsDisabledOutlined';
|
||||||
import ConstructionIcon from '@mui/icons-material/Construction';
|
import ConstructionIcon from '@mui/icons-material/Construction';
|
||||||
@@ -63,7 +65,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
@@ -132,15 +133,17 @@ const Devices = memo(() => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const leftOffset = () => {
|
const leftOffset = useCallback(() => {
|
||||||
const devicesWindow = document.getElementById('devices-window');
|
const devicesWindow = document.getElementById('devices-window');
|
||||||
if (!devicesWindow) return 0;
|
if (!devicesWindow) return 0;
|
||||||
const { left, right } = devicesWindow.getBoundingClientRect();
|
const { left, right } = devicesWindow.getBoundingClientRect();
|
||||||
if (!left || !right) return 0;
|
if (!left || !right) return 0;
|
||||||
return left + (right - left < 400 ? 0 : 200);
|
return left + (right - left < 400 ? 0 : 200);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const common_theme = useTheme({
|
const common_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
@@ -162,9 +165,13 @@ const Devices = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const device_theme = useTheme([
|
const device_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme([
|
||||||
common_theme,
|
common_theme,
|
||||||
{
|
{
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
@@ -189,9 +196,13 @@ const Devices = memo(() => {
|
|||||||
},
|
},
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
]);
|
]),
|
||||||
|
[common_theme]
|
||||||
|
);
|
||||||
|
|
||||||
const data_theme = useTheme([
|
const data_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme([
|
||||||
common_theme,
|
common_theme,
|
||||||
{
|
{
|
||||||
Table: `
|
Table: `
|
||||||
@@ -233,7 +244,9 @@ const Devices = memo(() => {
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
]);
|
]),
|
||||||
|
[common_theme]
|
||||||
|
);
|
||||||
|
|
||||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
@@ -332,8 +345,10 @@ const Devices = memo(() => {
|
|||||||
return sc;
|
return sc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMask = (id: string, mask: number) =>
|
const hasMask = useCallback(
|
||||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDownloadCsv = () => {
|
const handleDownloadCsv = () => {
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
const deviceIndex = coreData.devices.findIndex(
|
||||||
@@ -558,26 +573,16 @@ const Devices = memo(() => {
|
|||||||
<CircularProgress sx={{ margin: 1 }} size={18} />
|
<CircularProgress sx={{ margin: 1 }} size={18} />
|
||||||
)}
|
)}
|
||||||
{tableList.map((device: Device) => (
|
{tableList.map((device: Device) => (
|
||||||
<Row key={device.id} item={device} disabled={device.e === 0}>
|
<Row key={device.id} item={device}>
|
||||||
<Cell>
|
<Cell>
|
||||||
<DeviceIcon type_id={device.t} />
|
<DeviceIcon type_id={device.t} />
|
||||||
|
|
||||||
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
|
|
||||||
{device.n}
|
{device.n}
|
||||||
</span>
|
|
||||||
{device.e !== 0 && (
|
|
||||||
<span style={{ color: 'lightblue' }}>
|
<span style={{ color: 'lightblue' }}>
|
||||||
({device.e})
|
({device.e})
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</Cell>
|
|
||||||
<Cell stiff>
|
|
||||||
<ButtonTooltip
|
|
||||||
title={`DeviceID: 0x${('00' + device.d.toString(16).toUpperCase()).slice(-2)}, ProductID: ${device.p}`}
|
|
||||||
>
|
|
||||||
<span>{device.tn}</span>
|
|
||||||
</ButtonTooltip>
|
|
||||||
</Cell>
|
</Cell>
|
||||||
|
<Cell stiff>{device.tn}</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
</Body>
|
</Body>
|
||||||
@@ -602,12 +607,13 @@ const Devices = memo(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDeviceValue = (dv: DeviceValue) => {
|
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
||||||
setSelectedDeviceValue(dv);
|
setSelectedDeviceValue(dv);
|
||||||
setDeviceValueDialogOpen(true);
|
setDeviceValueDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const renderNameCell = (dv: DeviceValue) => (
|
const renderNameCell = useCallback(
|
||||||
|
(dv: DeviceValue) => (
|
||||||
<>
|
<>
|
||||||
{dv.id.slice(2)}
|
{dv.id.slice(2)}
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||||
@@ -620,17 +626,22 @@ const Devices = memo(() => {
|
|||||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
),
|
||||||
|
[hasMask]
|
||||||
);
|
);
|
||||||
|
|
||||||
const shown_data = onlyFav
|
const shown_data = useMemo(() => {
|
||||||
? deviceData.nodes.filter(
|
if (onlyFav) {
|
||||||
|
return deviceData.nodes.filter(
|
||||||
(dv: DeviceValue) =>
|
(dv: DeviceValue) =>
|
||||||
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) &&
|
||||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
);
|
||||||
: deviceData.nodes.filter((dv: DeviceValue) =>
|
}
|
||||||
|
return deviceData.nodes.filter((dv: DeviceValue) =>
|
||||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
}, [deviceData.nodes, onlyFav, search]);
|
||||||
|
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
const deviceIndex = coreData.devices.findIndex(
|
||||||
(d: Device) => d.id === device_select.state.id
|
(d: Device) => d.id === device_select.state.id
|
||||||
@@ -787,7 +798,6 @@ const Devices = memo(() => {
|
|||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => showDeviceValue(dv)}
|
onClick={() => showDeviceValue(dv)}
|
||||||
style={{ backgroundColor: 'transparent' }}
|
|
||||||
>
|
>
|
||||||
{dv.v === '' ? (
|
{dv.v === '' ? (
|
||||||
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -19,16 +18,13 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { callAction } from '@/api/app';
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
import { useRequest } from 'alova/client';
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||||
import type { DeviceValue } from './types';
|
import type { DeviceValue } from './types';
|
||||||
@@ -56,7 +52,7 @@ const DevicesDialog = ({
|
|||||||
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
const [editItem, setEditItem] = useState<DeviceValue>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = updateValue(setEditItem);
|
const updateFormValue = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -65,32 +61,18 @@ const DevicesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const { send: executeCommand } = useRequest(
|
const save = useCallback(async () => {
|
||||||
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
|
||||||
{ immediate: false }
|
|
||||||
)
|
|
||||||
.onSuccess(() => {
|
|
||||||
toast.success(LL.EXECUTE_COMMAND_SENT());
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const doAction = async () => {
|
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
if (editItem.v === undefined && editItem.c !== undefined) {
|
|
||||||
await executeCommand(editItem.c);
|
|
||||||
} else {
|
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
}
|
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [validator, editItem, onSave]);
|
||||||
|
|
||||||
const setUom = (uom?: DeviceValueUOM) => {
|
const setUom = useCallback(
|
||||||
|
(uom?: DeviceValueUOM) => {
|
||||||
if (uom === undefined) {
|
if (uom === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,9 +86,11 @@ const DevicesDialog = ({
|
|||||||
default:
|
default:
|
||||||
return DeviceValueUOM_s[uom];
|
return DeviceValueUOM_s[uom];
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const showHelperText = (dv: DeviceValue) => {
|
const showHelperText = useCallback((dv: DeviceValue) => {
|
||||||
if (dv.h) return dv.h;
|
if (dv.h) return dv.h;
|
||||||
if (dv.l) return dv.l.join(' | ');
|
if (dv.l) return dv.l.join(' | ');
|
||||||
if (dv.m !== undefined && dv.x !== undefined) {
|
if (dv.m !== undefined && dv.x !== undefined) {
|
||||||
@@ -117,21 +101,26 @@ const DevicesDialog = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const isCommand =
|
const isCommand = useMemo(
|
||||||
(selectedItem.v === '' || selectedItem.v === undefined) &&
|
() => selectedItem.v === '' && selectedItem.c,
|
||||||
Boolean(selectedItem.c);
|
[selectedItem.v, selectedItem.c]
|
||||||
const isSchedulerImmediate = selectedItem.v === undefined;
|
);
|
||||||
const dialogTitle = isCommand
|
|
||||||
? isSchedulerImmediate
|
const dialogTitle = useMemo(() => {
|
||||||
? LL.EXECUTE() + ' ' + LL.SCHEDULE(0)
|
if (isCommand) return LL.RUN_COMMAND();
|
||||||
: LL.RUN_COMMAND()
|
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||||
: writeable
|
}, [isCommand, writeable, LL]);
|
||||||
? LL.CHANGE_VALUE()
|
|
||||||
: LL.VALUE(0);
|
const buttonLabel = useMemo(() => {
|
||||||
const buttonLabel = isCommand ? LL.EXECUTE() : LL.UPDATE();
|
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||||
const helperText = showHelperText(editItem);
|
}, [isCommand, LL]);
|
||||||
|
|
||||||
|
const helperText = useMemo(
|
||||||
|
() => showHelperText(editItem),
|
||||||
|
[editItem, showHelperText]
|
||||||
|
);
|
||||||
|
|
||||||
const valueLabel = LL.VALUE(0);
|
const valueLabel = LL.VALUE(0);
|
||||||
|
|
||||||
@@ -142,7 +131,6 @@ const DevicesDialog = ({
|
|||||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||||
{editItem.id.slice(2)}
|
{editItem.id.slice(2)}
|
||||||
</Typography>
|
</Typography>
|
||||||
{!isSchedulerImmediate && (
|
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
{editItem.l ? (
|
{editItem.l ? (
|
||||||
@@ -204,7 +192,6 @@ const DevicesDialog = ({
|
|||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -226,12 +213,10 @@ const DevicesDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={
|
startIcon={<WarningIcon color="warning" />}
|
||||||
isCommand ? <PlayArrowIcon /> : <WarningIcon color="warning" />
|
|
||||||
}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={doAction}
|
onClick={save}
|
||||||
color={isCommand ? 'success' : 'primary'}
|
color="primary"
|
||||||
>
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
import { ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||||
|
|
||||||
import OptionIcon from './OptionIcon';
|
import OptionIcon from './OptionIcon';
|
||||||
@@ -9,6 +11,7 @@ interface EntityMaskToggleProps {
|
|||||||
de: DeviceEntity;
|
de: DeviceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Available mask values
|
||||||
const MASK_VALUES = [
|
const MASK_VALUES = [
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
DeviceEntityMask.DV_WEB_EXCLUDE, // 1
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE, // 2
|
||||||
@@ -17,21 +20,35 @@ const MASK_VALUES = [
|
|||||||
DeviceEntityMask.DV_DELETED // 128
|
DeviceEntityMask.DV_DELETED // 128
|
||||||
];
|
];
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]): number =>
|
/**
|
||||||
newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
* Converts an array of mask strings to a bitmask number
|
||||||
|
*/
|
||||||
|
const getMaskNumber = (newMask: string[]): number => {
|
||||||
|
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
||||||
|
};
|
||||||
|
|
||||||
const getMaskString = (mask: number): string[] =>
|
/**
|
||||||
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
* 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)
|
String(value)
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a specific mask bit is set
|
||||||
|
*/
|
||||||
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
const hasMask = (mask: number, flag: number): boolean => (mask & flag) === flag;
|
||||||
|
|
||||||
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
||||||
const handleChange = (_event: unknown, mask: string[]) => {
|
const handleChange = useCallback(
|
||||||
|
(_event: unknown, mask: string[]) => {
|
||||||
|
// Convert selected masks to a number
|
||||||
const newMask = getMaskNumber(mask);
|
const newMask = getMaskNumber(mask);
|
||||||
const updatedDe = { ...de };
|
const updatedDe = { ...de };
|
||||||
|
|
||||||
|
// Apply business logic for mask interactions
|
||||||
// If entity has no name and is set to readonly, also exclude from web
|
// If entity has no name and is set to readonly, also exclude from web
|
||||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
@@ -45,67 +62,81 @@ const EntityMaskToggle = ({ onUpdate, de }: EntityMaskToggleProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(updatedDe);
|
onUpdate(updatedDe);
|
||||||
};
|
},
|
||||||
|
[de, onUpdate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize mask string value
|
||||||
|
const maskStringValue = useMemo(() => getMaskString(de.m), [de.m]);
|
||||||
|
|
||||||
|
// Memoize disabled states
|
||||||
|
const isFavoriteDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED) ||
|
||||||
|
de.n === undefined,
|
||||||
|
[de.m, de.n]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReadonlyDisabled = useMemo(
|
||||||
|
() =>
|
||||||
|
!de.w ||
|
||||||
|
hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE),
|
||||||
|
[de.w, de.m]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isApiMqttExcludeDisabled = useMemo(
|
||||||
|
() => de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||||
|
[de.n, de.m]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isWebExcludeDisabled = useMemo(
|
||||||
|
() => de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||||
|
[de.n, de.m]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize mask flag checks
|
||||||
|
const isFavoriteSet = useMemo(
|
||||||
|
() => hasMask(de.m, DeviceEntityMask.DV_FAVORITE),
|
||||||
|
[de.m]
|
||||||
|
);
|
||||||
|
const isReadonlySet = useMemo(
|
||||||
|
() => hasMask(de.m, DeviceEntityMask.DV_READONLY),
|
||||||
|
[de.m]
|
||||||
|
);
|
||||||
|
const isApiMqttExcludeSet = useMemo(
|
||||||
|
() => hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE),
|
||||||
|
[de.m]
|
||||||
|
);
|
||||||
|
const isWebExcludeSet = useMemo(
|
||||||
|
() => hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE),
|
||||||
|
[de.m]
|
||||||
|
);
|
||||||
|
const isDeletedSet = useMemo(
|
||||||
|
() => hasMask(de.m, DeviceEntityMask.DV_DELETED),
|
||||||
|
[de.m]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
value={getMaskString(de.m)}
|
value={maskStringValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
||||||
value="8"
|
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
||||||
disabled={
|
|
||||||
hasMask(
|
|
||||||
de.m,
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_DELETED
|
|
||||||
) || de.n === undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<OptionIcon
|
|
||||||
type="favorite"
|
|
||||||
isSet={hasMask(de.m, DeviceEntityMask.DV_FAVORITE)}
|
|
||||||
/>
|
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton value="4" disabled={isReadonlyDisabled}>
|
||||||
value="4"
|
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
||||||
disabled={
|
|
||||||
!de.w ||
|
|
||||||
hasMask(
|
|
||||||
de.m,
|
|
||||||
DeviceEntityMask.DV_WEB_EXCLUDE | DeviceEntityMask.DV_FAVORITE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<OptionIcon
|
|
||||||
type="readonly"
|
|
||||||
isSet={hasMask(de.m, DeviceEntityMask.DV_READONLY)}
|
|
||||||
/>
|
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton value="2" disabled={isApiMqttExcludeDisabled}>
|
||||||
value="2"
|
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
||||||
disabled={de.n === '' || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
|
||||||
>
|
|
||||||
<OptionIcon
|
|
||||||
type="api_mqtt_exclude"
|
|
||||||
isSet={hasMask(de.m, DeviceEntityMask.DV_API_MQTT_EXCLUDE)}
|
|
||||||
/>
|
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton
|
<ToggleButton value="1" disabled={isWebExcludeDisabled}>
|
||||||
value="1"
|
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
||||||
disabled={de.n === undefined || hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
|
||||||
>
|
|
||||||
<OptionIcon
|
|
||||||
type="web_exclude"
|
|
||||||
isSet={hasMask(de.m, DeviceEntityMask.DV_WEB_EXCLUDE)}
|
|
||||||
/>
|
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<ToggleButton value="128">
|
<ToggleButton value="128">
|
||||||
<OptionIcon
|
<OptionIcon type="deleted" isSet={isDeletedSet} />
|
||||||
type="deleted"
|
|
||||||
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
|
||||||
/>
|
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useContext, useState } from 'react';
|
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
import CommentIcon from '@mui/icons-material/CommentTwoTone';
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
@@ -24,7 +25,6 @@ import type { SxProps, Theme } from '@mui/material/styles';
|
|||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
import { SectionContent, useLayoutTitle } from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { saveFile } from 'utils';
|
import { saveFile } from 'utils';
|
||||||
@@ -60,8 +60,6 @@ const AVATAR_STYLES: SxProps<Theme> = {
|
|||||||
bgcolor: '#72caf9'
|
bgcolor: '#72caf9'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYSTEM_INFO_API: APIcall = { device: 'system', cmd: 'info', id: 0 };
|
|
||||||
|
|
||||||
const HelpComponent = () => {
|
const HelpComponent = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.HELP());
|
useLayoutTitle(LL.HELP());
|
||||||
@@ -74,7 +72,12 @@ const HelpComponent = () => {
|
|||||||
});
|
});
|
||||||
const [imgError, setImgError] = useState<boolean>(false);
|
const [imgError, setImgError] = useState<boolean>(false);
|
||||||
|
|
||||||
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
const getCustomSupportMethod = useMemo(
|
||||||
|
() => callAction({ action: 'getCustomSupport' }),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
useRequest(getCustomSupportMethod).onSuccess((event) => {
|
||||||
if (event?.data && Object.keys(event.data).length !== 0) {
|
if (event?.data && Object.keys(event.data).length !== 0) {
|
||||||
const { Support } = event.data as {
|
const { Support } = event.data as {
|
||||||
Support: { img_url?: string; html?: string[] };
|
Support: { img_url?: string; html?: string[] };
|
||||||
@@ -97,7 +100,20 @@ const HelpComponent = () => {
|
|||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
|
|
||||||
const helpLinks: HelpLink[] = [
|
// Optimize API call memoization
|
||||||
|
const apiCall = useMemo(() => ({ device: 'system', cmd: 'info', id: 0 }), []);
|
||||||
|
|
||||||
|
const handleDownloadSystemInfo = useCallback(() => {
|
||||||
|
void sendAPI(apiCall);
|
||||||
|
}, [sendAPI, apiCall]);
|
||||||
|
|
||||||
|
const handleImageError = useCallback(() => {
|
||||||
|
setImgError(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoize help links to prevent recreation on every render
|
||||||
|
const helpLinks: HelpLink[] = useMemo(
|
||||||
|
() => [
|
||||||
{
|
{
|
||||||
href: 'https://emsesp.org',
|
href: 'https://emsesp.org',
|
||||||
icon: <MenuBookIcon />,
|
icon: <MenuBookIcon />,
|
||||||
@@ -113,10 +129,18 @@ const HelpComponent = () => {
|
|||||||
icon: <GitHubIcon />,
|
icon: <GitHubIcon />,
|
||||||
label: () => LL.HELP_INFORMATION_3()
|
label: () => LL.HELP_INFORMATION_3()
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const imageSrc =
|
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
|
||||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
|
||||||
|
// Memoize image source computation
|
||||||
|
const imageSrc = useMemo(
|
||||||
|
() =>
|
||||||
|
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
|
||||||
|
[imgError, customSupport.img_url]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
@@ -133,13 +157,13 @@ const HelpComponent = () => {
|
|||||||
component="img"
|
component="img"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
sx={IMAGE_STYLES}
|
sx={IMAGE_STYLES}
|
||||||
onError={() => setImgError(true)}
|
onError={handleImageError}
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{me?.admin && (
|
{isAdmin && (
|
||||||
<List>
|
<List>
|
||||||
{helpLinks.map(({ href, icon, label }) => (
|
{helpLinks.map(({ href, icon, label }) => (
|
||||||
<ListItem key={href}>
|
<ListItem key={href}>
|
||||||
@@ -167,7 +191,7 @@ const HelpComponent = () => {
|
|||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={() => void sendAPI(SYSTEM_INFO_API)}
|
onClick={handleDownloadSystemInfo}
|
||||||
>
|
>
|
||||||
{LL.SUPPORT_INFORMATION(0)}
|
{LL.SUPPORT_INFORMATION(0)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -190,6 +214,7 @@ const HelpComponent = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memoize the component to prevent unnecessary re-renders
|
||||||
const Help = memo(HelpComponent);
|
const Help = memo(HelpComponent);
|
||||||
|
|
||||||
export default Help;
|
export default Help;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import CircleIcon from '@mui/icons-material/Circle';
|
import CircleIcon from '@mui/icons-material/Circle';
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
import { readModules, writeModules } from '../../api/app';
|
import { readModules, writeModules } from '../../api/app';
|
||||||
@@ -69,7 +69,9 @@ const Modules = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const modules_theme = useTheme({
|
const modules_theme = useTheme(
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||||
`,
|
`,
|
||||||
@@ -109,13 +111,16 @@ const Modules = () => {
|
|||||||
background-color: #303030;
|
background-color: #303030;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = useCallback(() => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const updateModuleItem = (updatedItem: ModuleItem) => {
|
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
||||||
void updateState(readModules(), (data: ModuleItem[]) => {
|
void updateState(readModules(), (data: ModuleItem[]) => {
|
||||||
const new_data = data.map((mi) =>
|
const new_data = data.map((mi) =>
|
||||||
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
mi.id === updatedItem.id ? { ...mi, ...updatedItem } : mi
|
||||||
@@ -123,25 +128,28 @@ const Modules = () => {
|
|||||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: ModuleItem) => {
|
const onDialogSave = useCallback(
|
||||||
|
(updatedItem: ModuleItem) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
updateModuleItem(updatedItem);
|
updateModuleItem(updatedItem);
|
||||||
};
|
},
|
||||||
|
[updateModuleItem]
|
||||||
|
);
|
||||||
|
|
||||||
const editModuleItem = (mi: ModuleItem) => {
|
const editModuleItem = useCallback((mi: ModuleItem) => {
|
||||||
setSelectedModuleItem(mi);
|
setSelectedModuleItem(mi);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onCancel = async () => {
|
const onCancel = useCallback(async () => {
|
||||||
await fetchModules().then(() => {
|
await fetchModules().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
};
|
}, [fetchModules]);
|
||||||
|
|
||||||
const saveModules = async () => {
|
const saveModules = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
modules.map((condensed_mi: ModuleItem) =>
|
modules.map((condensed_mi: ModuleItem) =>
|
||||||
@@ -159,9 +167,9 @@ const Modules = () => {
|
|||||||
await fetchModules();
|
await fetchModules();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
}
|
}
|
||||||
};
|
}, [modules, updateModules, LL, fetchModules]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const content = useMemo(() => {
|
||||||
if (!modules) {
|
if (!modules) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||||
@@ -254,12 +262,22 @@ const Modules = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
modules,
|
||||||
|
fetchModules,
|
||||||
|
error,
|
||||||
|
modules_theme,
|
||||||
|
editModuleItem,
|
||||||
|
LL,
|
||||||
|
numChanges,
|
||||||
|
onCancel,
|
||||||
|
saveModules
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{renderContent()}
|
{content}
|
||||||
{selectedModuleItem && (
|
{selectedModuleItem && (
|
||||||
<ModulesDialog
|
<ModulesDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
@@ -37,10 +37,14 @@ const ModulesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = updateValue(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue(
|
||||||
setEditItem as unknown as React.Dispatch<
|
setEditItem as unknown as React.Dispatch<
|
||||||
React.SetStateAction<Record<string, unknown>>
|
React.SetStateAction<Record<string, unknown>>
|
||||||
>
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sync form state when dialog opens or selected item changes
|
// Sync form state when dialog opens or selected item changes
|
||||||
@@ -50,13 +54,18 @@ const ModulesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = useCallback(() => {
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
};
|
}, [editItem, onSave]);
|
||||||
|
|
||||||
|
const dialogTitle = useMemo(
|
||||||
|
() => `${LL.EDIT()} ${editItem.key}`,
|
||||||
|
[LL, editItem.key]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
<Dialog sx={dialogStyle} fullWidth maxWidth="xs" open={open} onClose={onClose}>
|
||||||
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -25,11 +26,10 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
|
|
||||||
import { readCommands, readSchedule, writeSchedule } from '../../api/app';
|
import { readSchedule, writeSchedule } from '../../api/app';
|
||||||
import SettingsSchedulerDialog from './SchedulerDialog';
|
import SettingsSchedulerDialog from './SchedulerDialog';
|
||||||
import { ScheduleFlag } from './types';
|
import { ScheduleFlag } from './types';
|
||||||
import type { Schedule, ScheduleItem } from './types';
|
import type { Schedule, ScheduleItem } from './types';
|
||||||
@@ -54,13 +54,14 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
flags: FLAG_ALL_DAYS,
|
flags: FLAG_ALL_DAYS,
|
||||||
time: '',
|
time: '',
|
||||||
cmd_name: '',
|
cmd: '',
|
||||||
|
value: '',
|
||||||
name: ''
|
name: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleTheme = {
|
const scheduleTheme = {
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
|
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -69,8 +70,11 @@ const scheduleTheme = {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
|
&:nth-of-type(2) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
&:nth-of-type(1) {
|
&:nth-of-type(1) {
|
||||||
text-align: 8px;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
@@ -96,6 +100,7 @@ const scheduleTheme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const scheduleTypeLabels: Record<number, string> = {
|
const scheduleTypeLabels: Record<number, string> = {
|
||||||
|
[ScheduleFlag.SCHEDULE_IMMEDIATE]: 'Immediate',
|
||||||
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
[ScheduleFlag.SCHEDULE_TIMER]: 'Timer',
|
||||||
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
[ScheduleFlag.SCHEDULE_CONDITION]: 'Condition',
|
||||||
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
[ScheduleFlag.SCHEDULE_ONCHANGE]: 'On Change'
|
||||||
@@ -120,11 +125,6 @@ const Scheduler = () => {
|
|||||||
initialData: []
|
initialData: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: commandNames } = useRequest(readCommands, {
|
|
||||||
initialData: [],
|
|
||||||
initializing: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const { send: updateSchedule } = useRequest(
|
const { send: updateSchedule } = useRequest(
|
||||||
(data: Schedule) => writeSchedule(data),
|
(data: Schedule) => writeSchedule(data),
|
||||||
{
|
{
|
||||||
@@ -132,7 +132,7 @@ const Scheduler = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasScheduleChanged = (si: ScheduleItem) => {
|
const hasScheduleChanged = useCallback((si: ScheduleItem) => {
|
||||||
return (
|
return (
|
||||||
si.id !== si.o_id ||
|
si.id !== si.o_id ||
|
||||||
(si.name || '') !== (si.o_name || '') ||
|
(si.name || '') !== (si.o_name || '') ||
|
||||||
@@ -140,15 +140,18 @@ const Scheduler = () => {
|
|||||||
si.deleted !== si.o_deleted ||
|
si.deleted !== si.o_deleted ||
|
||||||
si.flags !== si.o_flags ||
|
si.flags !== si.o_flags ||
|
||||||
si.time !== si.o_time ||
|
si.time !== si.o_time ||
|
||||||
si.cmd_name !== si.o_cmd_name
|
si.cmd !== si.o_cmd ||
|
||||||
|
si.value !== si.o_value
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useInterval(() => {
|
const intervalCallback = useCallback(() => {
|
||||||
if (numChanges === 0) {
|
if (numChanges === 0) {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}
|
}
|
||||||
}, INTERVAL_DELAY);
|
}, [numChanges, fetchSchedule]);
|
||||||
|
|
||||||
|
useInterval(intervalCallback, INTERVAL_DELAY);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const formatter = new Intl.DateTimeFormat(locale, {
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
@@ -166,7 +169,7 @@ const Scheduler = () => {
|
|||||||
|
|
||||||
const schedule_theme = useTheme(scheduleTheme);
|
const schedule_theme = useTheme(scheduleTheme);
|
||||||
|
|
||||||
const saveSchedule = async () => {
|
const saveSchedule = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await updateSchedule({
|
await updateSchedule({
|
||||||
schedule: schedule
|
schedule: schedule
|
||||||
@@ -176,7 +179,8 @@ const Scheduler = () => {
|
|||||||
active: condensed_si.active,
|
active: condensed_si.active,
|
||||||
flags: condensed_si.flags,
|
flags: condensed_si.flags,
|
||||||
time: condensed_si.time,
|
time: condensed_si.time,
|
||||||
cmd_name: condensed_si.cmd_name,
|
cmd: condensed_si.cmd,
|
||||||
|
value: condensed_si.value,
|
||||||
name: condensed_si.name
|
name: condensed_si.name
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
@@ -188,28 +192,29 @@ const Scheduler = () => {
|
|||||||
await fetchSchedule();
|
await fetchSchedule();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
}
|
}
|
||||||
};
|
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
||||||
|
|
||||||
const editScheduleItem = (si: ScheduleItem) => {
|
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setSelectedScheduleItem(si);
|
setSelectedScheduleItem(si);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
if (si.o_name === undefined) {
|
if (si.o_name === undefined) {
|
||||||
si.o_name = si.name;
|
si.o_name = si.name;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = useCallback(() => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onDialogCancel = async () => {
|
const onDialogCancel = useCallback(async () => {
|
||||||
await fetchSchedule().then(() => {
|
await fetchSchedule().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
};
|
}, [fetchSchedule]);
|
||||||
|
|
||||||
const onDialogSave = (updatedItem: ScheduleItem) => {
|
const onDialogSave = useCallback(
|
||||||
|
(updatedItem: ScheduleItem) => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||||
const new_data = creating
|
const new_data = creating
|
||||||
@@ -222,9 +227,11 @@ const Scheduler = () => {
|
|||||||
|
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[creating, hasScheduleChanged]
|
||||||
|
);
|
||||||
|
|
||||||
const addScheduleItem = () => {
|
const addScheduleItem = useCallback(() => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
const newItem: ScheduleItem = {
|
const newItem: ScheduleItem = {
|
||||||
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
@@ -232,13 +239,18 @@ const Scheduler = () => {
|
|||||||
};
|
};
|
||||||
setSelectedScheduleItem(newItem);
|
setSelectedScheduleItem(newItem);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const filteredAndSortedSchedule = schedule
|
const filteredAndSortedSchedule = useMemo(
|
||||||
|
() =>
|
||||||
|
schedule
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||||
|
[schedule]
|
||||||
|
);
|
||||||
|
|
||||||
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
|
const dayBox = useCallback(
|
||||||
|
(si: ScheduleItem, flag: number) => {
|
||||||
const dayIndex = Math.log(flag) / LOG_2;
|
const dayIndex = Math.log(flag) / LOG_2;
|
||||||
const isActive = (si.flags & flag) === flag;
|
const isActive = (si.flags & flag) === flag;
|
||||||
|
|
||||||
@@ -249,12 +261,14 @@ const Scheduler = () => {
|
|||||||
{dow[dayIndex]}
|
{dow[dayIndex]}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{!isLast && <Divider orientation="vertical" flexItem />}
|
<Divider orientation="vertical" flexItem />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[dow]
|
||||||
|
);
|
||||||
|
|
||||||
const scheduleType = (si: ScheduleItem) => {
|
const scheduleType = useCallback((si: ScheduleItem) => {
|
||||||
const label = scheduleTypeLabels[si.flags];
|
const label = scheduleTypeLabels[si.flags];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -264,9 +278,9 @@ const Scheduler = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const renderSchedule = () => {
|
const renderSchedule = useCallback(() => {
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||||
@@ -285,10 +299,9 @@ const Scheduler = () => {
|
|||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
<HeaderCell />
|
<HeaderCell />
|
||||||
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
||||||
<HeaderCell stiff>
|
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
|
||||||
{LL.TIME(0)}/{LL.CONDITION()}
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
||||||
|
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
||||||
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
||||||
</HeaderRow>
|
</HeaderRow>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -298,16 +311,12 @@ const Scheduler = () => {
|
|||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
color={si.active ? 'success' : 'error'}
|
color={si.active ? 'success' : 'error'}
|
||||||
sx={{
|
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
||||||
fontSize: ICON_SIZE,
|
|
||||||
ml: '4px',
|
|
||||||
position: 'relative',
|
|
||||||
top: '2px'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
<Stack spacing={0.5} direction="row">
|
<Stack spacing={0.5} direction="row">
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
{si.flags > SCHEDULE_FLAG_THRESHOLD ? (
|
||||||
scheduleType(si)
|
scheduleType(si)
|
||||||
) : (
|
) : (
|
||||||
@@ -318,13 +327,14 @@ const Scheduler = () => {
|
|||||||
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
|
{dayBox(si, ScheduleFlag.SCHEDULE_THU)}
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
|
{dayBox(si, ScheduleFlag.SCHEDULE_FRI)}
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
|
{dayBox(si, ScheduleFlag.SCHEDULE_SAT)}
|
||||||
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
|
{dayBox(si, ScheduleFlag.SCHEDULE_SUN)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
|
<Cell>{si.time}</Cell>
|
||||||
<Cell>{si.cmd_name}</Cell>
|
<Cell>{si.cmd}</Cell>
|
||||||
|
<Cell>{si.value}</Cell>
|
||||||
<Cell>{si.name}</Cell>
|
<Cell>{si.name}</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
@@ -333,7 +343,17 @@ const Scheduler = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
schedule,
|
||||||
|
error,
|
||||||
|
fetchSchedule,
|
||||||
|
filteredAndSortedSchedule,
|
||||||
|
schedule_theme,
|
||||||
|
editScheduleItem,
|
||||||
|
LL,
|
||||||
|
dayBox,
|
||||||
|
scheduleType
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
@@ -352,7 +372,6 @@ const Scheduler = () => {
|
|||||||
selectedItem={selectedScheduleItem}
|
selectedItem={selectedScheduleItem}
|
||||||
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
|
validator={schedulerItemValidation(schedule, selectedScheduleItem)}
|
||||||
dow={dow}
|
dow={dow}
|
||||||
commandNames={commandNames.map((ci) => ci.name)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import CircleIcon from '@mui/icons-material/Circle';
|
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Grid,
|
Grid,
|
||||||
MenuItem,
|
|
||||||
TextField,
|
TextField,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
@@ -22,12 +21,12 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
import { BlockFormControlLabel, ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { updateValue } from 'utils';
|
import { updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { ScheduleFlag } from './types';
|
import { ScheduleFlag } from './types';
|
||||||
import type { ScheduleItem } from './types';
|
import type { ScheduleItem } from './types';
|
||||||
@@ -61,12 +60,6 @@ const FLAG_VALUES = [
|
|||||||
ScheduleFlag.SCHEDULE_SAT
|
ScheduleFlag.SCHEDULE_SAT
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const getFlagDOWnumber = (flags: string[]) =>
|
|
||||||
flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
|
||||||
|
|
||||||
const getFlagDOWstring = (f: number) =>
|
|
||||||
FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) => String(flag));
|
|
||||||
|
|
||||||
interface SchedulerDialogProps {
|
interface SchedulerDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -75,7 +68,6 @@ interface SchedulerDialogProps {
|
|||||||
selectedItem: ScheduleItem;
|
selectedItem: ScheduleItem;
|
||||||
validator: Schema;
|
validator: Schema;
|
||||||
dow: string[];
|
dow: string[];
|
||||||
commandNames: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchedulerDialog = ({
|
const SchedulerDialog = ({
|
||||||
@@ -85,24 +77,33 @@ const SchedulerDialog = ({
|
|||||||
onSave,
|
onSave,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
validator,
|
validator,
|
||||||
dow,
|
dow
|
||||||
commandNames
|
|
||||||
}: SchedulerDialogProps) => {
|
}: SchedulerDialogProps) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ScheduleItem>(selectedItem);
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
const [scheduleType, setScheduleType] = useState<ScheduleFlag>();
|
||||||
|
|
||||||
const updateFormValue = updateValue(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue(
|
||||||
setEditItem as unknown as React.Dispatch<
|
setEditItem as unknown as React.Dispatch<
|
||||||
React.SetStateAction<Record<string, unknown>>
|
React.SetStateAction<Record<string, unknown>>
|
||||||
>
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
setEditItem(selectedItem);
|
setEditItem(selectedItem);
|
||||||
|
// Set the flags based on type when page is loaded:
|
||||||
|
// 0-127 is day schedule
|
||||||
|
// 128 is timer
|
||||||
|
// 129 is on change
|
||||||
|
// 130 is on condition
|
||||||
|
// 132 is immediate
|
||||||
setScheduleType(
|
setScheduleType(
|
||||||
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
|
selectedItem.flags <= SCHEDULE_TYPE_THRESHOLD
|
||||||
? ScheduleFlag.SCHEDULE_DAY
|
? ScheduleFlag.SCHEDULE_DAY
|
||||||
@@ -111,7 +112,9 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleSave = async (itemToSave: ScheduleItem) => {
|
// Helper function to handle save operations
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (itemToSave: ScheduleItem) => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, itemToSave);
|
await validate(validator, itemToSave);
|
||||||
@@ -119,17 +122,36 @@ const SchedulerDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[validator, onSave]
|
||||||
|
);
|
||||||
|
|
||||||
const save = async () => {
|
const save = useCallback(async () => {
|
||||||
await handleSave(editItem);
|
await handleSave(editItem);
|
||||||
};
|
}, [editItem, handleSave]);
|
||||||
|
|
||||||
const remove = () => {
|
const saveandactivate = useCallback(async () => {
|
||||||
|
await handleSave({ ...editItem, active: true });
|
||||||
|
}, [editItem, handleSave]);
|
||||||
|
|
||||||
|
const remove = useCallback(() => {
|
||||||
onSave({ ...editItem, deleted: true });
|
onSave({ ...editItem, deleted: true });
|
||||||
};
|
}, [editItem, onSave]);
|
||||||
|
|
||||||
const DayOfWeekButton = (flag: number) => {
|
// Optimize DOW flag conversion
|
||||||
|
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
||||||
|
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getFlagDOWstring = useCallback((f: number) => {
|
||||||
|
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
||||||
|
String(flag)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Day of week display component
|
||||||
|
const DayOfWeekButton = useCallback(
|
||||||
|
(flag: number) => {
|
||||||
const dayIndex = Math.log2(flag);
|
const dayIndex = Math.log2(flag);
|
||||||
const isSelected = (editItem.flags & flag) === flag;
|
const isSelected = (editItem.flags & flag) === flag;
|
||||||
return (
|
return (
|
||||||
@@ -140,21 +162,21 @@ const SchedulerDialog = ({
|
|||||||
{dow[dayIndex]}
|
{dow[dayIndex]}
|
||||||
</Typography>
|
</Typography>
|
||||||
);
|
);
|
||||||
};
|
},
|
||||||
|
[editItem.flags, dow]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = useCallback(
|
||||||
_event: React.SyntheticEvent,
|
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
const handleScheduleTypeChange = (
|
const handleScheduleTypeChange = useCallback(
|
||||||
_event: React.SyntheticEvent<HTMLElement>,
|
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
||||||
flag: ScheduleFlag | null
|
|
||||||
) => {
|
|
||||||
if (flag !== null) {
|
if (flag !== null) {
|
||||||
setFieldErrors(undefined); // clear any validation errors
|
setFieldErrors(undefined); // clear any validation errors
|
||||||
setScheduleType(flag);
|
setScheduleType(flag);
|
||||||
@@ -163,37 +185,56 @@ const SchedulerDialog = ({
|
|||||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
||||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDOWChange = (
|
const handleDOWChange = useCallback(
|
||||||
_event: React.SyntheticEvent<HTMLElement>,
|
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||||
flags: string[]
|
|
||||||
) => {
|
|
||||||
const newFlags =
|
const newFlags =
|
||||||
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
||||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||||
};
|
},
|
||||||
|
[getFlagDOWnumber]
|
||||||
|
);
|
||||||
|
|
||||||
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
|
// Memoize derived values
|
||||||
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
|
const isDaySchedule = useMemo(
|
||||||
const needsTimeField = isDaySchedule || isTimerSchedule;
|
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const isTimerSchedule = useMemo(
|
||||||
|
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const isImmediateSchedule = useMemo(
|
||||||
|
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const needsTimeField = useMemo(
|
||||||
|
() => isDaySchedule || isTimerSchedule,
|
||||||
|
[isDaySchedule, isTimerSchedule]
|
||||||
|
);
|
||||||
|
|
||||||
const dowFlags = getFlagDOWstring(editItem.flags);
|
const dowFlags = useMemo(
|
||||||
|
() => getFlagDOWstring(editItem.flags),
|
||||||
|
[editItem.flags, getFlagDOWstring]
|
||||||
|
);
|
||||||
|
|
||||||
const timeFieldValue = needsTimeField
|
const timeFieldValue = useMemo(() => {
|
||||||
? editItem.time === ''
|
if (needsTimeField) {
|
||||||
? DEFAULT_TIME
|
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
|
||||||
: editItem.time
|
}
|
||||||
: editItem.time === DEFAULT_TIME
|
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
||||||
? ''
|
}, [editItem.time, needsTimeField]);
|
||||||
: editItem.time;
|
|
||||||
|
|
||||||
const timeFieldLabel = (() => {
|
const timeFieldLabel = useMemo(() => {
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
if (scheduleType === ScheduleFlag.SCHEDULE_TIMER) return LL.TIMER(1);
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
if (scheduleType === ScheduleFlag.SCHEDULE_CONDITION) return LL.CONDITION();
|
||||||
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
if (scheduleType === ScheduleFlag.SCHEDULE_ONCHANGE) return LL.ONCHANGE();
|
||||||
|
if (scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE) return LL.IMMEDIATE();
|
||||||
return LL.TIME(1);
|
return LL.TIME(1);
|
||||||
})();
|
}, [scheduleType, LL]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
@@ -246,6 +287,14 @@ const SchedulerDialog = ({
|
|||||||
{LL.CONDITION()}
|
{LL.CONDITION()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
<ToggleButton value={ScheduleFlag.SCHEDULE_IMMEDIATE}>
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
|
color={isImmediateSchedule ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{LL.IMMEDIATE()}
|
||||||
|
</Typography>
|
||||||
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
{isDaySchedule && (
|
{isDaySchedule && (
|
||||||
@@ -263,6 +312,8 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isImmediateSchedule && (
|
||||||
|
<>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
control={
|
control={
|
||||||
@@ -274,15 +325,7 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
label={LL.ACTIVE()}
|
label={LL.ACTIVE()}
|
||||||
/>
|
/>
|
||||||
<CircleIcon
|
|
||||||
color={editItem.active ? 'success' : 'error'}
|
|
||||||
sx={{
|
|
||||||
fontSize: 16,
|
|
||||||
mt: '12px'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Grid container>
|
<Grid container>
|
||||||
{needsTimeField ? (
|
{needsTimeField ? (
|
||||||
<>
|
<>
|
||||||
@@ -295,8 +338,12 @@ const SchedulerDialog = ({
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
/>
|
||||||
{isTimerSchedule && (
|
{isTimerSchedule && (
|
||||||
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
|
<Typography
|
||||||
00:00 = {LL.SCHEDULER_HELP_2()}
|
sx={{ ml: 2, mt: 4 }}
|
||||||
|
color="warning"
|
||||||
|
variant="body2"
|
||||||
|
>
|
||||||
|
{LL.SCHEDULER_HELP_2()}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -312,25 +359,31 @@ const SchedulerDialog = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextField
|
</>
|
||||||
name="cmd_name"
|
)}
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="cmd"
|
||||||
label={LL.COMMAND(0)}
|
label={LL.COMMAND(0)}
|
||||||
value={editItem.cmd_name}
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
value={editItem.cmd}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
>
|
/>
|
||||||
{commandNames.map((name) => (
|
<TextField
|
||||||
<MenuItem key={name} value={name}>
|
name="value"
|
||||||
{name}
|
label={LL.VALUE(0)}
|
||||||
</MenuItem>
|
multiline
|
||||||
))}
|
margin="normal"
|
||||||
</TextField>
|
fullWidth
|
||||||
|
value={editItem.value}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="name"
|
name="name"
|
||||||
label={LL.NAME(0)}
|
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
||||||
value={editItem.name}
|
value={editItem.name}
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
@@ -367,6 +420,16 @@ const SchedulerDialog = ({
|
|||||||
>
|
>
|
||||||
{creating ? LL.ADD(0) : LL.UPDATE()}
|
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isImmediateSchedule && editItem.cmd !== '' && (
|
||||||
|
<Button
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={saveandactivate}
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
{LL.EXECUTE()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useContext, useRef, useState } from 'react';
|
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||||
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined';
|
||||||
@@ -20,7 +21,6 @@ import { useTheme } from '@table-library/react-table-library/theme';
|
|||||||
import type { State } from '@table-library/react-table-library/types/common';
|
import type { State } from '@table-library/react-table-library/types/common';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
import { SectionContent, useLayoutTitle } from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
@@ -158,16 +158,18 @@ const Sensors = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
useInterval(() => {
|
const intervalCallback = useCallback(() => {
|
||||||
if (!temperatureDialogOpen && !analogDialogOpen) {
|
if (!temperatureDialogOpen && !analogDialogOpen) {
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}
|
}
|
||||||
});
|
}, [temperatureDialogOpen, analogDialogOpen, fetchSensorData]);
|
||||||
|
|
||||||
|
useInterval(intervalCallback);
|
||||||
|
|
||||||
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
const temperature_theme = useTheme([common_theme, temperature_theme_config]);
|
||||||
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
const analog_theme = useTheme([common_theme, analog_theme_config]);
|
||||||
|
|
||||||
const getSortIcon = (state: State, sortKey: unknown) => {
|
const getSortIcon = useCallback((state: State, sortKey: unknown) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
return <KeyboardArrowDownOutlinedIcon />;
|
return <KeyboardArrowDownOutlinedIcon />;
|
||||||
}
|
}
|
||||||
@@ -175,7 +177,7 @@ const Sensors = () => {
|
|||||||
return <KeyboardArrowUpOutlinedIcon />;
|
return <KeyboardArrowUpOutlinedIcon />;
|
||||||
}
|
}
|
||||||
return <UnfoldMoreOutlinedIcon />;
|
return <UnfoldMoreOutlinedIcon />;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const analog_sort = useSort(
|
const analog_sort = useSort(
|
||||||
{ nodes: sensorData.as },
|
{ nodes: sensorData.as },
|
||||||
@@ -232,7 +234,8 @@ const Sensors = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = (duration_min: number) => {
|
const formatDurationMin = useCallback(
|
||||||
|
(duration_min: number) => {
|
||||||
const totalMs = duration_min * MS_PER_MINUTE;
|
const totalMs = duration_min * MS_PER_MINUTE;
|
||||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||||
@@ -249,9 +252,12 @@ const Sensors = () => {
|
|||||||
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
parts.push(LL.NUM_MINUTES({ num: minutes }));
|
||||||
}
|
}
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
|
const formatValue = useCallback(
|
||||||
|
(value: unknown, uom: DeviceValueUOM) => {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -280,22 +286,28 @@ const Sensors = () => {
|
|||||||
default:
|
default:
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[formatDurationMin, LL]
|
||||||
|
);
|
||||||
|
|
||||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
const updateTemperatureSensor = useCallback(
|
||||||
|
(ts: TemperatureSensor) => {
|
||||||
if (me.admin) {
|
if (me.admin) {
|
||||||
ts.o_n = ts.n;
|
ts.o_n = ts.n;
|
||||||
setSelectedTemperatureSensor(ts);
|
setSelectedTemperatureSensor(ts);
|
||||||
setTemperatureDialogOpen(true);
|
setTemperatureDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[me.admin]
|
||||||
|
);
|
||||||
|
|
||||||
const onTemperatureDialogClose = () => {
|
const onTemperatureDialogClose = useCallback(() => {
|
||||||
setTemperatureDialogOpen(false);
|
setTemperatureDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
};
|
}, [fetchSensorData]);
|
||||||
|
|
||||||
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
const onTemperatureDialogSave = useCallback(
|
||||||
|
async (ts: TemperatureSensor) => {
|
||||||
await sendTemperatureSensor({
|
await sendTemperatureSensor({
|
||||||
id: ts.id,
|
id: ts.id,
|
||||||
name: ts.n,
|
name: ts.n,
|
||||||
@@ -313,23 +325,28 @@ const Sensors = () => {
|
|||||||
setSelectedTemperatureSensor(undefined);
|
setSelectedTemperatureSensor(undefined);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[sendTemperatureSensor, LL, fetchSensorData]
|
||||||
|
);
|
||||||
|
|
||||||
const updateAnalogSensor = (as: AnalogSensor) => {
|
const updateAnalogSensor = useCallback(
|
||||||
|
(as: AnalogSensor) => {
|
||||||
if (me.admin) {
|
if (me.admin) {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
as.o_n = as.n;
|
as.o_n = as.n;
|
||||||
setSelectedAnalogSensor(as);
|
setSelectedAnalogSensor(as);
|
||||||
setAnalogDialogOpen(true);
|
setAnalogDialogOpen(true);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[me.admin]
|
||||||
|
);
|
||||||
|
|
||||||
const onAnalogDialogClose = () => {
|
const onAnalogDialogClose = useCallback(() => {
|
||||||
setAnalogDialogOpen(false);
|
setAnalogDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
};
|
}, [fetchSensorData]);
|
||||||
|
|
||||||
const addAnalogSensor = () => {
|
const addAnalogSensor = useCallback(() => {
|
||||||
if (firstAvailableGPIO.current === undefined) {
|
if (firstAvailableGPIO.current === undefined) {
|
||||||
toast.error(LL.NO_GPIO());
|
toast.error(LL.NO_GPIO());
|
||||||
return;
|
return;
|
||||||
@@ -349,9 +366,10 @@ const Sensors = () => {
|
|||||||
o_n: ''
|
o_n: ''
|
||||||
});
|
});
|
||||||
setAnalogDialogOpen(true);
|
setAnalogDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
const onAnalogDialogSave = useCallback(
|
||||||
|
async (as: AnalogSensor) => {
|
||||||
await sendAnalogSensor({
|
await sendAnalogSensor({
|
||||||
id: as.id,
|
id: as.id,
|
||||||
gpio: as.g,
|
gpio: as.g,
|
||||||
@@ -374,9 +392,12 @@ const Sensors = () => {
|
|||||||
setSelectedAnalogSensor(undefined);
|
setSelectedAnalogSensor(undefined);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[sendAnalogSensor, LL, fetchSensorData]
|
||||||
|
);
|
||||||
|
|
||||||
const RenderAnalogSensors = (
|
const RenderAnalogSensors = useMemo(
|
||||||
|
() => (
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: sensorData.as }}
|
data={{ nodes: sensorData.as }}
|
||||||
theme={analog_theme}
|
theme={analog_theme}
|
||||||
@@ -422,7 +443,9 @@ const Sensors = () => {
|
|||||||
fullWidth
|
fullWidth
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
onClick={() =>
|
||||||
|
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{LL.VALUE(0)}
|
{LL.VALUE(0)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -455,9 +478,20 @@ const Sensors = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
analog_sort,
|
||||||
|
analog_theme,
|
||||||
|
getSortIcon,
|
||||||
|
sensorData.as,
|
||||||
|
LL,
|
||||||
|
updateAnalogSensor,
|
||||||
|
formatValue
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const RenderTemperatureSensors = (
|
const RenderTemperatureSensors = useMemo(
|
||||||
|
() => (
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: sensorData.ts }}
|
data={{ nodes: sensorData.ts }}
|
||||||
theme={temperature_theme}
|
theme={temperature_theme}
|
||||||
@@ -510,6 +544,16 @@ const Sensors = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
temperature_sort,
|
||||||
|
temperature_theme,
|
||||||
|
getSortIcon,
|
||||||
|
sensorData.ts,
|
||||||
|
LL,
|
||||||
|
updateTemperatureSensor,
|
||||||
|
formatValue
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
@@ -18,12 +18,12 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
import { AnalogType, AnalogTypeNames, DeviceValueUOM_s } from './types';
|
||||||
import type { AnalogSensor } from './types';
|
import type { AnalogSensor } from './types';
|
||||||
@@ -53,38 +53,62 @@ const SensorsAnalogDialog = ({
|
|||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
const [editItem, setEditItem] = useState<AnalogSensor>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = updateValue((updater) =>
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue((updater) =>
|
||||||
setEditItem(
|
setEditItem(
|
||||||
(prev) =>
|
(prev) =>
|
||||||
updater(
|
updater(
|
||||||
prev as unknown as Record<string, unknown>
|
prev as unknown as Record<string, unknown>
|
||||||
) as unknown as AnalogSensor
|
) as unknown as AnalogSensor
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
[setEditItem]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCounterOrRate =
|
// Memoize helper functions to check sensor type conditions
|
||||||
|
const isCounterOrRate = useMemo(
|
||||||
|
() =>
|
||||||
editItem.t === AnalogType.COUNTER ||
|
editItem.t === AnalogType.COUNTER ||
|
||||||
editItem.t === AnalogType.RATE ||
|
editItem.t === AnalogType.RATE ||
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||||
const isCounter =
|
[editItem.t]
|
||||||
|
);
|
||||||
|
const isCounter = useMemo(
|
||||||
|
() =>
|
||||||
editItem.t === AnalogType.COUNTER ||
|
editItem.t === AnalogType.COUNTER ||
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||||
const isFreqType =
|
[editItem.t]
|
||||||
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
);
|
||||||
const isPWM =
|
const isFreqType = useMemo(
|
||||||
|
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
||||||
|
[editItem.t]
|
||||||
|
);
|
||||||
|
const isPWM = useMemo(
|
||||||
|
() =>
|
||||||
editItem.t === AnalogType.PWM_0 ||
|
editItem.t === AnalogType.PWM_0 ||
|
||||||
editItem.t === AnalogType.PWM_1 ||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
editItem.t === AnalogType.PWM_2;
|
editItem.t === AnalogType.PWM_2,
|
||||||
const isDACOutGPIO =
|
[editItem.t]
|
||||||
|
);
|
||||||
|
const isDACOutGPIO = useMemo(
|
||||||
|
() =>
|
||||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
(editItem.g === 25 || editItem.g === 26);
|
(editItem.g === 25 || editItem.g === 26),
|
||||||
const isDigitalOutGPIO =
|
[editItem.t, editItem.g]
|
||||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
);
|
||||||
|
const isDigitalOutGPIO = useMemo(
|
||||||
|
() =>
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
|
editItem.g !== 25 &&
|
||||||
|
editItem.g !== 26,
|
||||||
|
[editItem.t, editItem.g]
|
||||||
|
);
|
||||||
|
|
||||||
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
|
// Memoize menu items to avoid recreation on each render
|
||||||
name: val,
|
const analogTypeMenuItems = useMemo(
|
||||||
value: i + 1
|
() =>
|
||||||
}))
|
AnalogTypeNames.map((val, i) => ({ name: val, value: i + 1 }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map(({ name, value }) => (
|
.map(({ name, value }) => (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
@@ -94,13 +118,19 @@ const SensorsAnalogDialog = ({
|
|||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
)),
|
||||||
|
[disabledTypeList]
|
||||||
|
);
|
||||||
|
|
||||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
const uomMenuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
DeviceValueUOM_s.map((val, i) => (
|
||||||
<MenuItem key={val} value={i}>
|
<MenuItem key={val} value={i}>
|
||||||
{val}
|
{val}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const analogGPIOMenuItems = () =>
|
const analogGPIOMenuItems = () =>
|
||||||
// add selectedItem.g to the list
|
// add selectedItem.g to the list
|
||||||
@@ -127,16 +157,16 @@ const SensorsAnalogDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (
|
const handleClose = useCallback(
|
||||||
_event: React.SyntheticEvent,
|
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
|
||||||
) => {
|
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
const save = async () => {
|
const save = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -144,13 +174,17 @@ const SensorsAnalogDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [validator, editItem, onSave]);
|
||||||
|
|
||||||
const remove = () => {
|
const remove = useCallback(() => {
|
||||||
onSave({ ...editItem, d: true });
|
onSave({ ...editItem, d: true });
|
||||||
};
|
}, [editItem, onSave]);
|
||||||
|
|
||||||
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
|
const dialogTitle = useMemo(
|
||||||
|
() =>
|
||||||
|
`${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`,
|
||||||
|
[creating, LL]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DoneIcon from '@mui/icons-material/Done';
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import type { TemperatureSensor } from './types';
|
import type { TemperatureSensor } from './types';
|
||||||
|
|
||||||
@@ -50,12 +50,16 @@ const SensorsTemperatureDialog = ({
|
|||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
const [editItem, setEditItem] = useState<TemperatureSensor>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = updateValue(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValue(
|
||||||
setEditItem as unknown as (
|
setEditItem as unknown as (
|
||||||
updater: (
|
updater: (
|
||||||
prevState: Readonly<Record<string, unknown>>
|
prevState: Readonly<Record<string, unknown>>
|
||||||
) => Record<string, unknown>
|
) => Record<string, unknown>
|
||||||
) => void
|
) => void
|
||||||
|
),
|
||||||
|
[setEditItem]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,13 +69,16 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
const handleClose = useCallback(
|
||||||
|
(_event: React.SyntheticEvent, reason?: string) => {
|
||||||
if (reason !== 'backdropClick') {
|
if (reason !== 'backdropClick') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
const save = async () => {
|
const save = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -79,11 +86,29 @@ const SensorsTemperatureDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [validator, editItem, onSave]);
|
||||||
|
|
||||||
|
const dialogTitle = useMemo(() => `${LL.EDIT()} ${LL.TEMP_SENSOR()}`, [LL]);
|
||||||
|
|
||||||
|
const offsetValue = useMemo(() => numberValue(editItem.o), [editItem.o]);
|
||||||
|
|
||||||
|
const slotProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
input: {
|
||||||
|
startAdornment: <InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
||||||
|
},
|
||||||
|
htmlInput: {
|
||||||
|
min: OFFSET_MIN,
|
||||||
|
max: OFFSET_MAX,
|
||||||
|
step: OFFSET_STEP
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
<Typography sx={{ mb: 2 }} color="warning" variant="body2">
|
||||||
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
{LL.ID_OF(LL.SENSOR(0))}: {editItem.id}
|
||||||
@@ -103,23 +128,12 @@ const SensorsTemperatureDialog = ({
|
|||||||
<TextField
|
<TextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.OFFSET()}
|
label={LL.OFFSET()}
|
||||||
value={numberValue(editItem.o)}
|
value={offsetValue}
|
||||||
sx={{ width: '11ch' }}
|
sx={{ width: '11ch' }}
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
slotProps={{
|
slotProps={slotProps}
|
||||||
input: {
|
|
||||||
startAdornment: (
|
|
||||||
<InputAdornment position="start">{TEMP_UNIT}</InputAdornment>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
htmlInput: {
|
|
||||||
min: OFFSET_MIN,
|
|
||||||
max: OFFSET_MAX,
|
|
||||||
step: OFFSET_STEP
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { memo, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import PersonIcon from '@mui/icons-material/Person';
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
import {
|
import {
|
||||||
@@ -23,9 +23,9 @@ const UserProfileComponent = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.USER_PROFILE());
|
useLayoutTitle(LL.USER_PROFILE());
|
||||||
|
|
||||||
const handleSignOut = () => {
|
const handleSignOut = useCallback(() => {
|
||||||
signOut(true);
|
signOut(true);
|
||||||
};
|
}, [signOut]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
system_name: string;
|
|
||||||
locale: string;
|
locale: string;
|
||||||
tx_mode: number;
|
tx_mode: number;
|
||||||
ems_bus_id: number;
|
ems_bus_id: number;
|
||||||
@@ -44,17 +43,7 @@ export interface Settings {
|
|||||||
modbus_port: number;
|
modbus_port: number;
|
||||||
modbus_max_clients: number;
|
modbus_max_clients: number;
|
||||||
modbus_timeout: number;
|
modbus_timeout: number;
|
||||||
email_enabled: boolean;
|
|
||||||
email_security: number;
|
|
||||||
email_server: string;
|
|
||||||
email_port: number;
|
|
||||||
email_login: string;
|
|
||||||
email_pass: string;
|
|
||||||
email_sender: string;
|
|
||||||
email_recp: string;
|
|
||||||
email_subject: string;
|
|
||||||
developer_mode: boolean;
|
developer_mode: boolean;
|
||||||
disable_reset: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum busConnectionStatus {
|
export enum busConnectionStatus {
|
||||||
@@ -350,19 +339,21 @@ export enum DeviceEntityMask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleItem {
|
export interface ScheduleItem {
|
||||||
id: number; // unique index for table
|
id: number; // unique index
|
||||||
active: boolean;
|
active: boolean;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
flags: number;
|
flags: number;
|
||||||
time: string; // also used for Condition and On Change
|
time: string; // also used for Condition and On Change
|
||||||
cmd_name: string; // references a named Command
|
cmd: string;
|
||||||
name: string;
|
value: string;
|
||||||
|
name: string; // can be empty
|
||||||
o_id?: number;
|
o_id?: number;
|
||||||
o_active?: boolean;
|
o_active?: boolean;
|
||||||
o_deleted?: boolean;
|
o_deleted?: boolean;
|
||||||
o_flags?: number;
|
o_flags?: number;
|
||||||
o_time?: string;
|
o_time?: string;
|
||||||
o_cmd_name?: string;
|
o_cmd?: string;
|
||||||
|
o_value?: string;
|
||||||
o_name?: string;
|
o_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,23 +361,6 @@ export interface Schedule {
|
|||||||
readonly schedule: readonly ScheduleItem[];
|
readonly schedule: readonly ScheduleItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandItem {
|
|
||||||
id: number;
|
|
||||||
cmd: string;
|
|
||||||
value: string;
|
|
||||||
name: string;
|
|
||||||
deleted?: boolean;
|
|
||||||
o_id?: number;
|
|
||||||
o_cmd?: string;
|
|
||||||
o_value?: string;
|
|
||||||
o_name?: string;
|
|
||||||
o_deleted?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Commands {
|
|
||||||
readonly commands: readonly CommandItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModuleItem {
|
export interface ModuleItem {
|
||||||
id: number; // unique index
|
id: number; // unique index
|
||||||
key: string;
|
key: string;
|
||||||
@@ -417,7 +391,8 @@ export enum ScheduleFlag {
|
|||||||
SCHEDULE_DAY = 0, // no bits set
|
SCHEDULE_DAY = 0, // no bits set
|
||||||
SCHEDULE_TIMER = 128, // bit 8
|
SCHEDULE_TIMER = 128, // bit 8
|
||||||
SCHEDULE_ONCHANGE = 129, // bit 1
|
SCHEDULE_ONCHANGE = 129, // bit 1
|
||||||
SCHEDULE_CONDITION = 130 // bit 2
|
SCHEDULE_CONDITION = 130, // bit 2
|
||||||
|
SCHEDULE_IMMEDIATE = 132 // bit 3
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EntityItem {
|
export interface EntityItem {
|
||||||
@@ -460,7 +435,6 @@ export const enum DeviceType {
|
|||||||
ANALOGSENSOR = 2,
|
ANALOGSENSOR = 2,
|
||||||
SCHEDULER = 3,
|
SCHEDULER = 3,
|
||||||
CUSTOM = 4,
|
CUSTOM = 4,
|
||||||
COMMAND = 5,
|
|
||||||
BOILER,
|
BOILER,
|
||||||
THERMOSTAT,
|
THERMOSTAT,
|
||||||
MIXER,
|
MIXER,
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import Schema from 'validators/schema';
|
import Schema from 'async-validator';
|
||||||
import type { InternalRuleItem } from 'validators/schema';
|
import type { InternalRuleItem } from 'async-validator';
|
||||||
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
|
import { IP_OR_HOSTNAME_VALIDATOR } from 'validators/shared';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AnalogSensor,
|
AnalogSensor,
|
||||||
CommandItem,
|
|
||||||
DeviceValue,
|
DeviceValue,
|
||||||
EntityItem,
|
EntityItem,
|
||||||
ScheduleItem,
|
ScheduleItem,
|
||||||
@@ -233,29 +232,7 @@ export const schedulerItemValidation = (
|
|||||||
scheduleItem: ScheduleItem
|
scheduleItem: ScheduleItem
|
||||||
) =>
|
) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
name: [
|
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
||||||
{ required: true, message: 'Name is required' },
|
|
||||||
NAME_PATTERN_REQUIRED,
|
|
||||||
uniqueNameValidator(schedule, scheduleItem.o_name)
|
|
||||||
],
|
|
||||||
cmd_name: [{ required: true, message: 'Command is required' }]
|
|
||||||
});
|
|
||||||
|
|
||||||
export const uniqueCommandNameValidator = (
|
|
||||||
commands: CommandItem[],
|
|
||||||
o_name?: string
|
|
||||||
) => createUniqueNameValidator(commands, o_name);
|
|
||||||
|
|
||||||
export const commandItemValidation = (
|
|
||||||
commands: CommandItem[],
|
|
||||||
commandItem: CommandItem
|
|
||||||
) =>
|
|
||||||
new Schema({
|
|
||||||
name: [
|
|
||||||
{ required: true, message: 'Name is required' },
|
|
||||||
NAME_PATTERN_REQUIRED,
|
|
||||||
uniqueCommandNameValidator(commands, commandItem.o_name)
|
|
||||||
],
|
|
||||||
cmd: [
|
cmd: [
|
||||||
{ required: true, message: 'Command is required' },
|
{ required: true, message: 'Command is required' },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
@@ -6,6 +6,7 @@ import { Button, Checkbox, MenuItem } from '@mui/material';
|
|||||||
|
|
||||||
import * as APApi from 'api/ap';
|
import * as APApi from 'api/ap';
|
||||||
|
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -21,9 +22,9 @@ import type { APSettingsType } from 'types';
|
|||||||
import { APProvisionMode } from 'types';
|
import { APProvisionMode } from 'types';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
|
import { ValidationError, createAPSettingsValidator, validate } from 'validators';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
export const isAPEnabled = ({ provision_mode }: APSettingsType) =>
|
||||||
|
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
||||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||||
|
|
||||||
// Efficient range function without recursion
|
// Efficient range function without recursion
|
||||||
@@ -62,16 +63,22 @@ const APSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValueDirty(
|
||||||
origData as unknown as Record<string, unknown>,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
|
),
|
||||||
|
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const apEnabled = data ? isAPEnabled(data) : false;
|
// Memoize AP enabled state
|
||||||
|
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
// Memoize validation and submit handler
|
||||||
|
const validateAndSubmit = useCallback(async () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +88,7 @@ const APSettings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [data, saveData]);
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -101,6 +108,9 @@ const APSettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
>
|
>
|
||||||
|
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>
|
||||||
|
{LL.AP_PROVIDE_TEXT_1()}
|
||||||
|
</MenuItem>
|
||||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
||||||
{LL.AP_PROVIDE_TEXT_2()}
|
{LL.AP_PROVIDE_TEXT_2()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||||
@@ -19,6 +20,7 @@ import { readSystemStatus } from 'api/system';
|
|||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import SystemMonitor from 'app/status/SystemMonitor';
|
import SystemMonitor from 'app/status/SystemMonitor';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -26,15 +28,12 @@ import {
|
|||||||
FormLoader,
|
FormLoader,
|
||||||
MessageBox,
|
MessageBox,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
ValidatedPasswordField,
|
|
||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||||
import { BOARD_PROFILES } from '../main/types';
|
import { BOARD_PROFILES } from '../main/types';
|
||||||
@@ -107,46 +106,61 @@ const ApplicationSettings = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const SecondsInputProps = {
|
// Memoized input props to prevent recreation on every render
|
||||||
|
const SecondsInputProps = useMemo(
|
||||||
|
() => ({
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
};
|
}),
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const MinutesInputProps = {
|
const MinutesInputProps = useMemo(
|
||||||
|
() => ({
|
||||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||||
};
|
}),
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const HoursInputProps = {
|
const HoursInputProps = useMemo(
|
||||||
|
() => ({
|
||||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||||
};
|
}),
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const doRestart = async () => {
|
const doRestart = useCallback(async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}, [sendAPI]);
|
||||||
|
|
||||||
const updateBoardProfile = async (board_profile: string) => {
|
const updateBoardProfile = useCallback(
|
||||||
|
async (board_profile: string) => {
|
||||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
});
|
});
|
||||||
};
|
},
|
||||||
|
[readBoardProfile]
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutTitle(LL.APPLICATION());
|
useLayoutTitle(LL.APPLICATION());
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
const validateAndSubmit = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(createSettingsValidator(data), data);
|
await validate(createSettingsValidator(data), data);
|
||||||
await saveData();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
|
} finally {
|
||||||
|
await saveData();
|
||||||
}
|
}
|
||||||
};
|
}, [data, saveData]);
|
||||||
|
|
||||||
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const changeBoardProfile = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const boardProfile = event.target.value;
|
const boardProfile = event.target.value;
|
||||||
updateFormValue(event);
|
updateFormValue(event);
|
||||||
if (boardProfile === 'CUSTOM') {
|
if (boardProfile === 'CUSTOM') {
|
||||||
@@ -157,29 +171,17 @@ const ApplicationSettings = () => {
|
|||||||
} else {
|
} else {
|
||||||
void updateBoardProfile(boardProfile);
|
void updateBoardProfile(boardProfile);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
||||||
|
);
|
||||||
|
|
||||||
const restart = async () => {
|
const restart = useCallback(async () => {
|
||||||
await validateAndSubmit();
|
await validateAndSubmit();
|
||||||
await doRestart();
|
await doRestart();
|
||||||
};
|
}, [validateAndSubmit, doRestart]);
|
||||||
|
|
||||||
const sendmail = async () => {
|
// Memoize board profile select items to prevent recreation
|
||||||
await sendAPI({
|
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
||||||
device: 'system',
|
|
||||||
cmd: 'sendmail',
|
|
||||||
data: 'Email notification test successful!',
|
|
||||||
id: 0
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.TEST_EMAIL_SUCCESSFUL());
|
|
||||||
})
|
|
||||||
.catch((error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const boardProfileItems = boardProfileSelectItems();
|
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data || !hardwareData) {
|
if (!data || !hardwareData) {
|
||||||
@@ -188,19 +190,7 @@ const ApplicationSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography variant="h6" color="primary">
|
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
||||||
{LL.SYSTEM(0)}
|
|
||||||
{LL.CUSTOMIZATIONS()}
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
name="system_name"
|
|
||||||
label={LL.SYSTEM_NAME()}
|
|
||||||
value={data.system_name}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
|
||||||
{LL.SERVICES()}
|
{LL.SERVICES()}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography color="secondary">API</Typography>
|
<Typography color="secondary">API</Typography>
|
||||||
@@ -361,148 +351,6 @@ const ApplicationSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
<Typography color="secondary">eMail</Typography>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={data.email_enabled}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
name="email_enabled"
|
|
||||||
disabled={!hardwareData.psram}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={
|
|
||||||
<Typography color={!hardwareData.psram ? 'grey' : 'default'}>
|
|
||||||
Enable eMail notification
|
|
||||||
{!hardwareData.psram && (
|
|
||||||
<Typography variant="caption">
|
|
||||||
({LL.IS_REQUIRED('PSRAM')})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{data.email_enabled && (
|
|
||||||
<>
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
spacing={2}
|
|
||||||
direction="row"
|
|
||||||
sx={{ justifyContent: 'flex-start', alignItems: 'flex-start' }}
|
|
||||||
>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_server"
|
|
||||||
label="SMTP Server"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_server}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
sx={{ width: '12ch' }}
|
|
||||||
name="email_port"
|
|
||||||
variant="outlined"
|
|
||||||
label="Port"
|
|
||||||
value={numberValue(data.email_port)}
|
|
||||||
type="number"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<TextField
|
|
||||||
sx={{ width: '20ch' }}
|
|
||||||
name="email_security"
|
|
||||||
label={LL.SECURITY(0)}
|
|
||||||
value={data.email_security}
|
|
||||||
variant="outlined"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
select
|
|
||||||
>
|
|
||||||
<MenuItem value={0}>{LL.OFF()}</MenuItem>
|
|
||||||
<MenuItem value={1}>SSL</MenuItem>
|
|
||||||
<MenuItem value={2}>StartTLS</MenuItem>
|
|
||||||
</TextField>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_login"
|
|
||||||
label="Login"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_login}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedPasswordField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_pass"
|
|
||||||
label="Password"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_pass}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
<Grid container spacing={2} rowSpacing={0}>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_sender"
|
|
||||||
label="From"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_sender}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_recp"
|
|
||||||
label="To"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_recp}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<ValidatedTextField
|
|
||||||
fieldErrors={fieldErrors || {}}
|
|
||||||
name="email_subject"
|
|
||||||
label="Subject"
|
|
||||||
variant="outlined"
|
|
||||||
value={data.email_subject}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Grid>
|
|
||||||
<Grid>
|
|
||||||
<Button
|
|
||||||
sx={{ mt: 3 }}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
disabled={dirtyFlags.length !== 0}
|
|
||||||
onClick={sendmail}
|
|
||||||
>
|
|
||||||
Send test email
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pb: 1, pt: 2 }} variant="h6" color="primary">
|
||||||
{LL.SENSORS()}
|
{LL.SENSORS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -903,16 +751,6 @@ const ApplicationSettings = () => {
|
|||||||
}
|
}
|
||||||
label={LL.DEVELOPER_MODE()}
|
label={LL.DEVELOPER_MODE()}
|
||||||
/>
|
/>
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={data.disable_reset}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
name="disable_reset"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.DISABLE_RESET()}
|
|
||||||
/>
|
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
@@ -26,7 +27,6 @@ import {
|
|||||||
SingleUpload,
|
SingleUpload,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { saveFile } from 'utils';
|
import { saveFile } from 'utils';
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ const DownloadUpload = () => {
|
|||||||
|
|
||||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||||
|
|
||||||
const doRestart = async () => {
|
const doRestart = useCallback(async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
try {
|
try {
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||||
@@ -65,33 +65,16 @@ const DownloadUpload = () => {
|
|||||||
toast.error((error as Error).message);
|
toast.error((error as Error).message);
|
||||||
setRestarting(false);
|
setRestarting(false);
|
||||||
}
|
}
|
||||||
};
|
}, [sendAPI]);
|
||||||
|
|
||||||
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
useLayoutTitle(LL.DOWNLOAD_UPLOAD());
|
||||||
|
|
||||||
const handleCloseBackupDialog = () => {
|
const handleCloseBackupDialog = useCallback(() => {
|
||||||
setConfirmBackup(false);
|
setConfirmBackup(false);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDownload = (type: string) => () => {
|
const renderBackupDialog = useMemo(
|
||||||
void sendExportData(type);
|
() => (
|
||||||
setConfirmBackup(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (restarting) {
|
|
||||||
return <SystemMonitor />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<Dialog
|
<Dialog
|
||||||
sx={dialogStyle}
|
sx={dialogStyle}
|
||||||
open={confirmBackup}
|
open={confirmBackup}
|
||||||
@@ -115,13 +98,40 @@ const DownloadUpload = () => {
|
|||||||
<Button
|
<Button
|
||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={handleDownload('systembackup')}
|
onClick={() => handleDownload('systembackup')()}
|
||||||
color="primary"
|
color="primary"
|
||||||
>
|
>
|
||||||
{LL.DOWNLOAD(0)}
|
{LL.DOWNLOAD(0)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
),
|
||||||
|
[confirmBackup, handleCloseBackupDialog, LL]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
(type: string) => () => {
|
||||||
|
void sendExportData(type);
|
||||||
|
setConfirmBackup(false);
|
||||||
|
},
|
||||||
|
[sendExportData]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (restarting) {
|
||||||
|
return <SystemMonitor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
{renderBackupDialog}
|
||||||
|
|
||||||
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pb: 2 }} variant="h6" color="primary">
|
||||||
{LL.DOWNLOAD(0)}
|
{LL.DOWNLOAD(0)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
|
|
||||||
import * as MqttApi from 'api/mqtt';
|
import * as MqttApi from 'api/mqtt';
|
||||||
|
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -26,12 +28,10 @@ import {
|
|||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { MqttSettingsType } from 'types';
|
import type { MqttSettingsType } from 'types';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
|
import { ValidationError, createMqttSettingsValidator, validate } from 'validators';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import { callAction } from '../../api/app';
|
import { callAction } from '../../api/app';
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ const MqttSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const sendResetMQTT = () => {
|
const sendResetMQTT = useCallback(() => {
|
||||||
void callAction({ action: 'resetMQTT' })
|
void callAction({ action: 'resetMQTT' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||||
@@ -65,20 +65,29 @@ const MqttSettings = () => {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValueDirty(
|
||||||
origData as unknown as Record<string, unknown>,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
|
),
|
||||||
|
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const SecondsInputProps = {
|
const SecondsInputProps = useMemo(
|
||||||
|
() => ({
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
};
|
}),
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
const emptyFieldErrors = useMemo(() => ({}), []);
|
||||||
|
|
||||||
|
const validateAndSubmit = useCallback(async () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -87,9 +96,10 @@ const MqttSettings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [data, saveData]);
|
||||||
|
|
||||||
const publishIntervalFields = [
|
const publishIntervalFields = useMemo(
|
||||||
|
() => [
|
||||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||||
{
|
{
|
||||||
@@ -102,7 +112,9 @@ const MqttSettings = () => {
|
|||||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), 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_sensor', label: LL.SENSORS(), validated: false },
|
||||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||||
];
|
],
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +154,7 @@ const MqttSettings = () => {
|
|||||||
<Grid container spacing={2} rowSpacing={0}>
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? {}}
|
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||||
name="host"
|
name="host"
|
||||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||||
multiline
|
multiline
|
||||||
@@ -154,7 +166,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? {}}
|
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||||
name="port"
|
name="port"
|
||||||
label="Port"
|
label="Port"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -166,7 +178,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? {}}
|
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||||
name="base"
|
name="base"
|
||||||
label={LL.BASE_TOPIC()}
|
label={LL.BASE_TOPIC()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -207,7 +219,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? {}}
|
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||||
name="keep_alive"
|
name="keep_alive"
|
||||||
label="Keep Alive"
|
label="Keep Alive"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -426,7 +438,7 @@ const MqttSettings = () => {
|
|||||||
<Grid key={field.name}>
|
<Grid key={field.name}>
|
||||||
{field.validated ? (
|
{field.validated ? (
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? {}}
|
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -23,6 +24,7 @@ import { readNTPSettings } from 'api/ntp';
|
|||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { updateState } from 'alova/client';
|
import { updateState } from 'alova/client';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -32,13 +34,11 @@ import {
|
|||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { NTPSettingsType, Time } from 'types';
|
import type { NTPSettingsType, Time } from 'types';
|
||||||
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
import { formatLocalDateTime, updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
import { NTP_SETTINGS_VALIDATOR } from 'validators/ntp';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TIME_ZONES,
|
TIME_ZONES,
|
||||||
@@ -67,16 +67,19 @@ const NTPSettings = () => {
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle('NTP');
|
useLayoutTitle('NTP');
|
||||||
|
|
||||||
|
// Memoized timezone select items for better performance
|
||||||
const timeZoneItems = useTimeZoneSelectItems();
|
const timeZoneItems = useTimeZoneSelectItems();
|
||||||
const timeZoneItemsT = timeZoneSelectItemsT();
|
const timeZoneItemsT = timeZoneSelectItemsT();
|
||||||
|
|
||||||
const selectedTzValue = data
|
// Memoized selected timezone value
|
||||||
? selectedTimeZone(data.tz_label, data.tz_format)
|
const selectedTzValue = useMemo(
|
||||||
: undefined;
|
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
||||||
|
[data?.tz_label, data?.tz_format]
|
||||||
const selectedTzValueT = data
|
);
|
||||||
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
|
const selectedTzValueT = useMemo(
|
||||||
: undefined;
|
() => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined),
|
||||||
|
[data?.tz_label_t, data?.tz_format_t]
|
||||||
|
);
|
||||||
const [localTime, setLocalTime] = useState<string>('');
|
const [localTime, setLocalTime] = useState<string>('');
|
||||||
const [settingTime, setSettingTime] = useState<boolean>(false);
|
const [settingTime, setSettingTime] = useState<boolean>(false);
|
||||||
const [processing, setProcessing] = useState<boolean>(false);
|
const [processing, setProcessing] = useState<boolean>(false);
|
||||||
@@ -89,22 +92,32 @@ const NTPSettings = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateFormValue = updateValueDirty(
|
// Memoize updateFormValue to prevent recreation on every render
|
||||||
|
const updateFormValue = useMemo(
|
||||||
|
() =>
|
||||||
|
updateValueDirty(
|
||||||
origData as unknown as Record<string, unknown>,
|
origData as unknown as Record<string, unknown>,
|
||||||
dirtyFlags,
|
dirtyFlags,
|
||||||
setDirtyFlags,
|
setDirtyFlags,
|
||||||
updateDataValue as (value: unknown) => void
|
updateDataValue as (value: unknown) => void
|
||||||
|
),
|
||||||
|
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
// Memoize updateLocalTime handler
|
||||||
setLocalTime(event.target.value);
|
const updateLocalTime = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const openSetTime = () => {
|
// Memoize openSetTime handler
|
||||||
|
const openSetTime = useCallback(() => {
|
||||||
setLocalTime(formatLocalDateTime(new Date()));
|
setLocalTime(formatLocalDateTime(new Date()));
|
||||||
setSettingTime(true);
|
setSettingTime(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const configureTime = async () => {
|
// Memoize configureTime handler
|
||||||
|
const configureTime = useCallback(async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -117,11 +130,13 @@ const NTPSettings = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [localTime, updateTime, LL, loadData]);
|
||||||
|
|
||||||
const handleCloseSetTime = () => setSettingTime(false);
|
// Memoize close dialog handler
|
||||||
|
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
||||||
|
|
||||||
const validateAndSubmit = async () => {
|
// Memoize validate and submit handler
|
||||||
|
const validateAndSubmit = useCallback(async () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -130,27 +145,35 @@ const NTPSettings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
};
|
}, [data, saveData]);
|
||||||
|
|
||||||
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
// Memoize timezone change handler
|
||||||
|
const changeTimeZone = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
...settings,
|
...settings,
|
||||||
tz_label: event.target.value,
|
tz_label: event.target.value,
|
||||||
tz_format: TIME_ZONES[event.target.value]
|
tz_format: TIME_ZONES[event.target.value]
|
||||||
}));
|
}));
|
||||||
updateFormValue(event);
|
updateFormValue(event);
|
||||||
};
|
},
|
||||||
|
[updateFormValue]
|
||||||
|
);
|
||||||
|
|
||||||
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const changeTimeZoneT = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
...settings,
|
...settings,
|
||||||
tz_label_t: event.target.value,
|
tz_label_t: event.target.value,
|
||||||
tz_format_t: TIME_ZONES[event.target.value]
|
tz_format_t: TIME_ZONES[event.target.value]
|
||||||
}));
|
}));
|
||||||
updateFormValue(event);
|
updateFormValue(event);
|
||||||
};
|
},
|
||||||
|
[updateFormValue]
|
||||||
|
);
|
||||||
|
|
||||||
const renderContent = () => {
|
// Memoize render content to prevent unnecessary re-renders
|
||||||
|
const renderContent = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
@@ -276,12 +299,27 @@ const NTPSettings = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}, [
|
||||||
|
data,
|
||||||
|
errorMessage,
|
||||||
|
loadData,
|
||||||
|
updateFormValue,
|
||||||
|
fieldErrors,
|
||||||
|
selectedTzValue,
|
||||||
|
selectedTzValueT,
|
||||||
|
changeTimeZone,
|
||||||
|
timeZoneItems,
|
||||||
|
dirtyFlags,
|
||||||
|
openSetTime,
|
||||||
|
saving,
|
||||||
|
validateAndSubmit,
|
||||||
|
LL
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
{renderContent()}
|
{renderContent}
|
||||||
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
<Dialog sx={dialogStyle} open={settingTime} onClose={handleCloseSetTime}>
|
||||||
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
|
|||||||
@@ -1,43 +1,67 @@
|
|||||||
import { useContext } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import BuildIcon from '@mui/icons-material/Build';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
import TuneIcon from '@mui/icons-material/Tune';
|
import TuneIcon from '@mui/icons-material/Tune';
|
||||||
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
import ViewModuleIcon from '@mui/icons-material/ViewModule';
|
||||||
import { List } from '@mui/material';
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
List
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { API } from 'api/app';
|
||||||
|
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import type { APIcall } from 'app/main/types';
|
||||||
import { SectionContent, useLayoutTitle } from 'components';
|
import { SectionContent, useLayoutTitle } from 'components';
|
||||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
|
import SystemMonitor from '../status/SystemMonitor';
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const { versions } = useContext(AuthenticatedContext);
|
|
||||||
useLayoutTitle(LL.SETTINGS(0));
|
useLayoutTitle(LL.SETTINGS(0));
|
||||||
|
|
||||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
||||||
const firmwareText = versions?.current?.version
|
const [restarting, setRestarting] = useState<boolean>();
|
||||||
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const doFormat = useCallback(async () => {
|
||||||
|
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||||
|
setRestarting(true);
|
||||||
|
setConfirmFactoryReset(false);
|
||||||
|
});
|
||||||
|
}, [sendAPI]);
|
||||||
|
|
||||||
|
const handleFactoryResetClose = useCallback(() => {
|
||||||
|
setConfirmFactoryReset(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFactoryResetClick = useCallback(() => {
|
||||||
|
setConfirmFactoryReset(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<>
|
||||||
<List>
|
<List>
|
||||||
<ListMenuItem
|
|
||||||
icon={BuildIcon}
|
|
||||||
bgcolor="#72caf9"
|
|
||||||
label="EMS-ESP Firmware"
|
|
||||||
text={firmwareText}
|
|
||||||
to="/settings/version"
|
|
||||||
badge={upgradeAvailable}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={TuneIcon}
|
icon={TuneIcon}
|
||||||
bgcolor="#134ba2"
|
bgcolor="#134ba2"
|
||||||
@@ -101,8 +125,66 @@ const Settings = () => {
|
|||||||
to="downloadUpload"
|
to="downloadUpload"
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</SectionContent>
|
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmFactoryReset}
|
||||||
|
onClose={handleFactoryResetClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doFormat}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
}, [
|
||||||
|
LL,
|
||||||
|
handleFactoryResetClick,
|
||||||
|
handleFactoryResetClose,
|
||||||
|
doFormat,
|
||||||
|
confirmFactoryReset,
|
||||||
|
restarting
|
||||||
|
]);
|
||||||
|
|
||||||
|
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Settings;
|
export default Settings;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { MenuItem } from '@mui/material';
|
import { MenuItem } from '@mui/material';
|
||||||
|
|
||||||
export const TIME_ZONES: Record<string, string> = {
|
export const TIME_ZONES: Record<string, string> = {
|
||||||
@@ -470,16 +472,26 @@ export function selectedTimeZone(label: string, format: string) {
|
|||||||
return TIME_ZONES[label] === format ? label : undefined;
|
return TIME_ZONES[label] === format ? label : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Memoized version for use in components
|
||||||
|
export function useTimeZoneSelectItems() {
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
TIME_ZONE_LABELS.map((label) => (
|
||||||
|
<MenuItem key={label} value={label}>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback export for backward compatibility - now memoized
|
||||||
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
const precomputedTimeZoneItems = TIME_ZONE_LABELS.map((label) => (
|
||||||
<MenuItem key={label} value={label}>
|
<MenuItem key={label} value={label}>
|
||||||
{label}
|
{label}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
));
|
||||||
|
|
||||||
export function useTimeZoneSelectItems() {
|
|
||||||
return precomputedTimeZoneItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timeZoneSelectItems() {
|
export function timeZoneSelectItems() {
|
||||||
return precomputedTimeZoneItems;
|
return precomputedTimeZoneItems;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,944 +0,0 @@
|
|||||||
import { memo, useContext, useMemo, useState } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import CheckIcon from '@mui/icons-material/Done';
|
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
|
||||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
|
||||||
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
|
||||||
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
|
||||||
import WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableRow,
|
|
||||||
Typography
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import * as SystemApi from 'api/system';
|
|
||||||
import { API, callAction } from 'api/app';
|
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import type { APIcall } from 'app/main/types';
|
|
||||||
import SystemMonitor from 'app/status/SystemMonitor';
|
|
||||||
import {
|
|
||||||
FormLoader,
|
|
||||||
SectionContent,
|
|
||||||
SingleUpload,
|
|
||||||
useLayoutTitle
|
|
||||||
} from 'components';
|
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
import type { TranslationFunctions } from 'i18n/i18n-types';
|
|
||||||
import type { VersionInfo } from 'types';
|
|
||||||
import { prettyDateTime } from 'utils/time';
|
|
||||||
|
|
||||||
// Constants moved outside component to avoid recreation
|
|
||||||
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
|
||||||
const STABLE_RELNOTES_URL =
|
|
||||||
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
|
||||||
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
|
||||||
const DEV_RELNOTES_URL =
|
|
||||||
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
|
||||||
|
|
||||||
// Types for better type safety
|
|
||||||
interface PartitionData {
|
|
||||||
partition: string;
|
|
||||||
version: string;
|
|
||||||
install_date?: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VersionData {
|
|
||||||
emsesp_version: string;
|
|
||||||
arduino_version: string;
|
|
||||||
esp_platform: string;
|
|
||||||
flash_chip_size: number;
|
|
||||||
psram: boolean;
|
|
||||||
build_flags?: string;
|
|
||||||
partition: string;
|
|
||||||
partitions: PartitionData[];
|
|
||||||
developer_mode: boolean;
|
|
||||||
disable_reset: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized components for better performance
|
|
||||||
const VersionInfoDialog = memo(
|
|
||||||
({
|
|
||||||
showVersionInfo,
|
|
||||||
latestVersion,
|
|
||||||
latestDevVersion,
|
|
||||||
partitionVersion,
|
|
||||||
partition,
|
|
||||||
currentPartition,
|
|
||||||
size,
|
|
||||||
locale,
|
|
||||||
LL,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
showVersionInfo: number;
|
|
||||||
latestVersion: VersionInfo | undefined;
|
|
||||||
latestDevVersion: VersionInfo | undefined;
|
|
||||||
partitionVersion: VersionInfo | undefined;
|
|
||||||
partition: string;
|
|
||||||
currentPartition: string;
|
|
||||||
size: number;
|
|
||||||
locale: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
}) => {
|
|
||||||
if (showVersionInfo === 0) return null;
|
|
||||||
|
|
||||||
const isStable = showVersionInfo === 1;
|
|
||||||
const isDev = showVersionInfo === 2;
|
|
||||||
const isPartition = showVersionInfo === 3;
|
|
||||||
|
|
||||||
const version = isStable
|
|
||||||
? latestVersion
|
|
||||||
: isDev
|
|
||||||
? latestDevVersion
|
|
||||||
: partitionVersion;
|
|
||||||
const relNotesUrl = isStable
|
|
||||||
? STABLE_RELNOTES_URL
|
|
||||||
: isDev
|
|
||||||
? DEV_RELNOTES_URL
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
|
||||||
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{LL.VERSION()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{isPartition
|
|
||||||
? typeof version === 'string'
|
|
||||||
? version
|
|
||||||
: version?.version
|
|
||||||
: version?.version}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13,
|
|
||||||
width: 140
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{partition === currentPartition && LL.ACTIVE() + ' '}
|
|
||||||
{isStable
|
|
||||||
? LL.STABLE()
|
|
||||||
: isDev
|
|
||||||
? LL.DEVELOPMENT()
|
|
||||||
: 'Partition ' + LL.VERSION()}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{isPartition && (
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Partition
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{partition}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{isPartition && (
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Size
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{size} KB
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{version && version.date && (
|
|
||||||
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
|
||||||
<TableCell
|
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
sx={{
|
|
||||||
color: 'lightblue',
|
|
||||||
borderBottom: 'none',
|
|
||||||
pr: 1,
|
|
||||||
py: 0.5,
|
|
||||||
fontSize: 13
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isPartition ? 'Install Date' : 'Build Date'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
|
||||||
{prettyDateTime(locale, new Date(version.date))}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
{!isPartition && (
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
component="a"
|
|
||||||
href={relNotesUrl}
|
|
||||||
target="_blank"
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
Changelog
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="outlined" onClick={onClose} color="secondary">
|
|
||||||
{LL.CLOSE()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const InstallDialog = memo(
|
|
||||||
({
|
|
||||||
openInstallDialog,
|
|
||||||
fetchDevVersion,
|
|
||||||
latestVersion,
|
|
||||||
latestDevVersion,
|
|
||||||
upgradeImportantMessageType,
|
|
||||||
platform,
|
|
||||||
LL,
|
|
||||||
onClose,
|
|
||||||
onInstall
|
|
||||||
}: {
|
|
||||||
openInstallDialog: boolean;
|
|
||||||
fetchDevVersion: boolean;
|
|
||||||
latestVersion: VersionInfo | undefined;
|
|
||||||
latestDevVersion: VersionInfo | undefined;
|
|
||||||
upgradeImportantMessageType: number;
|
|
||||||
platform: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (url: string) => void;
|
|
||||||
}) => {
|
|
||||||
const binURL = (() => {
|
|
||||||
if (!latestVersion || !latestDevVersion) return '';
|
|
||||||
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
|
||||||
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
|
|
||||||
return fetchDevVersion
|
|
||||||
? `${DEV_URL}${filename}`
|
|
||||||
: `${STABLE_URL}v${version.version}/${filename}`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Typography sx={{ mb: 2 }}>
|
|
||||||
{LL.INSTALL_VERSION(
|
|
||||||
LL.INSTALL(),
|
|
||||||
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
|
|
||||||
{upgradeImportantMessageType === 1 && (
|
|
||||||
<>
|
|
||||||
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
|
|
||||||
<Typography sx={{ mt: 2 }}>
|
|
||||||
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
|
|
||||||
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Typography sx={{ mt: 2 }}>
|
|
||||||
<Link
|
|
||||||
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{ color: 'lightblue' }}
|
|
||||||
>
|
|
||||||
{LL.ONLINE_HELP()}
|
|
||||||
</Link>
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<DownloadIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={binURL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
style={{ color: 'lightblue', textDecoration: 'none' }}
|
|
||||||
>
|
|
||||||
{LL.DOWNLOAD(0)}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onInstall(binURL)}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const InstallPartitionDialog = memo(
|
|
||||||
({
|
|
||||||
openInstallPartitionDialog,
|
|
||||||
version,
|
|
||||||
partition,
|
|
||||||
LL,
|
|
||||||
onClose,
|
|
||||||
onInstall
|
|
||||||
}: {
|
|
||||||
openInstallPartitionDialog: boolean;
|
|
||||||
version: string;
|
|
||||||
partition: string;
|
|
||||||
LL: TranslationFunctions;
|
|
||||||
onClose: () => void;
|
|
||||||
onInstall: (partition: string) => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
|
||||||
<DialogTitle>
|
|
||||||
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Typography sx={{ mb: 2 }}>
|
|
||||||
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
|
||||||
</Typography>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
startIcon={<WarningIcon color="warning" />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onInstall(partition)}
|
|
||||||
color="primary"
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Helper function moved outside component
|
|
||||||
const getPlatform = (data: VersionData): string => {
|
|
||||||
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Version = () => {
|
|
||||||
const { LL, locale } = useI18nContext();
|
|
||||||
const { me, versions } = useContext(AuthenticatedContext);
|
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState<boolean>(false);
|
|
||||||
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
|
||||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
|
||||||
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
const [partition, setPartition] = useState<string>('');
|
|
||||||
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
|
||||||
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
|
||||||
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
|
||||||
|
|
||||||
const latestVersion = useMemo<VersionInfo | undefined>(
|
|
||||||
() =>
|
|
||||||
versions?.stable
|
|
||||||
? { version: versions.stable.version, date: versions.stable.date }
|
|
||||||
: undefined,
|
|
||||||
[versions?.stable]
|
|
||||||
);
|
|
||||||
const latestDevVersion = useMemo<VersionInfo | undefined>(
|
|
||||||
() =>
|
|
||||||
versions?.dev
|
|
||||||
? { version: versions.dev.version, date: versions.dev.date }
|
|
||||||
: undefined,
|
|
||||||
[versions?.dev]
|
|
||||||
);
|
|
||||||
const usingDevVersion = versions?.current?.type === 'dev';
|
|
||||||
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
|
|
||||||
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
|
|
||||||
const internetLive = Boolean(versions?.stable || versions?.dev);
|
|
||||||
|
|
||||||
const { send: sendSetPartition } = useRequest(
|
|
||||||
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
|
||||||
{ immediate: false }
|
|
||||||
).onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
|
||||||
|
|
||||||
const { send: sendUploadURL } = useRequest(
|
|
||||||
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
|
||||||
{ immediate: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
|
||||||
immediate: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
|
|
||||||
useState<number>(0);
|
|
||||||
|
|
||||||
const { send: checkUpgradeImportantMessages } = useRequest(
|
|
||||||
(version: string) =>
|
|
||||||
callAction({ action: 'upgradeImportantMessages', param: version }),
|
|
||||||
{
|
|
||||||
immediate: false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.onSuccess((event) => {
|
|
||||||
const upgradeImportantMessageType_n = (
|
|
||||||
event.data as { upgradeImportantMessageType: number }
|
|
||||||
).upgradeImportantMessageType;
|
|
||||||
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
|
||||||
})
|
|
||||||
.onError((error) => {
|
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const platform = data ? getPlatform(data) : '';
|
|
||||||
|
|
||||||
const otherPartitions =
|
|
||||||
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
|
|
||||||
|
|
||||||
const setPartitionVersionInfo = (partition: string) => {
|
|
||||||
setShowVersionInfo(3);
|
|
||||||
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
|
||||||
if (partitionData) {
|
|
||||||
setPartitionVersion({
|
|
||||||
version: partitionData.version,
|
|
||||||
date: partitionData.install_date ?? ''
|
|
||||||
});
|
|
||||||
setPartition(partitionData.partition);
|
|
||||||
setFirmwareSize(partitionData.size);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const doRestart = async () => {
|
|
||||||
setConfirmRestart(false);
|
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
|
||||||
(error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setRestarting(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const doFormat = async () => {
|
|
||||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
|
||||||
setRestarting(true);
|
|
||||||
setConfirmFactoryReset(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
|
|
||||||
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
|
|
||||||
const handleRestartClose = () => setConfirmRestart(false);
|
|
||||||
const handleRestartClick = () => setConfirmRestart(true);
|
|
||||||
|
|
||||||
const installFirmwareURL = async (url: string) => {
|
|
||||||
await sendUploadURL(url).catch((error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
});
|
|
||||||
await doRestart();
|
|
||||||
};
|
|
||||||
|
|
||||||
const installPartitionFirmware = async (partition: string) => {
|
|
||||||
await sendSetPartition(partition).catch((error: Error) => {
|
|
||||||
toast.error(error.message);
|
|
||||||
});
|
|
||||||
setRestarting(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showPartitionDialog = (
|
|
||||||
version: string,
|
|
||||||
partition: string,
|
|
||||||
install_date: string
|
|
||||||
) => {
|
|
||||||
setOpenInstallPartitionDialog(true);
|
|
||||||
setPartitionVersion({ version: version, date: install_date });
|
|
||||||
setPartition(partition);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showFirmwareDialog = (useDevVersion: boolean) => {
|
|
||||||
setFetchDevVersion(useDevVersion);
|
|
||||||
const targetVersion = useDevVersion
|
|
||||||
? latestDevVersion?.version
|
|
||||||
: latestVersion?.version;
|
|
||||||
if (targetVersion) {
|
|
||||||
void checkUpgradeImportantMessages(targetVersion);
|
|
||||||
}
|
|
||||||
setOpenInstallDialog(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeInstallDialog = () => setOpenInstallDialog(false);
|
|
||||||
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
|
|
||||||
|
|
||||||
const handleVersionInfoClose = () => {
|
|
||||||
setShowVersionInfo(0);
|
|
||||||
setPartitionVersion(undefined);
|
|
||||||
setPartition('');
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutTitle('EMS-ESP Firmware');
|
|
||||||
|
|
||||||
const showButtons = (showingDev: boolean) => {
|
|
||||||
const choice = showingDev
|
|
||||||
? !usingDevVersion
|
|
||||||
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
|
||||||
: devUpgradeAvailable
|
|
||||||
? LL.UPDATE_AVAILABLE()
|
|
||||||
: undefined
|
|
||||||
: usingDevVersion
|
|
||||||
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
|
||||||
: stableUpgradeAvailable
|
|
||||||
? LL.UPDATE_AVAILABLE()
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (!choice) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CheckIcon
|
|
||||||
color="success"
|
|
||||||
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
|
||||||
/>
|
|
||||||
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
|
||||||
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() => showFirmwareDialog(showingDev)}
|
|
||||||
>
|
|
||||||
{LL.REINSTALL()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!me.admin) return null;
|
|
||||||
|
|
||||||
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 1 }}
|
|
||||||
variant="outlined"
|
|
||||||
color={isUpdateAvailable ? 'success' : 'warning'}
|
|
||||||
size="small"
|
|
||||||
onClick={() => showFirmwareDialog(showingDev)}
|
|
||||||
>
|
|
||||||
{choice}
|
|
||||||
{isUpdateAvailable && (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
aria-label="update available"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
ml: 1,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#ffeb3b',
|
|
||||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (restarting) {
|
|
||||||
return <SystemMonitor />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
|
|
||||||
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
|
|
||||||
{LL.THIS_VERSION()}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
alignItems: 'baseline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.VERSION()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{data.emsesp_version}
|
|
||||||
{data.build_flags && (
|
|
||||||
<Typography variant="caption">
|
|
||||||
({data.build_flags})
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setPartitionVersionInfo(data.partition)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{platform}
|
|
||||||
<Typography variant="caption">
|
|
||||||
(
|
|
||||||
{data.psram ? (
|
|
||||||
<CheckIcon
|
|
||||||
color="success"
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.5em',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CloseIcon
|
|
||||||
color="error"
|
|
||||||
sx={{
|
|
||||||
fontSize: '1.5em',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
PSRAM)
|
|
||||||
</Typography>
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{internetLive ? (
|
|
||||||
<>
|
|
||||||
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
|
|
||||||
{LL.AVAILABLE_VERSION()}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Grid
|
|
||||||
container
|
|
||||||
direction="row"
|
|
||||||
rowSpacing={1}
|
|
||||||
sx={{
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
alignItems: 'baseline'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{otherPartitions.length > 0 && data.developer_mode && (
|
|
||||||
<>
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
{otherPartitions.map((partition) => (
|
|
||||||
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
|
||||||
{partition.version}
|
|
||||||
<IconButton
|
|
||||||
onClick={() =>
|
|
||||||
setPartitionVersionInfo(partition.partition)
|
|
||||||
}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 0 }}
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
onClick={() =>
|
|
||||||
showPartitionDialog(
|
|
||||||
partition.version,
|
|
||||||
partition.partition,
|
|
||||||
partition.install_date ?? ''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{LL.INSTALL()}
|
|
||||||
</Button>
|
|
||||||
</Typography>
|
|
||||||
))}
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.STABLE()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{latestVersion?.version}
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowVersionInfo(1)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
{showButtons(false)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid size={{ xs: 4, md: 2 }}>
|
|
||||||
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 8, md: 10 }}>
|
|
||||||
<Typography>
|
|
||||||
{latestDevVersion?.version}
|
|
||||||
<IconButton
|
|
||||||
onClick={() => setShowVersionInfo(2)}
|
|
||||||
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
|
||||||
>
|
|
||||||
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
|
||||||
</IconButton>
|
|
||||||
{showButtons(true)}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography sx={{ mt: 2 }} color="warning">
|
|
||||||
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
|
||||||
{LL.INTERNET_CONNECTION_REQUIRED()}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{me.admin && (
|
|
||||||
<>
|
|
||||||
<VersionInfoDialog
|
|
||||||
showVersionInfo={showVersionInfo}
|
|
||||||
latestVersion={latestVersion}
|
|
||||||
latestDevVersion={latestDevVersion}
|
|
||||||
partitionVersion={partitionVersion}
|
|
||||||
locale={locale}
|
|
||||||
partition={partition}
|
|
||||||
currentPartition={data?.partition ?? ''}
|
|
||||||
size={firmwareSize}
|
|
||||||
LL={LL}
|
|
||||||
onClose={handleVersionInfoClose}
|
|
||||||
/>
|
|
||||||
<InstallDialog
|
|
||||||
openInstallDialog={openInstallDialog}
|
|
||||||
fetchDevVersion={fetchDevVersion}
|
|
||||||
latestVersion={latestVersion}
|
|
||||||
latestDevVersion={latestDevVersion}
|
|
||||||
upgradeImportantMessageType={upgradeImportantMessageType}
|
|
||||||
platform={platform}
|
|
||||||
LL={LL}
|
|
||||||
onClose={closeInstallDialog}
|
|
||||||
onInstall={installFirmwareURL}
|
|
||||||
/>
|
|
||||||
<InstallPartitionDialog
|
|
||||||
openInstallPartitionDialog={openInstallPartitionDialog}
|
|
||||||
version={partitionVersion?.version || ''}
|
|
||||||
partition={partition}
|
|
||||||
LL={LL}
|
|
||||||
onClose={closeInstallPartitionDialog}
|
|
||||||
onInstall={installPartitionFirmware}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
|
||||||
{LL.UPLOAD()}
|
|
||||||
</Typography>
|
|
||||||
<SingleUpload doRestart={doRestart} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{me.admin && (
|
|
||||||
<>
|
|
||||||
<Dialog
|
|
||||||
sx={dialogStyle}
|
|
||||||
open={confirmFactoryReset}
|
|
||||||
onClose={handleFactoryResetClose}
|
|
||||||
>
|
|
||||||
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
|
||||||
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleFactoryResetClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={doFormat}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
sx={dialogStyle}
|
|
||||||
open={confirmRestart}
|
|
||||||
onClose={handleRestartClose}
|
|
||||||
>
|
|
||||||
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
|
||||||
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleRestartClose}
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={doRestart}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
gap: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleRestartClick}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
{!data.disable_reset && (
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleFactoryResetClick}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(Version);
|
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { Outlet, useMatch, useNavigate } from 'react-router';
|
import {
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Routes,
|
||||||
|
matchRoutes,
|
||||||
|
useLocation,
|
||||||
|
useNavigate
|
||||||
|
} from 'react-router';
|
||||||
|
|
||||||
import { Tab } from '@mui/material';
|
import { Tab } from '@mui/material';
|
||||||
|
|
||||||
@@ -7,32 +14,52 @@ import { RouterTabs, useLayoutTitle } from 'components';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { WiFiNetwork } from 'types';
|
import type { WiFiNetwork } from 'types';
|
||||||
|
|
||||||
|
import NetworkSettings from './NetworkSettings';
|
||||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||||
|
import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||||
|
|
||||||
const Network = () => {
|
const Network = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.NETWORK(0));
|
useLayoutTitle(LL.NETWORK(0));
|
||||||
|
|
||||||
const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
|
// this also works!
|
||||||
|
// const routerTab = useMatch(`settings/network/:path/*`)?.pathname || false;
|
||||||
|
const matchedRoutes = matchRoutes(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/settings/network/settings',
|
||||||
|
element: <NetworkSettings />
|
||||||
|
},
|
||||||
|
{ path: '/settings/network/scan', element: <WiFiNetworkScanner /> }
|
||||||
|
],
|
||||||
|
useLocation()
|
||||||
|
);
|
||||||
|
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
|
||||||
|
|
||||||
const selectNetwork = (network: WiFiNetwork) => {
|
const selectNetwork = useCallback(
|
||||||
|
(network: WiFiNetwork) => {
|
||||||
setSelectedNetwork(network);
|
setSelectedNetwork(network);
|
||||||
void navigate('/settings/network/settings');
|
void navigate('/settings/network/settings');
|
||||||
};
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const deselectNetwork = () => {
|
const deselectNetwork = useCallback(() => {
|
||||||
setSelectedNetwork(undefined);
|
setSelectedNetwork(undefined);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
...(selectedNetwork && { selectedNetwork }),
|
...(selectedNetwork && { selectedNetwork }),
|
||||||
selectNetwork,
|
selectNetwork,
|
||||||
deselectNetwork
|
deselectNetwork
|
||||||
};
|
}),
|
||||||
|
[selectedNetwork, selectNetwork, deselectNetwork]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WiFiConnectionContext.Provider value={contextValue}>
|
<WiFiConnectionContext.Provider value={contextValue}>
|
||||||
@@ -43,7 +70,14 @@ const Network = () => {
|
|||||||
/>
|
/>
|
||||||
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
||||||
</RouterTabs>
|
</RouterTabs>
|
||||||
<Outlet />
|
<Routes>
|
||||||
|
<Route path="scan" element={<WiFiNetworkScanner />} />
|
||||||
|
<Route path="settings" element={<NetworkSettings />} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate replace to="/settings/network/settings" />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</WiFiConnectionContext.Provider>
|
</WiFiConnectionContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
@@ -25,6 +26,7 @@ import { API } from 'api/app';
|
|||||||
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
import { updateState, useRequest } from 'alova/client';
|
||||||
import type { APIcall } from 'app/main/types';
|
import type { APIcall } from 'app/main/types';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -35,13 +37,11 @@ import {
|
|||||||
ValidatedPasswordField,
|
ValidatedPasswordField,
|
||||||
ValidatedTextField
|
ValidatedTextField
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { NetworkSettingsType } from 'types';
|
import type { NetworkSettingsType } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import { createNetworkSettingsValidator } from 'validators/network';
|
import { createNetworkSettingsValidator } from 'validators/network';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
import SystemMonitor from '../../status/SystemMonitor';
|
import SystemMonitor from '../../status/SystemMonitor';
|
||||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||||
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
|
|||||||
static_ip_config: false,
|
static_ip_config: false,
|
||||||
bandwidth20: false,
|
bandwidth20: false,
|
||||||
tx_power: 0,
|
tx_power: 0,
|
||||||
nosleep: true,
|
nosleep: false,
|
||||||
enableMDNS: true,
|
enableMDNS: true,
|
||||||
enableCORS: false,
|
enableCORS: false,
|
||||||
CORSOrigin: '*'
|
CORSOrigin: '*'
|
||||||
@@ -121,19 +121,19 @@ const NetworkSettings = () => {
|
|||||||
deselectNetwork();
|
deselectNetwork();
|
||||||
}, [data, saveData, deselectNetwork]);
|
}, [data, saveData, deselectNetwork]);
|
||||||
|
|
||||||
const setCancel = async () => {
|
const setCancel = useCallback(async () => {
|
||||||
deselectNetwork();
|
deselectNetwork();
|
||||||
await loadData();
|
await loadData();
|
||||||
};
|
}, [deselectNetwork, loadData]);
|
||||||
|
|
||||||
const doRestart = async () => {
|
const doRestart = useCallback(async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
};
|
}, [sendAPI]);
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="ssid"
|
name="ssid"
|
||||||
label="SSID"
|
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={data.ssid}
|
value={data.ssid}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useRef, useState } from 'react';
|
import { memo, useCallback, useRef, useState } from 'react';
|
||||||
|
|
||||||
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
|
||||||
import { Button } from '@mui/material';
|
import { Button } from '@mui/material';
|
||||||
@@ -48,12 +48,12 @@ const WiFiNetworkScanner = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderNetworkScanner = () => {
|
const renderNetworkScanner = useCallback(() => {
|
||||||
if (!networkList) {
|
if (!networkList) {
|
||||||
return <FormLoader errorMessage={errorMessage || ''} />;
|
return <FormLoader errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
return <WiFiNetworkSelector networkList={networkList} />;
|
return <WiFiNetworkSelector networkList={networkList} />;
|
||||||
};
|
}, [networkList, errorMessage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { memo, useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import LockIcon from '@mui/icons-material/Lock';
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
@@ -63,7 +63,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
|
|
||||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||||
|
|
||||||
const renderNetwork = (network: WiFiNetwork) => (
|
const renderNetwork = useCallback(
|
||||||
|
(network: WiFiNetwork) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={network.bssid}
|
key={network.bssid}
|
||||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||||
@@ -88,6 +89,8 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
</Badge>
|
</Badge>
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
),
|
||||||
|
[wifiConnectionContext, theme]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (networkList.networks.length === 0) {
|
if (networkList.networks.length === 0) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useState } from 'react';
|
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
||||||
import { useBlocker } from 'react-router';
|
import { useBlocker } from 'react-router';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -55,7 +55,9 @@ const ManageUsers = () => {
|
|||||||
const blocker = useBlocker(changed !== 0);
|
const blocker = useBlocker(changed !== 0);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const table_theme = useTheme({
|
const table_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) minmax(120px, max-content) 120px;
|
||||||
`,
|
`,
|
||||||
@@ -93,36 +95,44 @@ const ManageUsers = () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
|
const noAdminConfigured = useCallback(
|
||||||
|
() => !data?.users.find((u) => u.admin),
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
const removeUser = (toRemove: UserType) => {
|
const removeUser = useCallback(
|
||||||
|
(toRemove: UserType) => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||||
updateDataValue({ ...data, users });
|
updateDataValue({ ...data, users });
|
||||||
setChanged(changed + 1);
|
setChanged(changed + 1);
|
||||||
};
|
},
|
||||||
|
[data, updateDataValue, changed]
|
||||||
|
);
|
||||||
|
|
||||||
const createUser = () => {
|
const createUser = useCallback(() => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setUser({
|
setUser({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
admin: true
|
admin: true
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const editUser = (toEdit: UserType) => {
|
const editUser = useCallback((toEdit: UserType) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setUser({ ...toEdit });
|
setUser({ ...toEdit });
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const cancelEditingUser = () => {
|
const cancelEditingUser = useCallback(() => {
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const doneEditingUser = () => {
|
const doneEditingUser = useCallback(() => {
|
||||||
if (user && data) {
|
if (user && data) {
|
||||||
const users = [
|
const users = [
|
||||||
...data.users.filter(
|
...data.users.filter(
|
||||||
@@ -134,26 +144,26 @@ const ManageUsers = () => {
|
|||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
setChanged(changed + 1);
|
setChanged(changed + 1);
|
||||||
}
|
}
|
||||||
};
|
}, [user, data, updateDataValue, changed]);
|
||||||
|
|
||||||
const closeGenerateToken = useCallback(() => {
|
const closeGenerateToken = useCallback(() => {
|
||||||
setGeneratingToken(undefined);
|
setGeneratingToken(undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const generateTokenForUser = (username: string) => {
|
const generateTokenForUser = useCallback((username: string) => {
|
||||||
setGeneratingToken(username);
|
setGeneratingToken(username);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = useCallback(async () => {
|
||||||
await saveData();
|
await saveData();
|
||||||
await authenticatedContext.refresh();
|
await authenticatedContext.refresh();
|
||||||
setChanged(0);
|
setChanged(0);
|
||||||
};
|
}, [saveData, authenticatedContext]);
|
||||||
|
|
||||||
const onCancelSubmit = async () => {
|
const onCancelSubmit = useCallback(async () => {
|
||||||
await loadData();
|
await loadData();
|
||||||
setChanged(0);
|
setChanged(0);
|
||||||
};
|
}, [loadData]);
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -167,10 +177,15 @@ const ManageUsers = () => {
|
|||||||
admin: boolean;
|
admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user_table = data.users.map((u) => ({
|
// add id to the type, needed for the table
|
||||||
|
const user_table = useMemo(
|
||||||
|
() =>
|
||||||
|
data.users.map((u) => ({
|
||||||
...u,
|
...u,
|
||||||
id: u.username
|
id: u.username
|
||||||
})) as UserType2[];
|
})) as UserType2[],
|
||||||
|
[data.users]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Outlet, useMatch } from 'react-router';
|
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { Tab } from '@mui/material';
|
import { Tab } from '@mui/material';
|
||||||
|
|
||||||
import { RouterTabs, useLayoutTitle } from 'components';
|
import { RouterTabs, useLayoutTitle } from 'components';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
|
import ManageUsers from './ManageUsers';
|
||||||
|
import SecuritySettings from './SecuritySettings';
|
||||||
|
|
||||||
const Security = () => {
|
const Security = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
useLayoutTitle(LL.SECURITY(0));
|
useLayoutTitle(LL.SECURITY(0));
|
||||||
|
|
||||||
const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
|
const location = useLocation();
|
||||||
|
|
||||||
|
const matchedRoutes = useMemo(
|
||||||
|
() =>
|
||||||
|
matchRoutes(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: '/settings/security/settings',
|
||||||
|
element: <ManageUsers />
|
||||||
|
},
|
||||||
|
{ path: '/settings/security/users', element: <SecuritySettings /> }
|
||||||
|
],
|
||||||
|
location
|
||||||
|
),
|
||||||
|
[location]
|
||||||
|
);
|
||||||
|
const routerTab = matchedRoutes?.[0]?.route.path || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -21,7 +40,14 @@ const Security = () => {
|
|||||||
/>
|
/>
|
||||||
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
||||||
</RouterTabs>
|
</RouterTabs>
|
||||||
<Outlet />
|
<Routes>
|
||||||
|
<Route path="users" element={<ManageUsers />} />
|
||||||
|
<Route path="settings" element={<SecuritySettings />} />
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={<Navigate replace to="/settings/security/settings" />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@mui/material';
|
|||||||
|
|
||||||
import * as SecurityApi from 'api/security';
|
import * as SecurityApi from 'api/security';
|
||||||
|
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
ButtonRow,
|
ButtonRow,
|
||||||
@@ -19,7 +20,6 @@ import { useI18nContext } from 'i18n/i18n-react';
|
|||||||
import type { SecuritySettingsType } from 'types';
|
import type { SecuritySettingsType } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
|
import { SECURITY_SETTINGS_VALIDATOR, ValidationError, validate } from 'validators';
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
const SecuritySettings = () => {
|
const SecuritySettings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -79,7 +79,7 @@ const SecuritySettings = () => {
|
|||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
|
<MessageBox level="info" message={LL.SU_TEXT()} mt={1} />
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import type Schema from 'async-validator';
|
||||||
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
ValidatedPasswordField,
|
ValidatedPasswordField,
|
||||||
@@ -23,8 +25,6 @@ import { useI18nContext } from 'i18n/i18n-react';
|
|||||||
import type { UserType } from 'types';
|
import type { UserType } from 'types';
|
||||||
import { updateValue } from 'utils';
|
import { updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import type Schema from 'validators/schema';
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
|
||||||
|
|
||||||
interface UserFormProps {
|
interface UserFormProps {
|
||||||
creating: boolean;
|
creating: boolean;
|
||||||
@@ -62,7 +62,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const validateAndDone = async () => {
|
const validateAndDone = useCallback(async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -72,7 +72,7 @@ const User: FC<UserFormProps> = ({
|
|||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [user, validator, onDoneEditing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Cell,
|
Cell,
|
||||||
@@ -34,7 +36,9 @@ const SystemActivity = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||||
|
|
||||||
const stats_theme = tableTheme({
|
const stats_theme = tableTheme(
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
||||||
`,
|
`,
|
||||||
@@ -70,15 +74,21 @@ const SystemActivity = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const showName = (id: number) => {
|
const showName = useCallback(
|
||||||
|
(id: number) => {
|
||||||
const name: keyof Translation['STATUS_NAMES'] =
|
const name: keyof Translation['STATUS_NAMES'] =
|
||||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||||
return LL.STATUS_NAMES[name]();
|
return LL.STATUS_NAMES[name]();
|
||||||
};
|
},
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
const showQuality = (stat: Stat) => {
|
const showQuality = useCallback((stat: Stat) => {
|
||||||
if (stat.q === 0 || stat.s + stat.f === 0) {
|
if (stat.q === 0 || stat.s + stat.f === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -90,18 +100,14 @@ const SystemActivity = () => {
|
|||||||
} else {
|
} else {
|
||||||
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<Table
|
<Table
|
||||||
data={{ nodes: data.stats }}
|
data={{ nodes: data.stats }}
|
||||||
theme={stats_theme}
|
theme={stats_theme}
|
||||||
@@ -130,8 +136,10 @@ const SystemActivity = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
</SectionContent>
|
|
||||||
);
|
);
|
||||||
|
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
|
||||||
|
|
||||||
|
return <SectionContent>{content}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemActivity;
|
export default SystemActivity;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, memo } from 'react';
|
import { type FC, memo, useMemo } from 'react';
|
||||||
|
|
||||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
@@ -127,15 +127,16 @@ const MqttStatus = () => {
|
|||||||
void loadData();
|
void loadData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Memoize error message separately to avoid re-renders on error object changes
|
||||||
const errorMessage = error?.message || '';
|
const errorMessage = error?.message || '';
|
||||||
|
|
||||||
const mqttStatusText = !data
|
const mqttStatusText = useMemo(() => {
|
||||||
? ''
|
if (!data) return '';
|
||||||
: !data.enabled
|
if (!data.enabled) return LL.NOT_ENABLED();
|
||||||
? LL.NOT_ENABLED()
|
return data.connected
|
||||||
: data.connected
|
|
||||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||||
|
}, [data, LL]);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import DnsIcon from '@mui/icons-material/Dns';
|
import DnsIcon from '@mui/icons-material/Dns';
|
||||||
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
|
||||||
@@ -65,16 +67,12 @@ const NTPStatus = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -123,8 +121,10 @@ const NTPStatus = () => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<Divider variant="inset" component="li" />
|
<Divider variant="inset" component="li" />
|
||||||
</List>
|
</List>
|
||||||
</SectionContent>
|
|
||||||
);
|
);
|
||||||
|
}, [data, error, loadData, LL, theme]);
|
||||||
|
|
||||||
|
return <SectionContent>{content}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NTPStatus;
|
export default NTPStatus;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import DnsIcon from '@mui/icons-material/Dns';
|
import DnsIcon from '@mui/icons-material/Dns';
|
||||||
import GiteIcon from '@mui/icons-material/Gite';
|
import GiteIcon from '@mui/icons-material/Gite';
|
||||||
@@ -122,12 +124,9 @@ const NetworkStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
||||||
@@ -135,7 +134,6 @@ const NetworkStatus = () => {
|
|||||||
const qualityColor = networkQualityHighlight(data, theme);
|
const qualityColor = networkQualityHighlight(data, theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -229,8 +227,10 @@ const NetworkStatus = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
</SectionContent>
|
|
||||||
);
|
);
|
||||||
|
}, [data, error, loadData, LL, theme]);
|
||||||
|
|
||||||
|
return <SectionContent>{content}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkStatus;
|
export default NetworkStatus;
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { useContext } from 'react';
|
import { useCallback, useContext, useMemo, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
|
||||||
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
import LogoDevIcon from '@mui/icons-material/LogoDev';
|
||||||
import MemoryIcon from '@mui/icons-material/Memory';
|
import MemoryIcon from '@mui/icons-material/Memory';
|
||||||
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
|
||||||
|
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||||
import RouterIcon from '@mui/icons-material/Router';
|
import RouterIcon from '@mui/icons-material/Router';
|
||||||
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
import WifiIcon from '@mui/icons-material/Wifi';
|
import WifiIcon from '@mui/icons-material/Wifi';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
@@ -18,10 +27,12 @@ import {
|
|||||||
useTheme
|
useTheme
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { API } from 'api/app';
|
||||||
import { readSystemStatus } from 'api/system';
|
import { readSystemStatus } from 'api/system';
|
||||||
|
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { busConnectionStatus } from 'app/main/types';
|
import { type APIcall, busConnectionStatus } from 'app/main/types';
|
||||||
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
|
||||||
import ListMenuItem from 'components/layout/ListMenuItem';
|
import ListMenuItem from 'components/layout/ListMenuItem';
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
@@ -30,6 +41,9 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
|
|||||||
import { useInterval } from 'utils';
|
import { useInterval } from 'utils';
|
||||||
import { formatDateTime } from 'utils/time';
|
import { formatDateTime } from 'utils/time';
|
||||||
|
|
||||||
|
import SystemMonitor from './SystemMonitor';
|
||||||
|
|
||||||
|
// Pure functions moved outside component to avoid recreation on each render
|
||||||
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
|
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
|
||||||
|
|
||||||
const formatDurationSec = (
|
const formatDurationSec = (
|
||||||
@@ -58,7 +72,24 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||||
|
const [restarting, setRestarting] = useState<boolean>();
|
||||||
|
|
||||||
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
send: loadData,
|
||||||
|
error
|
||||||
|
} = useRequest(readSystemStatus, {
|
||||||
|
async middleware(_, next) {
|
||||||
|
if (!restarting) {
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
useInterval(() => {
|
useInterval(() => {
|
||||||
void loadData();
|
void loadData();
|
||||||
@@ -66,8 +97,10 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const busStatus = (() => {
|
// Memoize derived status values to avoid recalculation on every render
|
||||||
|
const busStatus = useMemo(() => {
|
||||||
if (!data) return 'EMS state unknown';
|
if (!data) return 'EMS state unknown';
|
||||||
|
|
||||||
switch (data.bus_status) {
|
switch (data.bus_status) {
|
||||||
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
case busConnectionStatus.BUS_STATUS_CONNECTED:
|
||||||
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
|
||||||
@@ -78,10 +111,12 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return 'EMS state unknown';
|
return 'EMS state unknown';
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.bus_status, data?.bus_uptime, LL]);
|
||||||
|
|
||||||
const systemStatus = (() => {
|
// Memoize derived status values to avoid recalculation on every render
|
||||||
|
const systemStatus = useMemo(() => {
|
||||||
if (!data) return '??';
|
if (!data) return '??';
|
||||||
|
|
||||||
switch (data.status) {
|
switch (data.status) {
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
case SystemStatusCodes.SYSTEM_STATUS_PENDING_UPLOAD:
|
||||||
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
case SystemStatusCodes.SYSTEM_STATUS_UPLOADING:
|
||||||
@@ -94,12 +129,14 @@ const SystemStatus = () => {
|
|||||||
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
case SystemStatusCodes.SYSTEM_STATUS_INVALID_GPIO:
|
||||||
return LL.GPIO_OF(LL.FAILED(0));
|
return LL.GPIO_OF(LL.FAILED(0));
|
||||||
default:
|
default:
|
||||||
|
// SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||||
return 'OK';
|
return 'OK';
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.status, LL]);
|
||||||
|
|
||||||
const busStatusHighlight = (() => {
|
const busStatusHighlight = useMemo(() => {
|
||||||
if (!data) return theme.palette.warning.main;
|
if (!data) return theme.palette.warning.main;
|
||||||
|
|
||||||
switch (data.bus_status) {
|
switch (data.bus_status) {
|
||||||
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
@@ -110,10 +147,11 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.bus_status, theme.palette]);
|
||||||
|
|
||||||
const ntpStatus = (() => {
|
const ntpStatus = useMemo(() => {
|
||||||
if (!data) return LL.UNKNOWN();
|
if (!data) return LL.UNKNOWN();
|
||||||
|
|
||||||
switch (data.ntp_status) {
|
switch (data.ntp_status) {
|
||||||
case NTPSyncStatus.NTP_DISABLED:
|
case NTPSyncStatus.NTP_DISABLED:
|
||||||
return LL.NOT_ENABLED();
|
return LL.NOT_ENABLED();
|
||||||
@@ -126,10 +164,11 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.ntp_status, data?.ntp_time, LL]);
|
||||||
|
|
||||||
const ntpStatusHighlight = (() => {
|
const ntpStatusHighlight = useMemo(() => {
|
||||||
if (!data) return theme.palette.error.main;
|
if (!data) return theme.palette.error.main;
|
||||||
|
|
||||||
switch (data.ntp_status) {
|
switch (data.ntp_status) {
|
||||||
case NTPSyncStatus.NTP_DISABLED:
|
case NTPSyncStatus.NTP_DISABLED:
|
||||||
return theme.palette.info.main;
|
return theme.palette.info.main;
|
||||||
@@ -140,10 +179,11 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.error.main;
|
return theme.palette.error.main;
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.ntp_status, theme.palette]);
|
||||||
|
|
||||||
const networkStatusHighlight = (() => {
|
const networkStatusHighlight = useMemo(() => {
|
||||||
if (!data) return theme.palette.warning.main;
|
if (!data) return theme.palette.warning.main;
|
||||||
|
|
||||||
switch (data.network_status) {
|
switch (data.network_status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||||
@@ -158,10 +198,11 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.network_status, theme.palette]);
|
||||||
|
|
||||||
const networkStatus = (() => {
|
const networkStatus = useMemo(() => {
|
||||||
if (!data) return LL.UNKNOWN();
|
if (!data) return LL.UNKNOWN();
|
||||||
|
|
||||||
switch (data.network_status) {
|
switch (data.network_status) {
|
||||||
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||||
return LL.INACTIVE(1);
|
return LL.INACTIVE(1);
|
||||||
@@ -182,22 +223,114 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
})();
|
}, [data?.network_status, data?.wifi_rssi, LL]);
|
||||||
|
|
||||||
const activeHighlight = (value: boolean) =>
|
const activeHighlight = useCallback(
|
||||||
value ? theme.palette.success.main : theme.palette.info.main;
|
(value: boolean) =>
|
||||||
|
value ? theme.palette.success.main : theme.palette.info.main,
|
||||||
if (!data || !LL) {
|
[theme.palette]
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const doRestart = useCallback(async () => {
|
||||||
|
setConfirmRestart(false);
|
||||||
|
setRestarting(true);
|
||||||
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
|
(error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [sendAPI]);
|
||||||
|
|
||||||
|
const handleCloseRestartDialog = useCallback(() => {
|
||||||
|
setConfirmRestart(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderRestartDialog = useMemo(
|
||||||
|
() => (
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmRestart}
|
||||||
|
onClose={handleCloseRestartDialog}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleCloseRestartDialog}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doRestart}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
),
|
||||||
|
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<>
|
||||||
<List>
|
<List>
|
||||||
|
<ListMenuItem
|
||||||
|
icon={BuildIcon}
|
||||||
|
bgcolor="#72caf9"
|
||||||
|
label="EMS-ESP Firmware"
|
||||||
|
text={firmwareVersion}
|
||||||
|
to="version"
|
||||||
|
/>
|
||||||
|
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||||
@@ -206,8 +339,18 @@ const SystemStatus = () => {
|
|||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
|
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
|
||||||
/>
|
/>
|
||||||
|
{me.admin && (
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleRestartClick}
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
@@ -215,7 +358,7 @@ const SystemStatus = () => {
|
|||||||
icon={MemoryIcon}
|
icon={MemoryIcon}
|
||||||
bgcolor="#68374d"
|
bgcolor="#68374d"
|
||||||
label={LL.HARDWARE()}
|
label={LL.HARDWARE()}
|
||||||
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
|
text={freeMemoryText}
|
||||||
to="/status/hardwarestatus"
|
to="/status/hardwarestatus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -230,11 +373,7 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
icon={
|
icon={networkIcon}
|
||||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
|
||||||
? WifiIcon
|
|
||||||
: RouterIcon
|
|
||||||
}
|
|
||||||
bgcolor={networkStatusHighlight}
|
bgcolor={networkStatusHighlight}
|
||||||
label={LL.NETWORK(1)}
|
label={LL.NETWORK(1)}
|
||||||
text={networkStatus}
|
text={networkStatus}
|
||||||
@@ -246,7 +385,7 @@ const SystemStatus = () => {
|
|||||||
icon={DeviceHubIcon}
|
icon={DeviceHubIcon}
|
||||||
bgcolor={activeHighlight(data.mqtt_status)}
|
bgcolor={activeHighlight(data.mqtt_status)}
|
||||||
label="MQTT"
|
label="MQTT"
|
||||||
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
text={mqttStatusText}
|
||||||
to="/status/mqtt"
|
to="/status/mqtt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -264,7 +403,7 @@ const SystemStatus = () => {
|
|||||||
icon={SettingsInputAntennaIcon}
|
icon={SettingsInputAntennaIcon}
|
||||||
bgcolor={activeHighlight(data.ap_status)}
|
bgcolor={activeHighlight(data.ap_status)}
|
||||||
label={LL.ACCESS_POINT(0)}
|
label={LL.ACCESS_POINT(0)}
|
||||||
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
text={apStatusText}
|
||||||
to="/status/ap"
|
to="/status/ap"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -277,8 +416,34 @@ const SystemStatus = () => {
|
|||||||
to="/status/log"
|
to="/status/log"
|
||||||
/>
|
/>
|
||||||
</List>
|
</List>
|
||||||
</SectionContent>
|
|
||||||
|
{renderRestartDialog}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
}, [
|
||||||
|
data,
|
||||||
|
LL,
|
||||||
|
firmwareVersion,
|
||||||
|
uptimeText,
|
||||||
|
freeMemoryText,
|
||||||
|
networkIcon,
|
||||||
|
mqttStatusText,
|
||||||
|
apStatusText,
|
||||||
|
busStatus,
|
||||||
|
busStatusHighlight,
|
||||||
|
networkStatusHighlight,
|
||||||
|
networkStatus,
|
||||||
|
ntpStatusHighlight,
|
||||||
|
ntpStatus,
|
||||||
|
activeHighlight,
|
||||||
|
me.admin,
|
||||||
|
handleRestartClick,
|
||||||
|
error,
|
||||||
|
loadData,
|
||||||
|
renderRestartDialog
|
||||||
|
]);
|
||||||
|
|
||||||
|
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemStatus;
|
export default SystemStatus;
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import DownloadIcon from '@mui/icons-material/GetApp';
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
@@ -25,7 +33,6 @@ import {
|
|||||||
SectionContent,
|
SectionContent,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { LogEntry, LogSettings } from 'types';
|
import type { LogEntry, LogSettings } from 'types';
|
||||||
import { LogLevel } from 'types';
|
import { LogLevel } from 'types';
|
||||||
@@ -178,7 +185,8 @@ const SystemLog = () => {
|
|||||||
};
|
};
|
||||||
}, [data]); // Recalculate when data changes (in case layout shifts)
|
}, [data]); // Recalculate when data changes (in case layout shifts)
|
||||||
|
|
||||||
const handleLogMessage = (message: { data: string }) => {
|
// Memoize message handler to avoid recreating on every render
|
||||||
|
const handleLogMessage = useCallback((message: { data: string }) => {
|
||||||
const rawData = message.data;
|
const rawData = message.data;
|
||||||
const logentry = JSON.parse(rawData) as LogEntry;
|
const logentry = JSON.parse(rawData) as LogEntry;
|
||||||
setLogEntries((log) => {
|
setLogEntries((log) => {
|
||||||
@@ -192,7 +200,7 @@ const SystemLog = () => {
|
|||||||
const newLog = [...log, logentry];
|
const newLog = [...log, logentry];
|
||||||
return newLog;
|
return newLog;
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useSSE(fetchLogES, {
|
useSSE(fetchLogES, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
@@ -203,7 +211,7 @@ const SystemLog = () => {
|
|||||||
toast.error('No connection to Log service');
|
toast.error('No connection to Log service');
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDownload = () => {
|
const onDownload = useCallback(() => {
|
||||||
const result = logEntries
|
const result = logEntries
|
||||||
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
@@ -217,11 +225,11 @@ const SystemLog = () => {
|
|||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
};
|
}, [logEntries]);
|
||||||
|
|
||||||
const saveSettings = async () => {
|
const saveSettings = useCallback(async () => {
|
||||||
await saveData();
|
await saveData();
|
||||||
};
|
}, [saveData]);
|
||||||
|
|
||||||
// handle scrolling - optimized to only scroll when needed
|
// handle scrolling - optimized to only scroll when needed
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -238,7 +246,7 @@ const SystemLog = () => {
|
|||||||
}
|
}
|
||||||
}, [logEntries.length, autoscroll]);
|
}, [logEntries.length, autoscroll]);
|
||||||
|
|
||||||
const sendReadCommand = () => {
|
const sendReadCommand = useCallback(() => {
|
||||||
if (readValue === '') {
|
if (readValue === '') {
|
||||||
setReadOpen(!readOpen);
|
setReadOpen(!readOpen);
|
||||||
return;
|
return;
|
||||||
@@ -249,7 +257,7 @@ const SystemLog = () => {
|
|||||||
setReadOpen(false);
|
setReadOpen(false);
|
||||||
setReadValue('');
|
setReadValue('');
|
||||||
}
|
}
|
||||||
};
|
}, [readValue, readOpen, send]);
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
@@ -57,9 +57,10 @@ const SystemMonitor = () => {
|
|||||||
void send();
|
void send();
|
||||||
}, 1000); // check every 1 second
|
}, 1000); // check every 1 second
|
||||||
|
|
||||||
|
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
||||||
const status = data?.status;
|
const status = data?.status;
|
||||||
|
|
||||||
const statusMessage =
|
const message =
|
||||||
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
|
||||||
? LL.WAIT_FIRMWARE()
|
? LL.WAIT_FIRMWARE()
|
||||||
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
: status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
|
||||||
@@ -70,18 +71,25 @@ const SystemMonitor = () => {
|
|||||||
? 'Upload Failed'
|
? 'Upload Failed'
|
||||||
: LL.RESTARTING_POST();
|
: LL.RESTARTING_POST();
|
||||||
|
|
||||||
const isUploading =
|
const uploading =
|
||||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||||
const progressValue =
|
const progress =
|
||||||
isUploading && status
|
uploading && status
|
||||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const onCancel = async () => {
|
return {
|
||||||
|
statusMessage: message,
|
||||||
|
isUploading: uploading,
|
||||||
|
progressValue: progress
|
||||||
|
};
|
||||||
|
}, [data?.status, LL]);
|
||||||
|
|
||||||
|
const onCancel = useCallback(async () => {
|
||||||
setErrorMessage(undefined);
|
setErrorMessage(undefined);
|
||||||
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
|
||||||
document.location.href = '/';
|
document.location.href = '/';
|
||||||
};
|
}, [setSystemStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -145,21 +153,9 @@ const SystemMonitor = () => {
|
|||||||
{LL.PLEASE_WAIT()}…
|
{LL.PLEASE_WAIT()}…
|
||||||
</Typography>
|
</Typography>
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<>
|
|
||||||
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
||||||
<LinearProgressWithLabel value={progressValue} />
|
<LinearProgressWithLabel value={progressValue} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Button
|
|
||||||
sx={{ ml: 2, mt: 2 }}
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
{LL.CANCEL()}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,931 @@
|
|||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState
|
||||||
|
} from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import CheckIcon from '@mui/icons-material/Done';
|
||||||
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import * as SystemApi from 'api/system';
|
||||||
|
import { API, callAction } from 'api/app';
|
||||||
|
import { getDevVersion, getStableVersion } from 'api/system';
|
||||||
|
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import type { APIcall } from 'app/main/types';
|
||||||
|
import SystemMonitor from 'app/status/SystemMonitor';
|
||||||
|
import {
|
||||||
|
FormLoader,
|
||||||
|
SectionContent,
|
||||||
|
SingleUpload,
|
||||||
|
useLayoutTitle
|
||||||
|
} from 'components';
|
||||||
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||||
|
import { prettyDateTime } from 'utils/time';
|
||||||
|
|
||||||
|
// Constants moved outside component to avoid recreation
|
||||||
|
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||||
|
const STABLE_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||||
|
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||||
|
const DEV_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||||
|
|
||||||
|
// Types for better type safety
|
||||||
|
interface PartitionData {
|
||||||
|
partition: string;
|
||||||
|
version: string;
|
||||||
|
install_date?: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionData {
|
||||||
|
emsesp_version: string;
|
||||||
|
arduino_version: string;
|
||||||
|
esp_platform: string;
|
||||||
|
flash_chip_size: number;
|
||||||
|
psram: boolean;
|
||||||
|
build_flags?: string;
|
||||||
|
partition: string;
|
||||||
|
partitions: PartitionData[];
|
||||||
|
developer_mode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeCheckData {
|
||||||
|
emsesp_version: string;
|
||||||
|
dev_upgradeable: boolean;
|
||||||
|
stable_upgradeable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionInfo {
|
||||||
|
name: string;
|
||||||
|
published_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized components for better performance
|
||||||
|
const VersionInfoDialog = memo(
|
||||||
|
({
|
||||||
|
showVersionInfo,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
partitionVersion,
|
||||||
|
partition,
|
||||||
|
currentPartition,
|
||||||
|
size,
|
||||||
|
locale,
|
||||||
|
LL,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
showVersionInfo: number;
|
||||||
|
latestVersion?: VersionInfo;
|
||||||
|
latestDevVersion?: VersionInfo;
|
||||||
|
partitionVersion?: VersionInfo | undefined;
|
||||||
|
partition: string;
|
||||||
|
currentPartition: string;
|
||||||
|
size: number;
|
||||||
|
locale: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
if (showVersionInfo === 0) return null;
|
||||||
|
|
||||||
|
const isStable = showVersionInfo === 1;
|
||||||
|
const isDev = showVersionInfo === 2;
|
||||||
|
const isPartition = showVersionInfo === 3;
|
||||||
|
|
||||||
|
const version = isStable
|
||||||
|
? latestVersion
|
||||||
|
: isDev
|
||||||
|
? latestDevVersion
|
||||||
|
: partitionVersion;
|
||||||
|
const relNotesUrl = isStable
|
||||||
|
? STABLE_RELNOTES_URL
|
||||||
|
: isDev
|
||||||
|
? DEV_RELNOTES_URL
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||||
|
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{isPartition
|
||||||
|
? typeof version === 'string'
|
||||||
|
? version
|
||||||
|
: version?.name
|
||||||
|
: version?.name}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13,
|
||||||
|
width: 140
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition === currentPartition && LL.ACTIVE() + ' '}
|
||||||
|
{isStable
|
||||||
|
? LL.STABLE()
|
||||||
|
: isDev
|
||||||
|
? LL.DEVELOPMENT()
|
||||||
|
: 'Partition ' + LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Partition
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{size} KB
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{version?.published_at && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? 'Install Date' : 'Build Date'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{prettyDateTime(locale, new Date(version.published_at))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{!isPartition && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={relNotesUrl}
|
||||||
|
target="_blank"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Changelog
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||||
|
{LL.CLOSE()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallDialog,
|
||||||
|
fetchDevVersion,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
upgradeImportantMessageType,
|
||||||
|
downloadOnly,
|
||||||
|
platform,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallDialog: boolean;
|
||||||
|
fetchDevVersion: boolean;
|
||||||
|
latestVersion?: VersionInfo;
|
||||||
|
latestDevVersion?: VersionInfo;
|
||||||
|
upgradeImportantMessageType: number;
|
||||||
|
downloadOnly: boolean;
|
||||||
|
platform: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (url: string) => void;
|
||||||
|
}) => {
|
||||||
|
const binURL = useMemo(() => {
|
||||||
|
if (!latestVersion || !latestDevVersion) return '';
|
||||||
|
|
||||||
|
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||||
|
const filename = `EMS-ESP-${version.name.replaceAll('.', '_')}-${platform}.bin`;
|
||||||
|
|
||||||
|
return fetchDevVersion
|
||||||
|
? `${DEV_URL}${filename}`
|
||||||
|
: `${STABLE_URL}v${version.name}/${filename}`;
|
||||||
|
}, [fetchDevVersion, latestVersion, latestDevVersion, platform]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(
|
||||||
|
downloadOnly ? LL.DOWNLOAD(1) : LL.INSTALL(),
|
||||||
|
fetchDevVersion ? latestDevVersion?.name : latestVersion?.name
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
|
||||||
|
{upgradeImportantMessageType === 1 && (
|
||||||
|
<>
|
||||||
|
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
|
||||||
|
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link
|
||||||
|
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue' }}
|
||||||
|
>
|
||||||
|
{LL.ONLINE_HELP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={binURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{LL.DOWNLOAD(0)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
{!downloadOnly && (
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(binURL)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallPartitionDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallPartitionDialog,
|
||||||
|
version,
|
||||||
|
partition,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallPartitionDialog: boolean;
|
||||||
|
version: string;
|
||||||
|
partition: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (partition: string) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(partition)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function moved outside component
|
||||||
|
const getPlatform = (data: VersionData): string => {
|
||||||
|
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Version = () => {
|
||||||
|
const { LL, locale } = useI18nContext();
|
||||||
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [restarting, setRestarting] = useState<boolean>(false);
|
||||||
|
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [partition, setPartition] = useState<string>('');
|
||||||
|
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
|
||||||
|
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||||
|
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
|
||||||
|
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [internetLive, setInternetLive] = useState<boolean>(false);
|
||||||
|
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
|
||||||
|
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||||
|
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||||
|
|
||||||
|
const { send: sendCheckUpgrade } = useRequest(
|
||||||
|
(versions: string) => callAction({ action: 'checkUpgrade', param: versions }),
|
||||||
|
{ immediate: false }
|
||||||
|
).onSuccess((event) => {
|
||||||
|
const data = event.data as UpgradeCheckData;
|
||||||
|
setDevUpgradeAvailable(data.dev_upgradeable);
|
||||||
|
setStableUpgradeAvailable(data.stable_upgradeable);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { send: sendSetPartition } = useRequest(
|
||||||
|
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||||
|
{ immediate: false }
|
||||||
|
).onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
send: loadData,
|
||||||
|
error
|
||||||
|
} = useRequest(SystemApi.readSystemStatus).onSuccess((event) => {
|
||||||
|
const systemData = event.data as VersionData;
|
||||||
|
if (systemData.arduino_version.startsWith('Tasmota')) {
|
||||||
|
setDownloadOnly(true);
|
||||||
|
}
|
||||||
|
setUsingDevVersion(systemData.emsesp_version.includes('dev'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { send: sendUploadURL } = useRequest(
|
||||||
|
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: latestVersion } = useRequest(getStableVersion);
|
||||||
|
const { data: latestDevVersion } = useRequest(getDevVersion);
|
||||||
|
|
||||||
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
|
||||||
|
useState<number>(0);
|
||||||
|
|
||||||
|
const { send: checkUpgradeImportantMessages } = useRequest(
|
||||||
|
(version: string) =>
|
||||||
|
callAction({ action: 'upgradeImportantMessages', param: version }),
|
||||||
|
{
|
||||||
|
immediate: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onSuccess((event) => {
|
||||||
|
const upgradeImportantMessageType_n = (
|
||||||
|
event.data as { upgradeImportantMessageType: number }
|
||||||
|
).upgradeImportantMessageType;
|
||||||
|
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Memoized values
|
||||||
|
const platform = useMemo(() => (data ? getPlatform(data) : ''), [data]);
|
||||||
|
|
||||||
|
// Memoize filtered partitions to avoid recomputing on every render
|
||||||
|
const otherPartitions = useMemo(
|
||||||
|
() => data?.partitions.filter((p) => p.partition !== data.partition) ?? [],
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPartitionVersionInfo = useCallback(
|
||||||
|
(partition: string) => {
|
||||||
|
setShowVersionInfo(3);
|
||||||
|
|
||||||
|
// search for the partition in the data.partitions array
|
||||||
|
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||||
|
if (partitionData) {
|
||||||
|
setPartitionVersion({
|
||||||
|
name: partitionData.version,
|
||||||
|
published_at: partitionData.install_date ?? ''
|
||||||
|
});
|
||||||
|
setPartition(partitionData.partition);
|
||||||
|
setFirmwareSize(partitionData.size);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const doRestart = useCallback(async () => {
|
||||||
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
|
(error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRestarting(true);
|
||||||
|
}, [sendAPI]);
|
||||||
|
|
||||||
|
const installFirmwareURL = useCallback(
|
||||||
|
async (url: string) => {
|
||||||
|
await sendUploadURL(url).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
await doRestart();
|
||||||
|
},
|
||||||
|
[sendUploadURL, doRestart]
|
||||||
|
);
|
||||||
|
|
||||||
|
const installPartitionFirmware = useCallback(
|
||||||
|
async (partition: string) => {
|
||||||
|
await sendSetPartition(partition).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
setRestarting(true);
|
||||||
|
},
|
||||||
|
[sendSetPartition]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showPartitionDialog = useCallback(
|
||||||
|
(version: string, partition: string, install_date: string) => {
|
||||||
|
setOpenInstallPartitionDialog(true);
|
||||||
|
setPartitionVersion({ name: version, published_at: install_date });
|
||||||
|
setPartition(partition);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const showFirmwareDialog = useCallback(
|
||||||
|
(useDevVersion: boolean) => {
|
||||||
|
setFetchDevVersion(useDevVersion);
|
||||||
|
void checkUpgradeImportantMessages(
|
||||||
|
useDevVersion ? latestDevVersion?.name : latestVersion?.name
|
||||||
|
);
|
||||||
|
setOpenInstallDialog(true);
|
||||||
|
},
|
||||||
|
[latestDevVersion, latestVersion, fetchDevVersion]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeInstallDialog = useCallback(() => {
|
||||||
|
setOpenInstallDialog(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeInstallPartitionDialog = useCallback(() => {
|
||||||
|
setOpenInstallPartitionDialog(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVersionInfoClose = useCallback(() => {
|
||||||
|
setShowVersionInfo(0);
|
||||||
|
setPartitionVersion(undefined);
|
||||||
|
setPartition('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// check upgrades - only once when both versions are available
|
||||||
|
const upgradeCheckedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (latestVersion && latestDevVersion && !upgradeCheckedRef.current) {
|
||||||
|
upgradeCheckedRef.current = true;
|
||||||
|
const versions = `${latestDevVersion.name},${latestVersion.name}`;
|
||||||
|
sendCheckUpgrade(versions)
|
||||||
|
.catch((error: Error) => {
|
||||||
|
toast.error(`Failed to check for upgrades: ${error.message}`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setInternetLive(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [latestVersion, latestDevVersion, sendCheckUpgrade]);
|
||||||
|
|
||||||
|
useLayoutTitle('EMS-ESP Firmware');
|
||||||
|
|
||||||
|
// Memoized button rendering logic
|
||||||
|
const showButtons = useCallback(
|
||||||
|
(showingDev: boolean) => {
|
||||||
|
const choice = showingDev
|
||||||
|
? !usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
||||||
|
: devUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined
|
||||||
|
: usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
||||||
|
: stableUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!choice) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||||
|
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{LL.REINSTALL()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me.admin) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{choice}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
usingDevVersion,
|
||||||
|
devUpgradeAvailable,
|
||||||
|
stableUpgradeAvailable,
|
||||||
|
me.admin,
|
||||||
|
LL,
|
||||||
|
showFirmwareDialog
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
|
||||||
|
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.THIS_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{data.emsesp_version}
|
||||||
|
{data.build_flags && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
({data.build_flags})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{platform}
|
||||||
|
<Typography variant="caption">
|
||||||
|
(
|
||||||
|
{data.psram ? (
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloseIcon
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
PSRAM)
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{internetLive ? (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.AVAILABLE_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
rowSpacing={1}
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{otherPartitions.length > 0 && data.developer_mode && (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">
|
||||||
|
{LL.STORED_VERSIONS()}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
{otherPartitions.map((partition) => (
|
||||||
|
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||||
|
{partition.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setPartitionVersionInfo(partition.partition)
|
||||||
|
}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontSize: 18 }}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 0 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
showPartitionDialog(
|
||||||
|
partition.version,
|
||||||
|
partition.partition,
|
||||||
|
partition.install_date ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestVersion?.name}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(1)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(false)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestDevVersion?.name}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(2)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(true)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ mt: 2 }} color="warning">
|
||||||
|
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
||||||
|
{LL.INTERNET_CONNECTION_REQUIRED()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<VersionInfoDialog
|
||||||
|
showVersionInfo={showVersionInfo}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
partitionVersion={partitionVersion}
|
||||||
|
locale={locale}
|
||||||
|
partition={partition}
|
||||||
|
currentPartition={data?.partition ?? ''}
|
||||||
|
size={firmwareSize}
|
||||||
|
LL={LL}
|
||||||
|
onClose={handleVersionInfoClose}
|
||||||
|
/>
|
||||||
|
<InstallDialog
|
||||||
|
openInstallDialog={openInstallDialog}
|
||||||
|
fetchDevVersion={fetchDevVersion}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
upgradeImportantMessageType={upgradeImportantMessageType}
|
||||||
|
downloadOnly={downloadOnly}
|
||||||
|
platform={platform}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallDialog}
|
||||||
|
onInstall={installFirmwareURL}
|
||||||
|
/>
|
||||||
|
<InstallPartitionDialog
|
||||||
|
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||||
|
version={partitionVersion?.name || ''}
|
||||||
|
partition={partition}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallPartitionDialog}
|
||||||
|
onInstall={installPartitionFirmware}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||||
|
{LL.UPLOAD()}
|
||||||
|
</Typography>
|
||||||
|
<SingleUpload doRestart={doRestart} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
loadData,
|
||||||
|
LL,
|
||||||
|
platform,
|
||||||
|
internetLive,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
showVersionInfo,
|
||||||
|
locale,
|
||||||
|
openInstallDialog,
|
||||||
|
fetchDevVersion,
|
||||||
|
downloadOnly,
|
||||||
|
me.admin,
|
||||||
|
showButtons,
|
||||||
|
handleVersionInfoClose,
|
||||||
|
closeInstallDialog,
|
||||||
|
installFirmwareURL,
|
||||||
|
doRestart,
|
||||||
|
otherPartitions,
|
||||||
|
setPartitionVersionInfo,
|
||||||
|
showPartitionDialog,
|
||||||
|
partitionVersion,
|
||||||
|
partition,
|
||||||
|
firmwareSize,
|
||||||
|
closeInstallPartitionDialog,
|
||||||
|
installPartitionFirmware
|
||||||
|
]);
|
||||||
|
|
||||||
|
return restarting ? <SystemMonitor /> : <SectionContent>{content}</SectionContent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Version);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, type PropsWithChildren, memo } from 'react';
|
import { type FC, type PropsWithChildren, memo, useMemo } from 'react';
|
||||||
|
|
||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
@@ -38,18 +38,19 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const { Icon, backgroundColor } = useMemo(() => {
|
||||||
const Icon = LEVEL_ICONS[level];
|
const Icon = LEVEL_ICONS[level];
|
||||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||||
const [paletteKeyName, shade] = palettePath.split('.') as [
|
const [key, shade] = palettePath.split('.') as [
|
||||||
keyof typeof theme.palette,
|
keyof typeof theme.palette,
|
||||||
string
|
string
|
||||||
];
|
];
|
||||||
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
||||||
string,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
const backgroundColor = paletteKey[shade];
|
const backgroundColor = paletteKey[shade];
|
||||||
|
|
||||||
|
return { Icon, backgroundColor };
|
||||||
|
}, [level, theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
{...rest}
|
{...rest}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { memo, useCallback, useContext, useMemo } from 'react';
|
||||||
import type { ChangeEventHandler } from 'react';
|
import type { ChangeEventHandler } from 'react';
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
import { MenuItem, TextField } from '@mui/material';
|
import { MenuItem, TextField } from '@mui/material';
|
||||||
|
|
||||||
import CZflag from 'i18n/CS.svg';
|
import CZflag from 'i18n/CZ.svg';
|
||||||
import DEflag from 'i18n/DE.svg';
|
import DEflag from 'i18n/DE.svg';
|
||||||
import FRflag from 'i18n/FR.svg';
|
import FRflag from 'i18n/FR.svg';
|
||||||
import GBflag from 'i18n/GB.svg';
|
import GBflag from 'i18n/GB.svg';
|
||||||
@@ -44,14 +44,27 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
|
|||||||
const LanguageSelector = () => {
|
const LanguageSelector = () => {
|
||||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||||
|
|
||||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||||
target
|
async ({ target }) => {
|
||||||
}) => {
|
|
||||||
const loc = target.value as Locales;
|
const loc = target.value as Locales;
|
||||||
localStorage.setItem('lang', loc);
|
localStorage.setItem('lang', loc);
|
||||||
await loadLocaleAsync(loc);
|
await loadLocaleAsync(loc);
|
||||||
setLocale(loc);
|
setLocale(loc);
|
||||||
};
|
},
|
||||||
|
[setLocale]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize menu items to prevent recreation on every render
|
||||||
|
const menuItems = useMemo(
|
||||||
|
() =>
|
||||||
|
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
<img src={flag} style={flagStyle} alt={label} />
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -63,12 +76,7 @@ const LanguageSelector = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
{menuItems}
|
||||||
<MenuItem key={key} value={key}>
|
|
||||||
<img src={flag} style={flagStyle} alt={label} />
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useState } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
|
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
@@ -13,9 +13,9 @@ type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
|||||||
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ ...props }) => {
|
||||||
const [showPassword, setShowPassword] = useState<boolean>(false);
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||||
|
|
||||||
const togglePasswordVisibility = () => {
|
const togglePasswordVisibility = useCallback(() => {
|
||||||
setShowPassword((prev) => !prev);
|
setShowPassword((prev) => !prev);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { FC } from 'react';
|
|||||||
import { FormHelperText, TextField } from '@mui/material';
|
import { FormHelperText, TextField } from '@mui/material';
|
||||||
import type { TextFieldProps } from '@mui/material';
|
import type { TextFieldProps } from '@mui/material';
|
||||||
|
|
||||||
import type { ValidateFieldsError } from 'validators/schema';
|
import type { ValidateFieldsError } from 'async-validator';
|
||||||
|
|
||||||
interface ValidatedFieldProps {
|
interface ValidatedFieldProps {
|
||||||
fieldErrors?: ValidateFieldsError;
|
fieldErrors?: ValidateFieldsError;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
const [title, setTitle] = useState(PROJECT_NAME);
|
const [title, setTitle] = useState(PROJECT_NAME);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
// Memoize drawer toggle handler to prevent unnecessary re-renders
|
||||||
const handleDrawerToggle = useCallback(() => {
|
const handleDrawerToggle = useCallback(() => {
|
||||||
setMobileOpen((prev) => !prev);
|
setMobileOpen((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -27,6 +28,7 @@ const LayoutComponent: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Memoize context value to prevent unnecessary re-renders
|
||||||
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
const contextValue = useMemo(() => ({ title, setTitle }), [title]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
@@ -39,11 +39,14 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const pathnames = location.pathname.split('/').filter((x) => x);
|
const pathnames = useMemo(
|
||||||
|
() => location.pathname.split('/').filter((x) => x),
|
||||||
|
[location.pathname]
|
||||||
|
);
|
||||||
|
|
||||||
const handleBackClick = () => {
|
const handleBackClick = useCallback(() => {
|
||||||
void navigate('/' + pathnames[0]);
|
void navigate('/' + pathnames[0]);
|
||||||
};
|
}, [navigate, pathnames]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={appBarStyles}>
|
<AppBar position="fixed" sx={appBarStyles}>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||||
|
|
||||||
import { readSettings } from 'api/app';
|
|
||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
|
||||||
import { PROJECT_NAME } from 'env';
|
import { PROJECT_NAME } from 'env';
|
||||||
|
|
||||||
import { DRAWER_WIDTH } from './Layout';
|
import { DRAWER_WIDTH } from './Layout';
|
||||||
@@ -27,34 +24,22 @@ interface LayoutDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||||
const { data: settings } = useRequest(readSettings);
|
// Memoize drawer content to prevent unnecessary re-renders
|
||||||
const system_name = settings?.system_name;
|
const drawer = useMemo(
|
||||||
|
() => (
|
||||||
const drawer = (
|
|
||||||
<>
|
<>
|
||||||
<Toolbar disableGutters>
|
<Toolbar disableGutters>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
||||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||||
<Box
|
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography>{PROJECT_NAME}</Typography>
|
|
||||||
{system_name && (
|
|
||||||
<Typography color="secondary" variant="body2">
|
|
||||||
{system_name}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Divider absolute />
|
<Divider absolute />
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Divider />
|
<Divider />
|
||||||
<LayoutMenu />
|
<LayoutMenu />
|
||||||
</>
|
</>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useContext, useState } from 'react';
|
import { memo, useCallback, useContext, useState } from 'react';
|
||||||
|
|
||||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||||
@@ -8,7 +8,6 @@ import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
|||||||
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
import LiveHelpIcon from '@mui/icons-material/LiveHelp';
|
||||||
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
import MoreTimeIcon from '@mui/icons-material/MoreTime';
|
||||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||||
import PlaylistPlayIcon from '@mui/icons-material/PlaylistPlay';
|
|
||||||
import SensorsIcon from '@mui/icons-material/Sensors';
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
import StarIcon from '@mui/icons-material/Star';
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
@@ -19,15 +18,13 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
const LayoutMenuComponent = () => {
|
const LayoutMenuComponent = () => {
|
||||||
const { me, versions } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [menuOpen, setMenuOpen] = useState(true);
|
const [menuOpen, setMenuOpen] = useState(true);
|
||||||
|
|
||||||
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
const handleMenuToggle = useCallback(() => {
|
||||||
|
|
||||||
const handleMenuToggle = () => {
|
|
||||||
setMenuOpen((prev) => !prev);
|
setMenuOpen((prev) => !prev);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -81,12 +78,6 @@ const LayoutMenuComponent = () => {
|
|||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
to={`/customizations`}
|
to={`/customizations`}
|
||||||
/>
|
/>
|
||||||
<LayoutMenuItem
|
|
||||||
icon={PlaylistPlayIcon}
|
|
||||||
label={LL.COMMANDS()}
|
|
||||||
disabled={!me.admin}
|
|
||||||
to={`/commands`}
|
|
||||||
/>
|
|
||||||
<LayoutMenuItem
|
<LayoutMenuItem
|
||||||
icon={MoreTimeIcon}
|
icon={MoreTimeIcon}
|
||||||
label={LL.SCHEDULER()}
|
label={LL.SCHEDULER()}
|
||||||
@@ -114,7 +105,6 @@ const LayoutMenuComponent = () => {
|
|||||||
label={LL.SETTINGS(0)}
|
label={LL.SETTINGS(0)}
|
||||||
disabled={!me.admin}
|
disabled={!me.admin}
|
||||||
to="/settings"
|
to="/settings"
|
||||||
badge={upgradeAvailable}
|
|
||||||
/>
|
/>
|
||||||
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
||||||
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
|
||||||
|
|
||||||
import { routeMatches } from 'utils';
|
import { routeMatches } from 'utils';
|
||||||
@@ -11,21 +11,21 @@ interface LayoutMenuItemProps {
|
|||||||
label: string;
|
label: string;
|
||||||
to: string;
|
to: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
badge?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutMenuItemComponent = ({
|
const LayoutMenuItemComponent = ({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
to,
|
to,
|
||||||
disabled,
|
disabled
|
||||||
badge
|
|
||||||
}: LayoutMenuItemProps) => {
|
}: LayoutMenuItemProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const selected = routeMatches(to, pathname);
|
const selected = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
||||||
|
|
||||||
const buttonStyles: SxProps<Theme> = {
|
// Memoize dynamic styles based on selected state
|
||||||
|
const buttonStyles: SxProps<Theme> = useMemo(
|
||||||
|
() => ({
|
||||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
@@ -43,20 +43,28 @@ const LayoutMenuItemComponent = ({
|
|||||||
backgroundColor: '#90caf9',
|
backgroundColor: '#90caf9',
|
||||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||||
}
|
}
|
||||||
};
|
}),
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
const iconStyles: SxProps<Theme> = {
|
const iconStyles: SxProps<Theme> = useMemo(
|
||||||
|
() => ({
|
||||||
color: selected ? '#90caf9' : '#9e9e9e',
|
color: selected ? '#90caf9' : '#9e9e9e',
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||||
transitionProperty: 'color, transform'
|
transitionProperty: 'color, transform'
|
||||||
};
|
}),
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
const textStyles: SxProps<Theme> = {
|
const textStyles: SxProps<Theme> = useMemo(
|
||||||
|
() => ({
|
||||||
color: selected ? '#90caf9' : '#f5f5f5',
|
color: selected ? '#90caf9' : '#f5f5f5',
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
transitionProperty: 'color, font-weight'
|
transitionProperty: 'color, font-weight'
|
||||||
};
|
}),
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
@@ -70,20 +78,6 @@ const LayoutMenuItemComponent = ({
|
|||||||
<Icon />
|
<Icon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
<ListItemText sx={textStyles}>{label}</ListItemText>
|
<ListItemText sx={textStyles}>{label}</ListItemText>
|
||||||
{badge && (
|
|
||||||
<Box
|
|
||||||
aria-label="update available"
|
|
||||||
sx={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
ml: 1,
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#ffeb3b',
|
|
||||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Link } from 'react-router';
|
|||||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
|
||||||
ListItem,
|
ListItem,
|
||||||
ListItemAvatar,
|
ListItemAvatar,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
@@ -21,7 +20,6 @@ interface ListMenuItemProps {
|
|||||||
text: string;
|
text: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
badge?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconStyles: CSSProperties = {
|
const iconStyles: CSSProperties = {
|
||||||
@@ -30,40 +28,15 @@ const iconStyles: CSSProperties = {
|
|||||||
verticalAlign: 'middle'
|
verticalAlign: 'middle'
|
||||||
};
|
};
|
||||||
|
|
||||||
const Badge = () => (
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
aria-label="update available"
|
|
||||||
sx={{
|
|
||||||
display: 'inline-block',
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
ml: 1,
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
borderRadius: '50%',
|
|
||||||
backgroundColor: '#ffeb3b',
|
|
||||||
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const RenderIcon = memo(
|
const RenderIcon = memo(
|
||||||
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
|
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
|
||||||
<>
|
<>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||||
<Icon />
|
<Icon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText primary={label} secondary={text} />
|
||||||
primary={
|
|
||||||
<>
|
|
||||||
{label}
|
|
||||||
{badge && <Badge />}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
secondary={text}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -74,8 +47,7 @@ const LayoutMenuItem = ({
|
|||||||
label,
|
label,
|
||||||
text,
|
text,
|
||||||
to,
|
to,
|
||||||
disabled,
|
disabled
|
||||||
badge
|
|
||||||
}: ListMenuItemProps) => (
|
}: ListMenuItemProps) => (
|
||||||
<>
|
<>
|
||||||
{to && !disabled ? (
|
{to && !disabled ? (
|
||||||
@@ -93,7 +65,6 @@ const LayoutMenuItem = ({
|
|||||||
{...(bgcolor && { bgcolor })}
|
{...(bgcolor && { bgcolor })}
|
||||||
label={label}
|
label={label}
|
||||||
text={text}
|
text={text}
|
||||||
{...(badge && { badge })}
|
|
||||||
/>
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -104,7 +75,6 @@ const LayoutMenuItem = ({
|
|||||||
{...(bgcolor && { bgcolor })}
|
{...(bgcolor && { bgcolor })}
|
||||||
label={label}
|
label={label}
|
||||||
text={text}
|
text={text}
|
||||||
{...(badge && { badge })}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import type { Blocker } from 'react-router';
|
import type { Blocker } from 'react-router';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -15,13 +15,13 @@ import { useI18nContext } from 'i18n/i18n-react';
|
|||||||
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
const BlockNavigation = ({ blocker }: { blocker: Blocker }) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = useCallback(() => {
|
||||||
blocker.reset?.();
|
blocker.reset?.();
|
||||||
};
|
}, [blocker]);
|
||||||
|
|
||||||
const handleProceed = () => {
|
const handleProceed = useCallback(() => {
|
||||||
blocker.proceed?.();
|
blocker.proceed?.();
|
||||||
};
|
}, [blocker]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { memo, useContext } from 'react';
|
||||||
import { Navigate, Outlet } from 'react-router';
|
import type { FC } from 'react';
|
||||||
|
import { Navigate } from 'react-router';
|
||||||
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
// Layout-route guard: renders nested admin routes only for admins, otherwise
|
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
// redirects home. Must be used inside the authenticated route subtree so that
|
const authenticatedContext = useContext(AuthenticatedContext);
|
||||||
// AuthenticatedContext (and `me`) is available.
|
return authenticatedContext.me.admin ? (
|
||||||
const RequireAdmin = () => {
|
<>{children}</>
|
||||||
const { me } = useContext(AuthenticatedContext);
|
) : (
|
||||||
return me.admin ? <Outlet /> : <Navigate replace to="/" />;
|
<Navigate replace to="/" />
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RequireAdmin);
|
export default memo(RequireAdmin);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { memo, useContext, useEffect, useRef } from 'react';
|
|
||||||
import { Navigate } from 'react-router';
|
|
||||||
|
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticationContext } from 'contexts/authentication';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
|
||||||
|
|
||||||
type RootRedirectKind = 'unauthorized' | 'fileUpdated';
|
|
||||||
|
|
||||||
// Shows a one-shot toast and bounces back to "/". Used by the /unauthorized and
|
|
||||||
// /fileUpdated routes. Resolves its own i18n message so it can be used directly
|
|
||||||
// as a static route element.
|
|
||||||
const RootRedirect = ({ kind }: { kind: RootRedirectKind }) => {
|
|
||||||
const { LL } = useI18nContext();
|
|
||||||
const { signOut } = useContext(AuthenticationContext);
|
|
||||||
const hasShownToast = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Guard against StrictMode double-invoke / re-renders.
|
|
||||||
if (hasShownToast.current) return;
|
|
||||||
hasShownToast.current = true;
|
|
||||||
|
|
||||||
if (kind === 'unauthorized') {
|
|
||||||
signOut(false);
|
|
||||||
toast.success(LL.PLEASE_SIGNIN());
|
|
||||||
} else {
|
|
||||||
toast.success(LL.UPLOAD_SUCCESSFUL());
|
|
||||||
}
|
|
||||||
// Run once on mount.
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return <Navigate to="/" replace />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default memo(RootRedirect);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useCallback } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
@@ -16,9 +16,12 @@ const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
const handleTabChange = (_event: unknown, path: string) => {
|
const handleTabChange = useCallback(
|
||||||
|
(_event: unknown, path: string) => {
|
||||||
void navigate(path);
|
void navigate(path);
|
||||||
};
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ export { default as RouterTabs } from './RouterTabs';
|
|||||||
export { default as RequireAdmin } from './RequireAdmin';
|
export { default as RequireAdmin } from './RequireAdmin';
|
||||||
export { default as RequireAuthenticated } from './RequireAuthenticated';
|
export { default as RequireAuthenticated } from './RequireAuthenticated';
|
||||||
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
|
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
|
||||||
export { default as RootRedirect } from './RootRedirect';
|
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { memo, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
||||||
|
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
|
||||||
import Alert from '@mui/material/Alert';
|
|
||||||
import Grow from '@mui/material/Grow';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
|
||||||
import LinearProgress from '@mui/material/LinearProgress';
|
|
||||||
import Stack from '@mui/material/Stack';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type ToastItem,
|
|
||||||
type ToastSeverity,
|
|
||||||
getSnapshot,
|
|
||||||
removeToast,
|
|
||||||
subscribe
|
|
||||||
} from './toastStore';
|
|
||||||
|
|
||||||
const AUTO_CLOSE_MS = 3000;
|
|
||||||
const TICK_MS = 50;
|
|
||||||
|
|
||||||
// Semantic accent colors users already expect:
|
|
||||||
// success → green, error → red, warning → amber, info → blue.
|
|
||||||
const ACCENT: Record<ToastSeverity, string> = {
|
|
||||||
success: '#16a34a',
|
|
||||||
error: '#dc2626',
|
|
||||||
warning: '#f59e0b',
|
|
||||||
info: '#2563eb'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Single toast row: owns its auto-dismiss timer + countdown progress bar, pauses
|
|
||||||
// while the window is unfocused (matching react-toastify's pauseOnFocusLoss).
|
|
||||||
const ToastRow = memo(({ item }: { item: ToastItem }) => {
|
|
||||||
const [open, setOpen] = useState(true);
|
|
||||||
const [remaining, setRemaining] = useState(AUTO_CLOSE_MS);
|
|
||||||
const remainingRef = useRef(AUTO_CLOSE_MS);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let paused = document.hidden;
|
|
||||||
const onVisibility = () => {
|
|
||||||
paused = document.hidden;
|
|
||||||
};
|
|
||||||
document.addEventListener('visibilitychange', onVisibility);
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
if (paused) return;
|
|
||||||
remainingRef.current = Math.max(0, remainingRef.current - TICK_MS);
|
|
||||||
setRemaining(remainingRef.current);
|
|
||||||
if (remainingRef.current === 0) setOpen(false);
|
|
||||||
}, TICK_MS);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
document.removeEventListener('visibilitychange', onVisibility);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const accent = ACCENT[item.severity];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Grow in={open} onExited={() => removeToast(item.id)}>
|
|
||||||
<Alert
|
|
||||||
severity={item.severity}
|
|
||||||
variant="standard"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
action={
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label="close"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
sx={{ color: '#9ca3af', '&:hover': { color: '#374151' } }}
|
|
||||||
>
|
|
||||||
<CloseIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
sx={{
|
|
||||||
position: 'relative',
|
|
||||||
width: 'fit-content',
|
|
||||||
minWidth: 300,
|
|
||||||
maxWidth: 360,
|
|
||||||
cursor: 'pointer',
|
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: '8px',
|
|
||||||
bgcolor: '#f3f4f6',
|
|
||||||
color: '#1f2937',
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.18)',
|
|
||||||
borderLeft: `4px solid ${accent}`,
|
|
||||||
alignItems: 'center',
|
|
||||||
'& .MuiAlert-icon': { color: accent, alignItems: 'center' },
|
|
||||||
// '& .MuiAlert-message': {
|
|
||||||
// py: '8px',
|
|
||||||
// color: '#1f2937'
|
|
||||||
// },
|
|
||||||
'& .MuiAlert-action': { alignItems: 'center', pt: 0, mr: '-4px' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.message}
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={(remaining / AUTO_CLOSE_MS) * 100}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
'& .MuiLinearProgress-bar': { backgroundColor: accent, opacity: 0.55 }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Alert>
|
|
||||||
</Grow>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const Toaster = memo(() => {
|
|
||||||
const toasts = useSyncExternalStore(subscribe, getSnapshot);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack
|
|
||||||
spacing={1}
|
|
||||||
sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: 16,
|
|
||||||
left: 16,
|
|
||||||
zIndex: (theme) => theme.zIndex.snackbar,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
'& > *': { pointerEvents: 'auto' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{toasts.map((item) => (
|
|
||||||
<ToastRow key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Toaster;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as Toaster } from './Toaster';
|
|
||||||
export { toast } from './toastStore';
|
|
||||||
export type { ToastSeverity } from './toastStore';
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export type ToastSeverity = 'success' | 'error' | 'info' | 'warning';
|
|
||||||
|
|
||||||
export interface ToastItem {
|
|
||||||
id: number;
|
|
||||||
severity: ToastSeverity;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let toasts: ToastItem[] = [];
|
|
||||||
let nextId = 1;
|
|
||||||
const listeners = new Set<() => void>();
|
|
||||||
|
|
||||||
const emit = () => {
|
|
||||||
for (const listener of listeners) listener();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const subscribe = (listener: () => void): (() => void) => {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSnapshot = (): ToastItem[] => toasts;
|
|
||||||
|
|
||||||
const add = (severity: ToastSeverity, message: string): number => {
|
|
||||||
const id = nextId++;
|
|
||||||
toasts = [...toasts, { id, severity, message }];
|
|
||||||
emit();
|
|
||||||
return id;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const removeToast = (id: number): void => {
|
|
||||||
const next = toasts.filter((t) => t.id !== id);
|
|
||||||
if (next.length !== toasts.length) {
|
|
||||||
toasts = next;
|
|
||||||
emit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Imperative API mirroring the subset of react-toastify used across the app.
|
|
||||||
export const toast = {
|
|
||||||
success: (message: string) => add('success', message),
|
|
||||||
error: (message: string) => add('error', message),
|
|
||||||
info: (message: string) => add('info', message),
|
|
||||||
warning: (message: string) => add('warning', message)
|
|
||||||
};
|
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
useState
|
useState
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
@@ -27,7 +28,6 @@ import { callAction } from 'api/app';
|
|||||||
|
|
||||||
import { dialogStyle } from '@/CustomTheme';
|
import { dialogStyle } from '@/CustomTheme';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
|
const DocumentUploader = styled(Box)<{ active?: boolean }>(({ theme, active }) => ({
|
||||||
@@ -91,10 +91,8 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
|
|||||||
).upgradeImportantMessageType;
|
).upgradeImportantMessageType;
|
||||||
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||||
if (upgradeImportantMessageType_n === 0) {
|
if (upgradeImportantMessageType_n === 0) {
|
||||||
if (file) {
|
|
||||||
onFileSelected(file);
|
onFileSelected(file);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onError((error) => {
|
.onError((error) => {
|
||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
import { Box, Button, Typography } from '@mui/material';
|
||||||
@@ -6,7 +7,6 @@ import { Box, Button, Typography } from '@mui/material';
|
|||||||
import * as SystemApi from 'api/system';
|
import * as SystemApi from 'api/system';
|
||||||
|
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
import DragNdrop from './DragNdrop';
|
import DragNdrop from './DragNdrop';
|
||||||
|
|||||||
@@ -1,55 +1,36 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { redirect } from 'react-router';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { callAction } from 'api/app';
|
|
||||||
import { ACCESS_TOKEN } from 'api/endpoints';
|
import { ACCESS_TOKEN } from 'api/endpoints';
|
||||||
|
|
||||||
import * as AuthenticationApi from 'components/routing/authentication';
|
import * as AuthenticationApi from 'components/routing/authentication';
|
||||||
import { useRequest } from 'alova/client';
|
import { useRequest } from 'alova/client';
|
||||||
import { LoadingSpinner } from 'components';
|
import { LoadingSpinner } from 'components';
|
||||||
import { verifyAuthorization } from 'components/routing/authentication';
|
import { verifyAuthorization } from 'components/routing/authentication';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { Me, VersionsResponse } from 'types';
|
import type { Me } from 'types';
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
import { AuthenticationContext } from './context';
|
import { AuthenticationContext } from './context';
|
||||||
|
|
||||||
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [initialized, setInitialized] = useState<boolean>(false);
|
const [initialized, setInitialized] = useState<boolean>(false);
|
||||||
const [me, setMe] = useState<Me>();
|
const [me, setMe] = useState<Me>();
|
||||||
const [versions, setVersions] = useState<VersionsResponse>();
|
|
||||||
|
|
||||||
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
|
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
|
||||||
immediate: false
|
immediate: false
|
||||||
});
|
});
|
||||||
|
|
||||||
const { send: sendGetVersions } = useRequest(
|
|
||||||
() => callAction({ action: 'getVersions' }),
|
|
||||||
{ immediate: false }
|
|
||||||
)
|
|
||||||
.onSuccess((event) => {
|
|
||||||
setVersions(event.data as VersionsResponse);
|
|
||||||
})
|
|
||||||
.onError(() => {
|
|
||||||
setVersions(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshVersions = useCallback(async () => {
|
|
||||||
await sendGetVersions().catch(() => undefined);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const signIn = (accessToken: string) => {
|
const signIn = (accessToken: string) => {
|
||||||
try {
|
try {
|
||||||
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||||
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
||||||
setMe(decodedMe);
|
setMe(decodedMe);
|
||||||
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
|
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
|
||||||
void refreshVersions();
|
|
||||||
} catch {
|
} catch {
|
||||||
setMe(undefined);
|
setMe(undefined);
|
||||||
throw new Error('Failed to parse JWT');
|
throw new Error('Failed to parse JWT');
|
||||||
@@ -59,9 +40,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
const signOut = (doRedirect: boolean) => {
|
const signOut = (doRedirect: boolean) => {
|
||||||
AuthenticationApi.clearAccessToken();
|
AuthenticationApi.clearAccessToken();
|
||||||
setMe(undefined);
|
setMe(undefined);
|
||||||
setVersions(undefined);
|
|
||||||
if (doRedirect) {
|
if (doRedirect) {
|
||||||
void navigate('/', { replace: true });
|
redirect('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -69,9 +49,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
|
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
await sendVerifyAuthorization()
|
await sendVerifyAuthorization()
|
||||||
.then(async () => {
|
.then(() => {
|
||||||
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
||||||
await refreshVersions();
|
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -88,16 +67,15 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// cache object to prevent re-renders
|
||||||
const obj = useMemo(
|
const obj = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
refresh,
|
refresh,
|
||||||
refreshVersions,
|
...(me && { me })
|
||||||
...(me && { me }),
|
|
||||||
...(versions && { versions })
|
|
||||||
}),
|
}),
|
||||||
[signIn, signOut, me, refresh, refreshVersions, versions]
|
[signIn, signOut, me, refresh]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
import type { Me, VersionsResponse } from 'types';
|
import type { Me } from 'types';
|
||||||
|
|
||||||
export interface AuthenticationContextValue {
|
export interface AuthenticationContextValue {
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
signIn: (accessToken: string) => void;
|
signIn: (accessToken: string) => void;
|
||||||
signOut: (redirect: boolean) => void;
|
signOut: (redirect: boolean) => void;
|
||||||
me?: Me;
|
me?: Me;
|
||||||
versions?: VersionsResponse;
|
|
||||||
refreshVersions: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 207 B |
@@ -31,8 +31,8 @@ const cs: Translation = {
|
|||||||
ID_OF: '{0} ID',
|
ID_OF: '{0} ID',
|
||||||
DEVICE: 'Zařízení',
|
DEVICE: 'Zařízení',
|
||||||
PRODUCT: 'Produkt',
|
PRODUCT: 'Produkt',
|
||||||
BRAND: 'Značka',
|
|
||||||
VERSION: 'Verze',
|
VERSION: 'Verze',
|
||||||
|
BRAND: 'Značka',
|
||||||
ENTITY_NAME: 'Název entity',
|
ENTITY_NAME: 'Název entity',
|
||||||
VALUE: '{{value|Value}}',
|
VALUE: '{{value|Value}}',
|
||||||
DEVICES: 'Zařízení',
|
DEVICES: 'Zařízení',
|
||||||
@@ -102,8 +102,8 @@ const cs: Translation = {
|
|||||||
PHY_TYPE: 'Typ Eth PHY',
|
PHY_TYPE: 'Typ Eth PHY',
|
||||||
DISABLED: 'zakázáno',
|
DISABLED: 'zakázáno',
|
||||||
TX_MODE: 'EMS Tx režim',
|
TX_MODE: 'EMS Tx režim',
|
||||||
EMS_BUS: '{{Bus|EMS Bus}}',
|
|
||||||
HARDWARE: 'Hardware',
|
HARDWARE: 'Hardware',
|
||||||
|
EMS_BUS: '{{BUS|EMS BUS}}',
|
||||||
GENERAL_OPTIONS: 'Obecné možnosti',
|
GENERAL_OPTIONS: 'Obecné možnosti',
|
||||||
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
|
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
|
||||||
HIDE_LED: 'Skrýt LED',
|
HIDE_LED: 'Skrýt LED',
|
||||||
@@ -247,7 +247,8 @@ const cs: Translation = {
|
|||||||
TIME_ZONE: 'Časová zóna',
|
TIME_ZONE: 'Časová zóna',
|
||||||
ACCESS_POINT: 'Přístupový bod',
|
ACCESS_POINT: 'Přístupový bod',
|
||||||
AP_PROVIDE: 'Povolit přístupový bod',
|
AP_PROVIDE: 'Povolit přístupový bod',
|
||||||
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
|
AP_PROVIDE_TEXT_1: 'Vždy',
|
||||||
|
AP_PROVIDE_TEXT_2: 'Když je WiFi odpojena',
|
||||||
AP_PROVIDE_TEXT_3: 'Nikdy',
|
AP_PROVIDE_TEXT_3: 'Nikdy',
|
||||||
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
|
AP_PREFERRED_CHANNEL: 'Preferovaný kanál',
|
||||||
AP_HIDE_SSID: 'Skrýt SSID',
|
AP_HIDE_SSID: 'Skrýt SSID',
|
||||||
@@ -261,6 +262,7 @@ const cs: Translation = {
|
|||||||
SCAN_AGAIN: 'Skenovat znovu',
|
SCAN_AGAIN: 'Skenovat znovu',
|
||||||
NETWORK_SCANNER: 'Síťový skener',
|
NETWORK_SCANNER: 'Síťový skener',
|
||||||
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
|
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
|
||||||
|
NETWORK_BLANK_SSID: 'ponechte prázdné pro deaktivaci WiFi a povolení ETH',
|
||||||
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
|
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
|
||||||
TX_POWER: 'Vysílací výkon',
|
TX_POWER: 'Vysílací výkon',
|
||||||
HOSTNAME: 'Název hostitele',
|
HOSTNAME: 'Název hostitele',
|
||||||
@@ -286,13 +288,14 @@ const cs: Translation = {
|
|||||||
STAY: 'Zůstat',
|
STAY: 'Zůstat',
|
||||||
LEAVE: 'Odejít',
|
LEAVE: 'Odejít',
|
||||||
SCHEDULER: 'Plánovač',
|
SCHEDULER: 'Plánovač',
|
||||||
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže',
|
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže. Nastavte jedinečný název pro povolení/zakázání aktivace přes API/MQTT',
|
||||||
SCHEDULER_HELP_2: 'při startu',
|
SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu',
|
||||||
SCHEDULE: 'Harmonogram',
|
SCHEDULE: 'Harmonogram',
|
||||||
TIME: 'Čas',
|
TIME: 'Čas',
|
||||||
TIMER: 'Časovač',
|
TIMER: 'Časovač',
|
||||||
ONCHANGE: 'Při změně',
|
ONCHANGE: 'Při změně',
|
||||||
CONDITION: 'Podmínka',
|
CONDITION: 'Podmínka',
|
||||||
|
IMMEDIATE: 'Ihned',
|
||||||
SCHEDULE_UPDATED: 'Harmonogram aktualizován',
|
SCHEDULE_UPDATED: 'Harmonogram aktualizován',
|
||||||
SCHEDULE_TIMER_1: 'při startu',
|
SCHEDULE_TIMER_1: 'při startu',
|
||||||
SCHEDULE_TIMER_2: 'každou minutu',
|
SCHEDULE_TIMER_2: 'každou minutu',
|
||||||
@@ -340,7 +343,6 @@ const cs: Translation = {
|
|||||||
AUTO_SCROLL: 'Automatické rolování',
|
AUTO_SCROLL: 'Automatické rolování',
|
||||||
DASHBOARD: 'Dashboard',
|
DASHBOARD: 'Dashboard',
|
||||||
DEVELOPER_MODE: 'Režim vývojáře',
|
DEVELOPER_MODE: 'Režim vývojáře',
|
||||||
DISABLE_RESET: 'Zakázat obnovení továrního nastavení',
|
|
||||||
BYTES: 'Bajty',
|
BYTES: 'Bajty',
|
||||||
BITMASK: 'Bit Mask',
|
BITMASK: 'Bit Mask',
|
||||||
DUPLICATE: 'Duplikát',
|
DUPLICATE: 'Duplikát',
|
||||||
@@ -362,13 +364,8 @@ const cs: Translation = {
|
|||||||
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
|
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
|
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
|
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
|
||||||
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?',
|
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?'
|
||||||
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán',
|
|
||||||
SYSTEM_NAME: 'Název systému',
|
|
||||||
COMMANDS: 'Příkazy',
|
|
||||||
COMMANDS_UPDATED: 'Příkazy byly aktualizovány',
|
|
||||||
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
|
|
||||||
EXECUTE_COMMAND_SENT: 'Příkaz odeslán',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default cs;
|
export default cs;
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const de: Translation = {
|
|||||||
ID_OF: '{0}-ID',
|
ID_OF: '{0}-ID',
|
||||||
DEVICE: 'Geräte',
|
DEVICE: 'Geräte',
|
||||||
PRODUCT: 'Produkt',
|
PRODUCT: 'Produkt',
|
||||||
BRAND: 'Marke',
|
|
||||||
VERSION: 'Version',
|
VERSION: 'Version',
|
||||||
|
BRAND: 'Marke',
|
||||||
ENTITY_NAME: 'Entitätsname',
|
ENTITY_NAME: 'Entitätsname',
|
||||||
VALUE: '{{wert|Wert}}',
|
VALUE: '{{wert|Wert}}',
|
||||||
DEVICES: 'Geräte',
|
DEVICES: 'Geräte',
|
||||||
@@ -102,8 +102,8 @@ const de: Translation = {
|
|||||||
PHY_TYPE: 'Eth PHY Typ',
|
PHY_TYPE: 'Eth PHY Typ',
|
||||||
DISABLED: 'deaktiviert',
|
DISABLED: 'deaktiviert',
|
||||||
TX_MODE: 'EMS Tx-Modus',
|
TX_MODE: 'EMS Tx-Modus',
|
||||||
EMS_BUS: '{{Bus|EMS Bus}}',
|
|
||||||
HARDWARE: 'Hardware',
|
HARDWARE: 'Hardware',
|
||||||
|
EMS_BUS: '{{BUS|EMS BUS}}',
|
||||||
GENERAL_OPTIONS: 'Allgemeine Optionen',
|
GENERAL_OPTIONS: 'Allgemeine Optionen',
|
||||||
LANGUAGE_ENTITIES: 'Sprache (für Geräteentitäten)',
|
LANGUAGE_ENTITIES: 'Sprache (für Geräteentitäten)',
|
||||||
HIDE_LED: 'LED ausblenden',
|
HIDE_LED: 'LED ausblenden',
|
||||||
@@ -247,7 +247,8 @@ const de: Translation = {
|
|||||||
TIME_ZONE: 'Zeitzone',
|
TIME_ZONE: 'Zeitzone',
|
||||||
ACCESS_POINT: 'Zugangspunkt',
|
ACCESS_POINT: 'Zugangspunkt',
|
||||||
AP_PROVIDE: 'Aktiviere Zugangspunkt',
|
AP_PROVIDE: 'Aktiviere Zugangspunkt',
|
||||||
AP_PROVIDE_TEXT_2: 'Wenn Netzwerkverbindung verloren geht',
|
AP_PROVIDE_TEXT_1: 'Immer',
|
||||||
|
AP_PROVIDE_TEXT_2: 'Wenn WiFi nicht verbunden',
|
||||||
AP_PROVIDE_TEXT_3: 'Niemals',
|
AP_PROVIDE_TEXT_3: 'Niemals',
|
||||||
AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal',
|
AP_PREFERRED_CHANNEL: 'Bevorzugter Kanal',
|
||||||
AP_HIDE_SSID: 'Verstecke SSID',
|
AP_HIDE_SSID: 'Verstecke SSID',
|
||||||
@@ -261,6 +262,7 @@ const de: Translation = {
|
|||||||
SCAN_AGAIN: 'Erneute Suche',
|
SCAN_AGAIN: 'Erneute Suche',
|
||||||
NETWORK_SCANNER: 'Netzwerksuche',
|
NETWORK_SCANNER: 'Netzwerksuche',
|
||||||
NETWORK_NO_WIFI: 'Keine WiFi-Netzwerke gefunden',
|
NETWORK_NO_WIFI: 'Keine WiFi-Netzwerke gefunden',
|
||||||
|
NETWORK_BLANK_SSID: 'Freilassen, um WiFi zu deaktivieren und ETH zu aktivieren.',
|
||||||
NETWORK_BLANK_BSSID: 'Freilassen, um nur SSID für die Verbindung zu nutzen.',
|
NETWORK_BLANK_BSSID: 'Freilassen, um nur SSID für die Verbindung zu nutzen.',
|
||||||
TX_POWER: 'Tx Leistung',
|
TX_POWER: 'Tx Leistung',
|
||||||
HOSTNAME: 'Hostname',
|
HOSTNAME: 'Hostname',
|
||||||
@@ -286,13 +288,14 @@ const de: Translation = {
|
|||||||
STAY: 'Bleiben',
|
STAY: 'Bleiben',
|
||||||
LEAVE: 'Verlassen',
|
LEAVE: 'Verlassen',
|
||||||
SCHEDULER: 'Planer',
|
SCHEDULER: 'Planer',
|
||||||
SCHEDULER_HELP_1: 'Fügen Sie eigene geplante Befehle zur Automatisierung hinzu',
|
SCHEDULER_HELP_1: 'Fügen Sie eigene geplante Befehle zur Automatisierung hinzu. Vergeben Sie einen Entitätsnamen, um die Aktivierung über API/Mqtt zu steuern',
|
||||||
SCHEDULER_HELP_2: 'beim Start',
|
SCHEDULER_HELP_2: '00:00 aktiviert einmalige Ausführung beim Start.',
|
||||||
SCHEDULE: 'Zeitplan',
|
SCHEDULE: 'Zeitplan',
|
||||||
TIME: 'Zeit',
|
TIME: 'Zeit',
|
||||||
TIMER: 'Timer',
|
TIMER: 'Timer',
|
||||||
ONCHANGE: 'Bei Änderung',
|
ONCHANGE: 'Bei Änderung',
|
||||||
CONDITION: 'Zustand',
|
CONDITION: 'Zustand',
|
||||||
|
IMMEDIATE: 'Sofort',
|
||||||
SCHEDULE_UPDATED: 'Zeitplan aktualisiert',
|
SCHEDULE_UPDATED: 'Zeitplan aktualisiert',
|
||||||
SCHEDULE_TIMER_1: 'beim Start',
|
SCHEDULE_TIMER_1: 'beim Start',
|
||||||
SCHEDULE_TIMER_2: 'jede Minute',
|
SCHEDULE_TIMER_2: 'jede Minute',
|
||||||
@@ -340,7 +343,6 @@ const de: Translation = {
|
|||||||
AUTO_SCROLL: 'Automatisches Scrollen',
|
AUTO_SCROLL: 'Automatisches Scrollen',
|
||||||
DASHBOARD: 'Dashboard',
|
DASHBOARD: 'Dashboard',
|
||||||
DEVELOPER_MODE: 'Entwicklermodus',
|
DEVELOPER_MODE: 'Entwicklermodus',
|
||||||
DISABLE_RESET: 'Werkseinstellungen deaktivieren',
|
|
||||||
BYTES: 'Bytes',
|
BYTES: 'Bytes',
|
||||||
BITMASK: 'Bit Maske',
|
BITMASK: 'Bit Maske',
|
||||||
DUPLICATE: 'Kopieren',
|
DUPLICATE: 'Kopieren',
|
||||||
@@ -362,13 +364,8 @@ const de: Translation = {
|
|||||||
UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren',
|
UPGRADE_IMPORTANT_MESSAGES: 'Wichtige Nachrichten aktualisieren',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.',
|
UPGRADE_IMPORTANT_MESSAGES_1: 'Für diese Aktualisierung ist ein Werksreset erforderlich. Stellen Sie sicher, dass Sie zuerst eine Systemsicherung herunterladen, bevor Sie fortfahren, und laden Sie diese Datei dann nach der Installation der neuen Version hoch.',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_2: 'Sie aktualisieren auf eine neue Hauptversion. Stellen Sie sicher, dass Sie den ChangeLog für alle wichtigen Änderungen gelesen haben.',
|
UPGRADE_IMPORTANT_MESSAGES_2: 'Sie aktualisieren auf eine neue Hauptversion. Stellen Sie sicher, dass Sie den ChangeLog für alle wichtigen Änderungen gelesen haben.',
|
||||||
WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?',
|
WARNING_SYSTEM_BACKUP: 'Dies wird eine Sicherung Ihrer vollständigen Systemkonfiguration und Einstellungen erstellen. Alle Passwörter werden in dieser Sicherungsdatei lesbar sein. Seien Sie vorsichtig beim Teilen! Möchten Sie fortfahren?'
|
||||||
TEST_EMAIL_SUCCESSFUL: 'Test email erfolgreich gesendet',
|
|
||||||
SYSTEM_NAME: 'Systemname',
|
|
||||||
COMMANDS: 'Befehle',
|
|
||||||
COMMANDS_UPDATED: 'Befehle wurden aktualisiert',
|
|
||||||
COMMANDS_HELP_1: 'Definieren Sie eigene Befehle für die EMS-Magistral',
|
|
||||||
EXECUTE_COMMAND_SENT: 'Befehl gesendet',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default de;
|
export default de;
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ const en: Translation = {
|
|||||||
ID_OF: '{0} ID',
|
ID_OF: '{0} ID',
|
||||||
DEVICE: 'Device',
|
DEVICE: 'Device',
|
||||||
PRODUCT: 'Product',
|
PRODUCT: 'Product',
|
||||||
BRAND: 'Brand',
|
|
||||||
VERSION: 'Version',
|
VERSION: 'Version',
|
||||||
|
BRAND: 'Brand',
|
||||||
ENTITY_NAME: 'Entity Name',
|
ENTITY_NAME: 'Entity Name',
|
||||||
VALUE: '{{value|Value}}',
|
VALUE: '{{value|Value}}',
|
||||||
DEVICES: 'Devices',
|
DEVICES: 'Devices',
|
||||||
@@ -102,8 +102,8 @@ const en: Translation = {
|
|||||||
PHY_TYPE: 'Eth PHY Type',
|
PHY_TYPE: 'Eth PHY Type',
|
||||||
DISABLED: 'disabled',
|
DISABLED: 'disabled',
|
||||||
TX_MODE: 'EMS Tx Mode',
|
TX_MODE: 'EMS Tx Mode',
|
||||||
EMS_BUS: '{{Bus|EMS Bus}}',
|
|
||||||
HARDWARE: 'Hardware',
|
HARDWARE: 'Hardware',
|
||||||
|
EMS_BUS: '{{BUS|EMS BUS}}',
|
||||||
GENERAL_OPTIONS: 'General Options',
|
GENERAL_OPTIONS: 'General Options',
|
||||||
LANGUAGE_ENTITIES: 'Language (for device entities)',
|
LANGUAGE_ENTITIES: 'Language (for device entities)',
|
||||||
HIDE_LED: 'Hide LED',
|
HIDE_LED: 'Hide LED',
|
||||||
@@ -247,7 +247,8 @@ const en: Translation = {
|
|||||||
TIME_ZONE: 'Time Zone',
|
TIME_ZONE: 'Time Zone',
|
||||||
ACCESS_POINT: 'Access Point',
|
ACCESS_POINT: 'Access Point',
|
||||||
AP_PROVIDE: 'Enable Access Point',
|
AP_PROVIDE: 'Enable Access Point',
|
||||||
AP_PROVIDE_TEXT_2: 'When network connection is lost',
|
AP_PROVIDE_TEXT_1: 'Always',
|
||||||
|
AP_PROVIDE_TEXT_2: 'When WiFi is disconnected',
|
||||||
AP_PROVIDE_TEXT_3: 'Never',
|
AP_PROVIDE_TEXT_3: 'Never',
|
||||||
AP_PREFERRED_CHANNEL: 'Preferred Channel',
|
AP_PREFERRED_CHANNEL: 'Preferred Channel',
|
||||||
AP_HIDE_SSID: 'Hide SSID',
|
AP_HIDE_SSID: 'Hide SSID',
|
||||||
@@ -261,6 +262,7 @@ const en: Translation = {
|
|||||||
SCAN_AGAIN: 'Scan again',
|
SCAN_AGAIN: 'Scan again',
|
||||||
NETWORK_SCANNER: 'Network Scanner',
|
NETWORK_SCANNER: 'Network Scanner',
|
||||||
NETWORK_NO_WIFI: 'No WiFi networks found',
|
NETWORK_NO_WIFI: 'No WiFi networks found',
|
||||||
|
NETWORK_BLANK_SSID: 'leave blank to disable WiFi and enable ETH',
|
||||||
NETWORK_BLANK_BSSID: 'leave blank to use only SSID',
|
NETWORK_BLANK_BSSID: 'leave blank to use only SSID',
|
||||||
TX_POWER: 'Tx Power',
|
TX_POWER: 'Tx Power',
|
||||||
HOSTNAME: 'Hostname',
|
HOSTNAME: 'Hostname',
|
||||||
@@ -286,20 +288,21 @@ const en: Translation = {
|
|||||||
STAY: 'Stay',
|
STAY: 'Stay',
|
||||||
LEAVE: 'Leave',
|
LEAVE: 'Leave',
|
||||||
SCHEDULER: 'Scheduler',
|
SCHEDULER: 'Scheduler',
|
||||||
SCHEDULER_HELP_1: 'Automate commands by adding scheduled events below',
|
SCHEDULER_HELP_1: 'Automate commands by adding scheduled events below. Set a unique Name to enable/disable activation via API/MQTT',
|
||||||
SCHEDULER_HELP_2: 'at start-up',
|
SCHEDULER_HELP_2: 'Use 00:00 to trigger once on start-up',
|
||||||
SCHEDULE: 'Schedule',
|
SCHEDULE: 'Schedule',
|
||||||
TIME: 'Time',
|
TIME: 'Time',
|
||||||
TIMER: 'Timer',
|
TIMER: 'Timer',
|
||||||
ONCHANGE: 'On Change',
|
ONCHANGE: 'On Change',
|
||||||
CONDITION: 'Condition',
|
CONDITION: 'Condition',
|
||||||
|
IMMEDIATE: 'Immediate',
|
||||||
SCHEDULE_UPDATED: 'Schedule updated',
|
SCHEDULE_UPDATED: 'Schedule updated',
|
||||||
SCHEDULE_TIMER_1: 'on startup',
|
SCHEDULE_TIMER_1: 'on startup',
|
||||||
SCHEDULE_TIMER_2: 'every minute',
|
SCHEDULE_TIMER_2: 'every minute',
|
||||||
SCHEDULE_TIMER_3: 'every hour',
|
SCHEDULE_TIMER_3: 'every hour',
|
||||||
CUSTOM_ENTITIES: 'Custom Entities',
|
CUSTOM_ENTITIES: 'Custom Entities',
|
||||||
ENTITIES_HELP_1: 'Define custom EMS entities or dynamic user variables',
|
ENTITIES_HELP_1: 'Define custom EMS entities or dynamic user variables',
|
||||||
ENTITIES_UPDATED: 'Entities updated',
|
ENTITIES_UPDATED: 'Entities Updated',
|
||||||
WRITEABLE: 'Writeable',
|
WRITEABLE: 'Writeable',
|
||||||
SHOWING: 'Showing',
|
SHOWING: 'Showing',
|
||||||
SEARCH: 'Search',
|
SEARCH: 'Search',
|
||||||
@@ -339,12 +342,11 @@ const en: Translation = {
|
|||||||
RESTARTING_POST: 'Preparing',
|
RESTARTING_POST: 'Preparing',
|
||||||
AUTO_SCROLL: 'Auto Scroll',
|
AUTO_SCROLL: 'Auto Scroll',
|
||||||
DASHBOARD: 'Dashboard',
|
DASHBOARD: 'Dashboard',
|
||||||
|
DASHBOARD_1: 'All EMS entities that are active and marked as Favorite, plus all Custom Entities, Schedules and external Sensors data are displayed below.',
|
||||||
DEVELOPER_MODE: 'Developer Mode',
|
DEVELOPER_MODE: 'Developer Mode',
|
||||||
DISABLE_RESET: 'Disable Factory Reset',
|
|
||||||
BYTES: 'Bytes',
|
BYTES: 'Bytes',
|
||||||
BITMASK: 'Bit Mask',
|
BITMASK: 'Bit Mask',
|
||||||
DUPLICATE: 'Duplicate',
|
DUPLICATE: 'Duplicate',
|
||||||
DASHBOARD_1: 'All EMS entities that are active and marked as Favorite, plus all Custom Entities, Schedules and external Sensors data are displayed below.',
|
|
||||||
NO_DATA_1: 'No favorite EMS entities found yet. Use the',
|
NO_DATA_1: 'No favorite EMS entities found yet. Use the',
|
||||||
NO_DATA_2: 'module to mark them.',
|
NO_DATA_2: 'module to mark them.',
|
||||||
NO_DATA_3: 'To see all available entities go to',
|
NO_DATA_3: 'To see all available entities go to',
|
||||||
@@ -362,13 +364,8 @@ const en: Translation = {
|
|||||||
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages',
|
UPGRADE_IMPORTANT_MESSAGES: 'Upgrade Important Messages',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.',
|
UPGRADE_IMPORTANT_MESSAGES_1: 'This upgrade requires a factory reset. Make sure you first download a System Backup before continuing, and then upload this file after the new version is installed.',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.',
|
UPGRADE_IMPORTANT_MESSAGES_2: 'You are upgrading to a new major version. Make sure you have read the ChangeLog for any breaking changes.',
|
||||||
WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?',
|
WARNING_SYSTEM_BACKUP: 'This will create a backup of your full system configuration and settings. All passwords will be readable in the backup file. Be careful with sharing! Do you want to continue?'
|
||||||
TEST_EMAIL_SUCCESSFUL: 'Test email sent successfully',
|
|
||||||
SYSTEM_NAME: 'System Name',
|
|
||||||
COMMANDS: 'Commands',
|
|
||||||
COMMANDS_UPDATED: 'Commands updated',
|
|
||||||
COMMANDS_HELP_1: 'Define reusable named commands below. These can be executed from the console, API/MQTT, or referenced by the Scheduler',
|
|
||||||
EXECUTE_COMMAND_SENT: 'Command sent',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user