mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-14 06:04:06 +00:00
Compare commits
74
Commits
2ec8450b78
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8a96352ec | ||
|
|
02caaef916 | ||
|
|
ba0f4cd008 | ||
|
|
45f7d91b43 | ||
|
|
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
|
|
||||||
|
|||||||
@@ -27,17 +27,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-2
@@ -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
|
||||||
@@ -975,4 +981,4 @@ There are breaking changes between 3.5.x and earlier versions of 3.6.0. Please r
|
|||||||
- some names of mqtt-tags like in v2.2.1
|
- some names of mqtt-tags like in v2.2.1
|
||||||
- new ESP32 partition side to allow for smoother OTA and fallback
|
- new ESP32 partition side to allow for smoother OTA and fallback
|
||||||
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
|
- Network Gateway IP is optional (#682)emsesp/EMS-ESP
|
||||||
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
|
- moved to a new GitHub repo <https://github.com/emsesp/EMS-ESP32>
|
||||||
|
|||||||
@@ -1,41 +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
|
|
||||||
- added eth_10mbit to Network Settings, which forces 10BASE-T half-duplex & autonegotiation off, halving the PHY’s power draw. It’s off by default as it will cause issues on managed switches with a port forced to 100Mb [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
|
|
||||||
- shunting yard show json
|
|
||||||
- `system/sendmail` called from a Command or the Scheduler computed its value twice, which stripped the quotes from literal text and then failed on characters like `!`. Also shunting yard treated a `?` inside a quoted string as a ternary, so text like `"too hot?"` returned nothing
|
|
||||||
- memory leak when using different timezones for ems-esp and thermostat[#3184](https://github.com/emsesp/EMS-ESP32/issues/3184)
|
|
||||||
- LED stayed off on a healthy system when "Disable LED" was unchecked
|
|
||||||
- Ethernet MAC address changed with the new SDK, breaking DHCP reservations (currently disabled)
|
|
||||||
- "IPv4 nameserver" showed an IPv6 address when IPv6 was in use
|
|
||||||
- HA Discovery warning on Uptime after EMS-ESP boot due to NTP not ready
|
|
||||||
- hc/control setting for UI800 thermostats [#3181](https://github.com/emsesp/EMS-ESP32/discussions/3181)
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
- show control setting only for master thermostats (0x10) [#3173](https://github.com/emsesp/EMS-ESP32/issues/3173)
|
|
||||||
- remove devices without entities not listed in 0x07 telegram
|
|
||||||
- changes to the Dialogs in Dashboard page, removed the run icon in the Commands page. Renamed Execute to Run.
|
|
||||||
- set `None` for undefined values in HA `val_tpl`
|
|
||||||
- changes in WebUI - remove multiple calls systemInfo endpoint, Vewrsion pages checks for internet connection [#3195](https://github.com/emsesp/EMS-ESP32/pull/3195)
|
|
||||||
- multiple optimization in network, power down WiFi when Ethernet and WiFi nosleep is instant when selected [#3213](https://github.com/emsesp/EMS-ESP32/pull/3213)
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ MAKEFLAGS += -j$(JOBS) -l$(shell echo $$(($(JOBS) * 2)))
|
|||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
TARGET := emsesp
|
TARGET := emsesp
|
||||||
BUILD := build
|
BUILD := build
|
||||||
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
SOURCES := src/core src/devices src/web src/test lib_standalone lib/espMqttClient/src lib/espMqttClient/src/* lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/PButton
|
||||||
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
INCLUDES := src/core src/devices src/web src/test lib_standalone lib/* lib/espMqttClient/src lib/espMqttClient/src/Transport lib/ArduinoJson/src lib/uuid-common/src lib/uuid-console/src lib/uuid-log/src lib/uuid-telnet/src lib/uuid-syslog/src
|
||||||
LIBRARIES :=
|
LIBRARIES :=
|
||||||
|
|
||||||
@@ -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",
|
||||||
|
|||||||
+26
-161
@@ -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",
|
||||||
@@ -16,11 +15,10 @@
|
|||||||
"enableMDNS": true,
|
"enableMDNS": true,
|
||||||
"enableCORS": false,
|
"enableCORS": false,
|
||||||
"CORSOrigin": "*",
|
"CORSOrigin": "*",
|
||||||
"tx_power": 0,
|
"tx_power": 0
|
||||||
"eth_10mbit": false
|
|
||||||
},
|
},
|
||||||
"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,
|
||||||
@@ -33,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,
|
||||||
@@ -53,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"
|
||||||
@@ -84,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,
|
||||||
@@ -100,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,
|
||||||
@@ -110,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,
|
||||||
@@ -134,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,
|
||||||
@@ -237,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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -278,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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+481
-521
File diff suppressed because it is too large
Load Diff
+5998
-6011
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
|
||||||
|
|||||||
+36
-27
@@ -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",
|
||||||
@@ -23,40 +23,49 @@
|
|||||||
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
"standalone-devcontainer": "concurrently -c \"auto\" \"typesafe-i18n\" \"pnpm:mock-rest\" \"vite --host\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alova/adapter-xhr": "2.3.3",
|
"@alova/adapter-xhr": "2.3.1",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.4.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.4.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.5",
|
"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.1",
|
"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.4.1",
|
"@types/node": "^25.7.0",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.5",
|
"@types/react-dom": "^19.2.3",
|
||||||
"concurrently": "^10.0.5",
|
"axe-core": "^4.12.1",
|
||||||
"eslint": "^10.9.1",
|
"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",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"prettier": "^3.9.6",
|
"terser": "^5.47.1",
|
||||||
"rollup-plugin-visualizer": "^7.1.1",
|
"typescript-eslint": "^8.59.3",
|
||||||
"terser": "^5.51.2",
|
"vite": "^8.0.12",
|
||||||
"typescript": "^6.0.3",
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
"typescript-eslint": "^8.69.0",
|
"vite-tsconfig-paths": "^6.1.1"
|
||||||
"vite": "^8.2.2",
|
|
||||||
"vite-plugin-imagemin": "^0.6.1"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.25.0+sha512.5cde925b4f075f725eb71fbae18a42ffe784524789f19b61c731cb8721ec28aaee160e01a8d5af4fedb2a42cdbf300efe23db356b0d4a17b4d63e11f8ab7c956"
|
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+711
-496
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());
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.6 KiB |
+37
-37
@@ -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).
|
localStorage.setItem('lang', newLocale);
|
||||||
const newLocale =
|
setLocale(newLocale);
|
||||||
stored && AVAILABLE_LOCALES.includes(stored as Locales)
|
await loadLocaleAsync(newLocale);
|
||||||
? (stored as Locales)
|
setWasLoaded(true);
|
||||||
: browserLocale;
|
|
||||||
localStorage.setItem('lang', newLocale);
|
|
||||||
setLocale(newLocale);
|
|
||||||
await loadLocaleAsync(newLocale);
|
|
||||||
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;
|
||||||
+17
-12
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useContext, useEffect, 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,13 +43,18 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateLoginRequestValue = updateValue((updater) =>
|
// Memoize callback to prevent recreation on every render
|
||||||
setSignInRequest(
|
const updateLoginRequestValue = useMemo(
|
||||||
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
() =>
|
||||||
)
|
updateValue((updater) =>
|
||||||
|
setSignInRequest(
|
||||||
|
updater as unknown as (prevState: SignInRequest) => SignInRequest
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const signIn = async () => {
|
const signIn = useCallback(async () => {
|
||||||
await callSignIn(signInRequest).catch((event: Error) => {
|
await callSignIn(signInRequest).catch((event: Error) => {
|
||||||
if (event.message === 'Unauthorized') {
|
if (event.message === 'Unauthorized') {
|
||||||
toast.warning(LL.INVALID_LOGIN());
|
toast.warning(LL.INVALID_LOGIN());
|
||||||
@@ -58,9 +63,9 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
});
|
});
|
||||||
};
|
}, [callSignIn, signInRequest, LL]);
|
||||||
|
|
||||||
const validateAndSignIn = async () => {
|
const validateAndSignIn = useCallback(async () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
SIGN_IN_REQUEST_VALIDATOR.messages({
|
SIGN_IN_REQUEST_VALIDATOR.messages({
|
||||||
required: LL.IS_REQUIRED('%s')
|
required: LL.IS_REQUIRED('%s')
|
||||||
@@ -72,9 +77,9 @@ const SignIn = memo(() => {
|
|||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [signInRequest, signIn, LL]);
|
||||||
|
|
||||||
const submitOnEnter = onEnterCallback(signIn);
|
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||||
|
|
||||||
// get rid of scrollbar
|
// get rid of scrollbar
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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,8 +1,8 @@
|
|||||||
import type { LogSettings, SystemStatus } from 'types';
|
import type { LogSettings, SystemStatus } from 'types';
|
||||||
|
|
||||||
import { alovaInstance } from './endpoints';
|
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
||||||
|
|
||||||
// systemStatus - also used in System Monitor to ping the ESP
|
// systemStatus - also used to ping in System Monitor for pinging
|
||||||
export const readSystemStatus = () =>
|
export const readSystemStatus = () =>
|
||||||
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
alovaInstance.Get<SystemStatus>('/rest/systemStatus');
|
||||||
|
|
||||||
@@ -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,284 +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 WarningIcon from '@mui/icons-material/Warning';
|
|
||||||
import { Box, Button, Typography } from '@mui/material';
|
|
||||||
|
|
||||||
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;
|
|
||||||
`,
|
|
||||||
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 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>
|
|
||||||
</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>
|
|
||||||
</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.COMMAND_EXECUTED());
|
|
||||||
})
|
|
||||||
.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.UPDATE() + '/' + LL.RUN()}
|
|
||||||
{LL.COMMAND(0)}
|
|
||||||
</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.RUN()}
|
|
||||||
</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,19 +86,21 @@ 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(
|
||||||
Table: `
|
() =>
|
||||||
|
useTheme({
|
||||||
|
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;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(1) {
|
&:nth-of-type(1) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -116,7 +120,7 @@ const CustomEntities = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -125,7 +129,7 @@ const CustomEntities = () => {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -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,41 +173,44 @@ 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(
|
||||||
setDialogOpen(false);
|
(updatedItem: EntityItem) => {
|
||||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
setDialogOpen(false);
|
||||||
const new_data = creating
|
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||||
? [
|
const new_data = creating
|
||||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
? [
|
||||||
updatedItem
|
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||||
]
|
updatedItem
|
||||||
: data.map((ei) =>
|
]
|
||||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
: data.map((ei) =>
|
||||||
);
|
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
);
|
||||||
return new_data;
|
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||||
});
|
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
|
() =>
|
||||||
?.filter((ei: EntityItem) => !ei.deleted)
|
entities
|
||||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
?.filter((ei: EntityItem) => !ei.deleted)
|
||||||
|
.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(
|
||||||
setEditItem as unknown as React.Dispatch<
|
() =>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
updateValue(
|
||||||
>
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
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') {
|
||||||
) => {
|
onClose();
|
||||||
if (reason !== 'backdropClick') {
|
}
|
||||||
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
|
||||||
<MenuItem key={val} value={i}>
|
const uomMenuItems = useMemo(
|
||||||
{val}
|
() =>
|
||||||
</MenuItem>
|
DeviceValueUOM_s.map((val, i) => (
|
||||||
));
|
<MenuItem key={val} value={i}>
|
||||||
|
{val}
|
||||||
|
</MenuItem>
|
||||||
|
)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
|||||||
@@ -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,17 +171,19 @@ const Customizations = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const entities_theme = useTheme({
|
const entities_theme = useMemo(
|
||||||
Table: `
|
() =>
|
||||||
|
useTheme({
|
||||||
|
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);
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(3) {
|
&:nth-of-type(3) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,7 @@ const Customizations = () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -204,7 +206,7 @@ const Customizations = () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -220,7 +222,7 @@ const Customizations = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Cell: `
|
Cell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -234,7 +236,9 @@ const Customizations = () => {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
function hasEntityChanged(de: DeviceEntity) {
|
||||||
return (
|
return (
|
||||||
@@ -283,23 +287,26 @@ 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(
|
||||||
let name: string;
|
(de: DeviceEntity, withShortname: boolean) => {
|
||||||
if (isCommand(de)) {
|
let name: string;
|
||||||
name = de.t
|
if (isCommand(de)) {
|
||||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
name = de.t
|
||||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||||
} else if (de.cn && de.cn !== '') {
|
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
} else if (de.cn && de.cn !== '') {
|
||||||
} else {
|
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
} else {
|
||||||
}
|
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,27 +336,33 @@ const Customizations = () => {
|
|||||||
return new_masks;
|
return new_masks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter_entity = (de: DeviceEntity) =>
|
const filter_entity = useCallback(
|
||||||
(de.m & selectedFilters || !selectedFilters) &&
|
(de: DeviceEntity) =>
|
||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
(de.m & selectedFilters || !selectedFilters) &&
|
||||||
|
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
||||||
|
[selectedFilters, search, formatName]
|
||||||
|
);
|
||||||
|
|
||||||
const maskDisabled = (set: boolean) => {
|
const maskDisabled = useCallback(
|
||||||
setDeviceEntities((prev) =>
|
(set: boolean) => {
|
||||||
prev.map((de) => {
|
setDeviceEntities((prev) =>
|
||||||
if (filter_entity(de)) {
|
prev.map((de) => {
|
||||||
const excludeMask =
|
if (filter_entity(de)) {
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
const excludeMask =
|
||||||
return {
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
...de,
|
return {
|
||||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
...de,
|
||||||
};
|
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||||
}
|
};
|
||||||
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(
|
||||||
setDialogOpen(false);
|
(updatedItem: DeviceEntity) => {
|
||||||
updateDeviceEntity(updatedItem);
|
setDialogOpen(false);
|
||||||
};
|
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(
|
||||||
setEditItem as unknown as React.Dispatch<
|
() =>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
updateValue(
|
||||||
>
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isWriteableNumber =
|
const isWriteableNumber = useMemo(
|
||||||
typeof editItem.v === 'number' &&
|
() =>
|
||||||
editItem.w &&
|
typeof editItem.v === 'number' &&
|
||||||
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
editItem.w &&
|
||||||
|
!(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') {
|
||||||
) => {
|
onClose();
|
||||||
if (reason !== 'backdropClick') {
|
}
|
||||||
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,38 +75,42 @@ 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(
|
||||||
if (!selectedDashboardItem) {
|
async (devicevalue: DeviceValue) => {
|
||||||
return;
|
if (!selectedDashboardItem) {
|
||||||
}
|
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(() => {
|
||||||
setDeviceValueDialogOpen(false);
|
toast.success(LL.WRITE_CMD_SENT());
|
||||||
setSelectedDashboardItem(undefined);
|
})
|
||||||
};
|
.catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setDeviceValueDialogOpen(false);
|
||||||
|
setSelectedDashboardItem(undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[selectedDashboardItem, sendDeviceValue, LL]
|
||||||
|
);
|
||||||
|
|
||||||
const dashboard_theme = useTheme({
|
const dashboard_theme = useMemo(
|
||||||
Table: `
|
() =>
|
||||||
|
useTheme({
|
||||||
|
Table: `
|
||||||
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
--data-table-library_grid-template-columns: minmax(80px, auto) 120px 32px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
.td {
|
.td {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
&:nth-of-type(odd) .td {
|
&:nth-of-type(odd) .td {
|
||||||
@@ -117,7 +120,7 @@ const Dashboard = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
},
|
},
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -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,71 +164,79 @@ 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(
|
||||||
// if we have a name show it
|
(n?: string, t?: number) => {
|
||||||
if (n) {
|
// if we have a name show it
|
||||||
return n;
|
if (n) {
|
||||||
}
|
return n;
|
||||||
if (t) {
|
|
||||||
// otherwise pick translation based on type
|
|
||||||
switch (t) {
|
|
||||||
case DeviceType.CUSTOM:
|
|
||||||
return LL.CUSTOM_ENTITIES(0);
|
|
||||||
case DeviceType.ANALOGSENSOR:
|
|
||||||
return LL.ANALOG_SENSORS();
|
|
||||||
case DeviceType.TEMPERATURESENSOR:
|
|
||||||
return LL.TEMP_SENSORS();
|
|
||||||
case DeviceType.COMMAND:
|
|
||||||
return LL.COMMANDS();
|
|
||||||
case DeviceType.SCHEDULER:
|
|
||||||
return LL.SCHEDULER();
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
if (t) {
|
||||||
return '';
|
// otherwise pick translation based on type
|
||||||
};
|
switch (t) {
|
||||||
|
case DeviceType.CUSTOM:
|
||||||
const showName = (di: DashboardItem) => {
|
return LL.CUSTOM_ENTITIES(0);
|
||||||
if (di.id < 100) {
|
case DeviceType.ANALOGSENSOR:
|
||||||
// if its a device (parent node) and has entities
|
return LL.ANALOG_SENSORS();
|
||||||
if (di.nodes?.length) {
|
case DeviceType.TEMPERATURESENSOR:
|
||||||
return (
|
return LL.TEMP_SENSORS();
|
||||||
<span style={{ fontSize: '15px' }}>
|
case DeviceType.SCHEDULER:
|
||||||
<DeviceIcon type_id={di.t ?? 0} />
|
return LL.SCHEDULER();
|
||||||
{showType(di.n, di.t)}
|
default:
|
||||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
break;
|
||||||
</span>
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
return '';
|
||||||
if (di.dv) {
|
},
|
||||||
return <span>{di.dv.id.slice(2)}</span>;
|
[LL]
|
||||||
}
|
);
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasMask = (id: string, mask: number) =>
|
const showName = useCallback(
|
||||||
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
(di: DashboardItem) => {
|
||||||
|
if (di.id < 100) {
|
||||||
|
// if its a device (parent node) and has entities
|
||||||
|
if (di.nodes?.length) {
|
||||||
|
return (
|
||||||
|
<span style={{ fontSize: '15px' }}>
|
||||||
|
<DeviceIcon type_id={di.t ?? 0} />
|
||||||
|
{showType(di.n, di.t)}
|
||||||
|
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (di.dv) {
|
||||||
|
return <span>{di.dv.id.slice(2)}</span>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[showType]
|
||||||
|
);
|
||||||
|
|
||||||
const editDashboardValue = (di: DashboardItem) => {
|
const hasMask = useCallback(
|
||||||
// don't execute on parent nodes
|
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
||||||
if (!me.admin || di.id <= 99) {
|
[]
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (di.dv?.c) {
|
const editDashboardValue = useCallback(
|
||||||
setSelectedDashboardItem(di);
|
(di: DashboardItem) => {
|
||||||
setDeviceValueDialogOpen(true);
|
if (me.admin && di.dv?.c) {
|
||||||
}
|
setSelectedDashboardItem(di);
|
||||||
};
|
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()}
|
|
||||||
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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -402,10 +402,7 @@ const Dashboard = memo(() => {
|
|||||||
{selectedDashboardItem && selectedDashboardItem.dv && (
|
{selectedDashboardItem && selectedDashboardItem.dv && (
|
||||||
<DevicesDialog
|
<DevicesDialog
|
||||||
open={deviceValueDialogOpen}
|
open={deviceValueDialogOpen}
|
||||||
onClose={() => {
|
onClose={() => setDeviceValueDialogOpen(false)}
|
||||||
setDeviceValueDialogOpen(false);
|
|
||||||
setSelectedDashboardItem(undefined);
|
|
||||||
}}
|
|
||||||
onSave={deviceValueDialogSave}
|
onSave={deviceValueDialogSave}
|
||||||
selectedItem={selectedDashboardItem.dv}
|
selectedItem={selectedDashboardItem.dv}
|
||||||
writeable={true}
|
writeable={true}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+122
-113
@@ -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,19 +133,21 @@ 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(
|
||||||
BaseRow: `
|
() =>
|
||||||
|
useTheme({
|
||||||
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -152,7 +155,7 @@ const Devices = memo(() => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #1E1E1E;
|
background-color: #1E1E1E;
|
||||||
.td {
|
.td {
|
||||||
@@ -162,78 +165,88 @@ const Devices = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const device_theme = useTheme([
|
const device_theme = useMemo(
|
||||||
common_theme,
|
() =>
|
||||||
{
|
useTheme([
|
||||||
BaseRow: `
|
common_theme,
|
||||||
font-size: 15px;
|
{
|
||||||
.td {
|
BaseRow: `
|
||||||
height: 28px;
|
font-size: 15px;
|
||||||
|
.td {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
.th {
|
||||||
|
padding: 8px;
|
||||||
|
`,
|
||||||
|
Row: `
|
||||||
|
&:nth-of-type(odd) .td {
|
||||||
|
background-color: #303030;
|
||||||
|
},
|
||||||
|
&:hover .td {
|
||||||
|
background-color: #177ac9;
|
||||||
|
},
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
[common_theme]
|
||||||
|
);
|
||||||
|
|
||||||
|
const data_theme = useMemo(
|
||||||
|
() =>
|
||||||
|
useTheme([
|
||||||
|
common_theme,
|
||||||
|
{
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
||||||
|
height: auto;
|
||||||
|
max-height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
display:none;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Table: `
|
BaseRow: `
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
.td {
|
||||||
`,
|
height: 32px;
|
||||||
HeaderRow: `
|
}
|
||||||
.th {
|
`,
|
||||||
padding: 8px;
|
BaseCell: `
|
||||||
`,
|
&:nth-of-type(1) {
|
||||||
Row: `
|
border-left: 1px solid #177ac9;
|
||||||
&:nth-of-type(odd) .td {
|
},
|
||||||
|
&:nth-of-type(2) {
|
||||||
|
text-align: right;
|
||||||
|
},
|
||||||
|
&:nth-of-type(3) {
|
||||||
|
border-right: 1px solid #177ac9;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
.th {
|
||||||
|
border-top: 1px solid #565656;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Row: `
|
||||||
|
&:nth-of-type(odd) .td {
|
||||||
background-color: #303030;
|
background-color: #303030;
|
||||||
},
|
},
|
||||||
&:hover .td {
|
&:hover .td {
|
||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
},
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const data_theme = useTheme([
|
|
||||||
common_theme,
|
|
||||||
{
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: minmax(200px, auto) minmax(150px, auto) 40px;
|
|
||||||
height: auto;
|
|
||||||
max-height: 100%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
display:none;
|
|
||||||
}
|
}
|
||||||
`,
|
`
|
||||||
BaseRow: `
|
}
|
||||||
.td {
|
]),
|
||||||
height: 32px;
|
[common_theme]
|
||||||
}
|
);
|
||||||
`,
|
|
||||||
BaseCell: `
|
|
||||||
&:nth-of-type(1) {
|
|
||||||
border-left: 1px solid #177ac9;
|
|
||||||
},
|
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: right;
|
|
||||||
},
|
|
||||||
&:nth-of-type(3) {
|
|
||||||
border-right: 1px solid #177ac9;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
HeaderRow: `
|
|
||||||
.th {
|
|
||||||
border-top: 1px solid #565656;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
Row: `
|
|
||||||
&:nth-of-type(odd) .td {
|
|
||||||
background-color: #303030;
|
|
||||||
},
|
|
||||||
&:hover .td {
|
|
||||||
background-color: #177ac9;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
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 style={{ color: 'lightblue' }}>
|
||||||
|
({device.e})
|
||||||
</span>
|
</span>
|
||||||
{device.e !== 0 && (
|
|
||||||
<span style={{ color: 'lightblue' }}>
|
|
||||||
({device.e})
|
|
||||||
</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>
|
||||||
@@ -592,7 +597,6 @@ const Devices = memo(() => {
|
|||||||
|
|
||||||
const deviceValueDialogClose = () => {
|
const deviceValueDialogClose = () => {
|
||||||
setDeviceValueDialogOpen(false);
|
setDeviceValueDialogOpen(false);
|
||||||
setSelectedDeviceValue(undefined);
|
|
||||||
if (selectedDevice !== undefined) {
|
if (selectedDevice !== undefined) {
|
||||||
void sendDeviceData(selectedDevice);
|
void sendDeviceData(selectedDevice);
|
||||||
}
|
}
|
||||||
@@ -603,35 +607,41 @@ 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)}
|
<>
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
{dv.id.slice(2)}
|
||||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||||
)}
|
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
)}
|
||||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||||
)}
|
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
)}
|
||||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||||
)}
|
<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) =>
|
|
||||||
dv.id.slice(2).toLowerCase().includes(search.toLowerCase())
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
return deviceData.nodes.filter((dv: DeviceValue) =>
|
||||||
|
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
|
||||||
@@ -788,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,18 +18,15 @@ 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, getDeviceValueKind } from './types';
|
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||||
import type { DeviceValue } from './types';
|
import type { DeviceValue } from './types';
|
||||||
|
|
||||||
interface DevicesDialogProps {
|
interface DevicesDialogProps {
|
||||||
@@ -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,48 +61,36 @@ 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.COMMAND_EXECUTED());
|
|
||||||
})
|
|
||||||
.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 validate(validator, editItem);
|
||||||
await executeCommand(editItem.c);
|
|
||||||
} else {
|
|
||||||
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(
|
||||||
if (uom === undefined) {
|
(uom?: DeviceValueUOM) => {
|
||||||
return;
|
if (uom === undefined) {
|
||||||
}
|
return;
|
||||||
switch (uom) {
|
}
|
||||||
case DeviceValueUOM.HOURS:
|
switch (uom) {
|
||||||
return LL.HOURS();
|
case DeviceValueUOM.HOURS:
|
||||||
case DeviceValueUOM.MINUTES:
|
return LL.HOURS();
|
||||||
return LL.MINUTES();
|
case DeviceValueUOM.MINUTES:
|
||||||
case DeviceValueUOM.SECONDS:
|
return LL.MINUTES();
|
||||||
return LL.SECONDS();
|
case DeviceValueUOM.SECONDS:
|
||||||
default:
|
return LL.SECONDS();
|
||||||
return DeviceValueUOM_s[uom];
|
default:
|
||||||
}
|
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,23 +101,26 @@ const DevicesDialog = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const kind = getDeviceValueKind(selectedItem);
|
const isCommand = useMemo(
|
||||||
|
() => selectedItem.v === '' && selectedItem.c,
|
||||||
|
[selectedItem.v, selectedItem.c]
|
||||||
|
);
|
||||||
|
|
||||||
const dialogTitle =
|
const dialogTitle = useMemo(() => {
|
||||||
kind === 'command' || kind === 'deviceCommand'
|
if (isCommand) return LL.RUN_COMMAND();
|
||||||
? LL.RUN() + ' ' + LL.COMMAND(0)
|
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
||||||
: kind === 'scheduler'
|
}, [isCommand, writeable, LL]);
|
||||||
? LL.UPDATE() + ' ' + LL.SCHEDULE(0)
|
|
||||||
: writeable
|
|
||||||
? LL.CHANGE_VALUE()
|
|
||||||
: LL.VALUE(0);
|
|
||||||
|
|
||||||
const buttonLabel =
|
const buttonLabel = useMemo(() => {
|
||||||
kind === 'command' || kind === 'deviceCommand' ? LL.RUN() : LL.UPDATE();
|
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
||||||
|
}, [isCommand, LL]);
|
||||||
|
|
||||||
const helperText = showHelperText(editItem);
|
const helperText = useMemo(
|
||||||
|
() => showHelperText(editItem),
|
||||||
|
[editItem, showHelperText]
|
||||||
|
);
|
||||||
|
|
||||||
const valueLabel = LL.VALUE(0);
|
const valueLabel = LL.VALUE(0);
|
||||||
|
|
||||||
@@ -144,69 +131,67 @@ 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>
|
||||||
{kind !== 'command' && (
|
<Grid container>
|
||||||
<Grid container>
|
<Grid size={12}>
|
||||||
<Grid size={12}>
|
{editItem.l ? (
|
||||||
{editItem.l ? (
|
<TextField
|
||||||
<TextField
|
name="v"
|
||||||
name="v"
|
value={editItem.v}
|
||||||
value={editItem.v}
|
aria-label={valueLabel}
|
||||||
aria-label={valueLabel}
|
disabled={!writeable}
|
||||||
disabled={!writeable}
|
sx={{ width: '30ch' }}
|
||||||
sx={{ width: '30ch' }}
|
select
|
||||||
select
|
onChange={updateFormValue}
|
||||||
onChange={updateFormValue}
|
>
|
||||||
>
|
{editItem.l.map((val) => (
|
||||||
{editItem.l.map((val) => (
|
<MenuItem value={val} key={val}>
|
||||||
<MenuItem value={val} key={val}>
|
{val}
|
||||||
{val}
|
</MenuItem>
|
||||||
</MenuItem>
|
))}
|
||||||
))}
|
</TextField>
|
||||||
</TextField>
|
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
<ValidatedTextField
|
||||||
<ValidatedTextField
|
fieldErrors={fieldErrors || {}}
|
||||||
fieldErrors={fieldErrors || {}}
|
name="v"
|
||||||
name="v"
|
label={valueLabel}
|
||||||
label={valueLabel}
|
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
autoFocus
|
||||||
autoFocus
|
disabled={!writeable}
|
||||||
disabled={!writeable}
|
type="number"
|
||||||
type="number"
|
sx={{ width: '30ch' }}
|
||||||
sx={{ width: '30ch' }}
|
onChange={updateFormValue}
|
||||||
onChange={updateFormValue}
|
slotProps={{
|
||||||
slotProps={{
|
htmlInput: editItem.s
|
||||||
htmlInput: editItem.s
|
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
: {},
|
||||||
: {},
|
input: {
|
||||||
input: {
|
startAdornment: (
|
||||||
startAdornment: (
|
<InputAdornment position="start">
|
||||||
<InputAdornment position="start">
|
{setUom(editItem.u)}
|
||||||
{setUom(editItem.u)}
|
</InputAdornment>
|
||||||
</InputAdornment>
|
)
|
||||||
)
|
}
|
||||||
}
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<ValidatedTextField
|
||||||
<ValidatedTextField
|
fieldErrors={fieldErrors || {}}
|
||||||
fieldErrors={fieldErrors || {}}
|
name="v"
|
||||||
name="v"
|
label={valueLabel}
|
||||||
label={valueLabel}
|
value={editItem.v}
|
||||||
value={editItem.v}
|
disabled={!writeable}
|
||||||
disabled={!writeable}
|
sx={{ width: '30ch' }}
|
||||||
sx={{ width: '30ch' }}
|
multiline={!editItem.u}
|
||||||
multiline={!editItem.u}
|
onChange={updateFormValue}
|
||||||
onChange={updateFormValue}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
{writeable && helperText && (
|
|
||||||
<Grid>
|
|
||||||
<FormHelperText>{helperText}</FormHelperText>
|
|
||||||
</Grid>
|
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
{writeable && helperText && (
|
||||||
|
<Grid>
|
||||||
|
<FormHelperText>{helperText}</FormHelperText>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -228,16 +213,10 @@ const DevicesDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={
|
startIcon={<WarningIcon color="warning" />}
|
||||||
kind === 'command' ? (
|
|
||||||
<PlayArrowIcon />
|
|
||||||
) : kind === 'deviceEntity' || kind === 'deviceCommand' ? (
|
|
||||||
<WarningIcon color="warning" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={doAction}
|
onClick={save}
|
||||||
color={kind !== 'value' ? '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,95 +20,123 @@ 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(
|
||||||
const newMask = getMaskNumber(mask);
|
(_event: unknown, mask: string[]) => {
|
||||||
const updatedDe = { ...de };
|
// Convert selected masks to a number
|
||||||
|
const newMask = getMaskNumber(mask);
|
||||||
|
const updatedDe = { ...de };
|
||||||
|
|
||||||
// If entity has no name and is set to readonly, also exclude from web
|
// Apply business logic for mask interactions
|
||||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
// If entity has no name and is set to readonly, also exclude from web
|
||||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||||
} else {
|
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
updatedDe.m = newMask;
|
} else {
|
||||||
}
|
updatedDe.m = newMask;
|
||||||
|
}
|
||||||
|
|
||||||
// If excluded from web, cannot be favorite
|
// If excluded from web, cannot be favorite
|
||||||
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
if (hasMask(updatedDe.m, DeviceEntityMask.DV_WEB_EXCLUDE)) {
|
||||||
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
updatedDe.m = updatedDe.m & ~DeviceEntityMask.DV_FAVORITE;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,26 +100,47 @@ 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 }), []);
|
||||||
href: 'https://emsesp.org',
|
|
||||||
icon: <MenuBookIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_1()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://discord.gg/GP9DPSgeJq',
|
|
||||||
icon: <CommentIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_2()
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
|
||||||
icon: <GitHubIcon />,
|
|
||||||
label: () => LL.HELP_INFORMATION_3()
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const imageSrc =
|
const handleDownloadSystemInfo = useCallback(() => {
|
||||||
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
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',
|
||||||
|
icon: <MenuBookIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_1()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://discord.gg/GP9DPSgeJq',
|
||||||
|
icon: <CommentIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_2()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/emsesp/EMS-ESP32/issues/new/choose',
|
||||||
|
icon: <GitHubIcon />,
|
||||||
|
label: () => LL.HELP_INFORMATION_3()
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[LL]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAdmin = useMemo(() => me?.admin ?? false, [me?.admin]);
|
||||||
|
|
||||||
|
// Memoize image source computation
|
||||||
|
const imageSrc = useMemo(
|
||||||
|
() =>
|
||||||
|
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url,
|
||||||
|
[imgError, customSupport.img_url]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
@@ -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,53 +69,58 @@ const Modules = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const modules_theme = useTheme({
|
const modules_theme = useTheme(
|
||||||
Table: `
|
useMemo(
|
||||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
() => ({
|
||||||
`,
|
Table: `
|
||||||
BaseRow: `
|
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||||
font-size: 14px;
|
`,
|
||||||
.td {
|
BaseRow: `
|
||||||
height: 32px;
|
font-size: 14px;
|
||||||
}
|
.td {
|
||||||
`,
|
height: 32px;
|
||||||
BaseCell: `
|
}
|
||||||
&:nth-of-type(1) {
|
`,
|
||||||
text-align: center;
|
BaseCell: `
|
||||||
}
|
&:nth-of-type(1) {
|
||||||
`,
|
text-align: center;
|
||||||
HeaderRow: `
|
}
|
||||||
text-transform: uppercase;
|
`,
|
||||||
background-color: black;
|
HeaderRow: `
|
||||||
color: #90CAF9;
|
text-transform: uppercase;
|
||||||
.th {
|
background-color: black;
|
||||||
border-bottom: 1px solid #565656;
|
color: #90CAF9;
|
||||||
height: 36px;
|
.th {
|
||||||
}
|
border-bottom: 1px solid #565656;
|
||||||
`,
|
height: 36px;
|
||||||
Row: `
|
}
|
||||||
background-color: #1e1e1e;
|
`,
|
||||||
position: relative;
|
Row: `
|
||||||
cursor: pointer;
|
background-color: #1e1e1e;
|
||||||
.td {
|
position: relative;
|
||||||
border-top: 1px solid #565656;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #565656;
|
.td {
|
||||||
}
|
border-top: 1px solid #565656;
|
||||||
&:hover .td {
|
border-bottom: 1px solid #565656;
|
||||||
border-top: 1px solid #177ac9;
|
}
|
||||||
border-bottom: 1px solid #177ac9;
|
&:hover .td {
|
||||||
}
|
border-top: 1px solid #177ac9;
|
||||||
&:nth-of-type(odd) .td {
|
border-bottom: 1px solid #177ac9;
|
||||||
background-color: #303030;
|
}
|
||||||
}
|
&:nth-of-type(odd) .td {
|
||||||
`
|
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(
|
||||||
setDialogOpen(false);
|
(updatedItem: ModuleItem) => {
|
||||||
updateModuleItem(updatedItem);
|
setDialogOpen(false);
|
||||||
};
|
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(
|
||||||
setEditItem as unknown as React.Dispatch<
|
() =>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
updateValue(
|
||||||
>
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
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,43 +192,46 @@ 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(
|
||||||
setDialogOpen(false);
|
(updatedItem: ScheduleItem) => {
|
||||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
setDialogOpen(false);
|
||||||
const new_data = creating
|
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||||
? [...data, updatedItem]
|
const new_data = creating
|
||||||
: data.map((si) =>
|
? [...data, updatedItem]
|
||||||
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
: data.map((si) =>
|
||||||
);
|
si.id === updatedItem.id ? { ...si, ...updatedItem } : si
|
||||||
|
);
|
||||||
|
|
||||||
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
setNumChanges(new_data.filter((si) => hasScheduleChanged(si)).length);
|
||||||
|
|
||||||
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,29 +239,36 @@ const Scheduler = () => {
|
|||||||
};
|
};
|
||||||
setSelectedScheduleItem(newItem);
|
setSelectedScheduleItem(newItem);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const filteredAndSortedSchedule = schedule
|
const filteredAndSortedSchedule = useMemo(
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
() =>
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
schedule
|
||||||
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
|
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
||||||
|
[schedule]
|
||||||
|
);
|
||||||
|
|
||||||
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
|
const dayBox = useCallback(
|
||||||
const dayIndex = Math.log(flag) / LOG_2;
|
(si: ScheduleItem, flag: number) => {
|
||||||
const isActive = (si.flags & flag) === flag;
|
const dayIndex = Math.log(flag) / LOG_2;
|
||||||
|
const isActive = (si.flags & flag) === flag;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
<Typography sx={{ fontSize: 11 }} color={isActive ? 'primary' : 'grey'}>
|
||||||
{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(
|
||||||
setEditItem as unknown as React.Dispatch<
|
() =>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
updateValue(
|
||||||
>
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
),
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
setEditItem(selectedItem);
|
setEditItem(selectedItem);
|
||||||
|
// Set the flags based on type when page is loaded:
|
||||||
|
// 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,89 +112,129 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleSave = async (itemToSave: ScheduleItem) => {
|
// Helper function to handle save operations
|
||||||
try {
|
const handleSave = useCallback(
|
||||||
setFieldErrors(undefined);
|
async (itemToSave: ScheduleItem) => {
|
||||||
await validate(validator, itemToSave);
|
try {
|
||||||
onSave(itemToSave);
|
setFieldErrors(undefined);
|
||||||
} catch (error) {
|
await validate(validator, itemToSave);
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
onSave(itemToSave);
|
||||||
}
|
} catch (error) {
|
||||||
};
|
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 dayIndex = Math.log2(flag);
|
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
||||||
const isSelected = (editItem.flags & flag) === flag;
|
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
||||||
return (
|
}, []);
|
||||||
<Typography
|
|
||||||
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
const getFlagDOWstring = useCallback((f: number) => {
|
||||||
color={isSelected ? 'primary' : 'grey'}
|
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
||||||
>
|
String(flag)
|
||||||
{dow[dayIndex]}
|
|
||||||
</Typography>
|
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleClose = (
|
// Day of week display component
|
||||||
_event: React.SyntheticEvent,
|
const DayOfWeekButton = useCallback(
|
||||||
reason: 'backdropClick' | 'escapeKeyDown'
|
(flag: number) => {
|
||||||
) => {
|
const dayIndex = Math.log2(flag);
|
||||||
if (reason !== 'backdropClick') {
|
const isSelected = (editItem.flags & flag) === flag;
|
||||||
onClose();
|
return (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
|
color={isSelected ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{dow[dayIndex]}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[editItem.flags, dow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(
|
||||||
|
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
||||||
|
if (reason !== 'backdropClick') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleScheduleTypeChange = useCallback(
|
||||||
|
(_event: React.SyntheticEvent<HTMLElement>, flag: ScheduleFlag | null) => {
|
||||||
|
if (flag !== null) {
|
||||||
|
setFieldErrors(undefined); // clear any validation errors
|
||||||
|
setScheduleType(flag);
|
||||||
|
// wipe the time field when changing the schedule type
|
||||||
|
// set the flags based on type
|
||||||
|
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
||||||
|
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDOWChange = useCallback(
|
||||||
|
(_event: React.SyntheticEvent<HTMLElement>, flags: string[]) => {
|
||||||
|
const newFlags =
|
||||||
|
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
||||||
|
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
||||||
|
},
|
||||||
|
[getFlagDOWnumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Memoize derived values
|
||||||
|
const isDaySchedule = useMemo(
|
||||||
|
() => scheduleType === ScheduleFlag.SCHEDULE_DAY,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const isTimerSchedule = useMemo(
|
||||||
|
() => scheduleType === ScheduleFlag.SCHEDULE_TIMER,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const isImmediateSchedule = useMemo(
|
||||||
|
() => scheduleType === ScheduleFlag.SCHEDULE_IMMEDIATE,
|
||||||
|
[scheduleType]
|
||||||
|
);
|
||||||
|
const needsTimeField = useMemo(
|
||||||
|
() => isDaySchedule || isTimerSchedule,
|
||||||
|
[isDaySchedule, isTimerSchedule]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dowFlags = useMemo(
|
||||||
|
() => getFlagDOWstring(editItem.flags),
|
||||||
|
[editItem.flags, getFlagDOWstring]
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFieldValue = useMemo(() => {
|
||||||
|
if (needsTimeField) {
|
||||||
|
return editItem.time === '' ? DEFAULT_TIME : editItem.time;
|
||||||
}
|
}
|
||||||
};
|
return editItem.time === DEFAULT_TIME ? '' : editItem.time;
|
||||||
|
}, [editItem.time, needsTimeField]);
|
||||||
|
|
||||||
const handleScheduleTypeChange = (
|
const timeFieldLabel = useMemo(() => {
|
||||||
_event: React.SyntheticEvent<HTMLElement>,
|
|
||||||
flag: ScheduleFlag | null
|
|
||||||
) => {
|
|
||||||
if (flag !== null) {
|
|
||||||
setFieldErrors(undefined); // clear any validation errors
|
|
||||||
setScheduleType(flag);
|
|
||||||
// wipe the time field when changing the schedule type
|
|
||||||
// set the flags based on type
|
|
||||||
const newFlags = flag === ScheduleFlag.SCHEDULE_DAY ? FLAG_ALL_DAYS : flag;
|
|
||||||
setEditItem((prev) => ({ ...prev, time: '', flags: newFlags }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDOWChange = (
|
|
||||||
_event: React.SyntheticEvent<HTMLElement>,
|
|
||||||
flags: string[]
|
|
||||||
) => {
|
|
||||||
const newFlags =
|
|
||||||
getFlagDOWnumber(flags) === 0 ? FLAG_ALL_DAYS : getFlagDOWnumber(flags);
|
|
||||||
setEditItem((prev) => ({ ...prev, flags: newFlags }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDaySchedule = scheduleType === ScheduleFlag.SCHEDULE_DAY;
|
|
||||||
const isTimerSchedule = scheduleType === ScheduleFlag.SCHEDULE_TIMER;
|
|
||||||
const needsTimeField = isDaySchedule || isTimerSchedule;
|
|
||||||
|
|
||||||
const dowFlags = getFlagDOWstring(editItem.flags);
|
|
||||||
|
|
||||||
const timeFieldValue = needsTimeField
|
|
||||||
? editItem.time === ''
|
|
||||||
? DEFAULT_TIME
|
|
||||||
: editItem.time
|
|
||||||
: editItem.time === DEFAULT_TIME
|
|
||||||
? ''
|
|
||||||
: editItem.time;
|
|
||||||
|
|
||||||
const timeFieldLabel = (() => {
|
|
||||||
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,74 +312,78 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Grid container>
|
{!isImmediateSchedule && (
|
||||||
<BlockFormControlLabel
|
<>
|
||||||
control={
|
<Grid container>
|
||||||
<Checkbox
|
<BlockFormControlLabel
|
||||||
checked={editItem.active}
|
control={
|
||||||
onChange={updateFormValue}
|
<Checkbox
|
||||||
name="active"
|
checked={editItem.active}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
name="active"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={LL.ACTIVE()}
|
||||||
/>
|
/>
|
||||||
}
|
</Grid>
|
||||||
label={LL.ACTIVE()}
|
<Grid container>
|
||||||
/>
|
{needsTimeField ? (
|
||||||
<CircleIcon
|
<>
|
||||||
color={editItem.active ? 'success' : 'error'}
|
<TextField
|
||||||
sx={{
|
name="time"
|
||||||
fontSize: 16,
|
type="time"
|
||||||
mt: '12px'
|
label={timeFieldLabel}
|
||||||
}}
|
value={timeFieldValue}
|
||||||
/>
|
margin="normal"
|
||||||
</Grid>
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
<Grid container>
|
{isTimerSchedule && (
|
||||||
{needsTimeField ? (
|
<Typography
|
||||||
<>
|
sx={{ ml: 2, mt: 4 }}
|
||||||
<TextField
|
color="warning"
|
||||||
name="time"
|
variant="body2"
|
||||||
type="time"
|
>
|
||||||
label={timeFieldLabel}
|
{LL.SCHEDULER_HELP_2()}
|
||||||
value={timeFieldValue}
|
</Typography>
|
||||||
margin="normal"
|
)}
|
||||||
onChange={updateFormValue}
|
</>
|
||||||
/>
|
) : (
|
||||||
{isTimerSchedule && (
|
<TextField
|
||||||
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
|
name="time"
|
||||||
00:00 = {LL.SCHEDULER_HELP_2()}
|
label={timeFieldLabel}
|
||||||
</Typography>
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={timeFieldValue}
|
||||||
|
margin="normal"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</Grid>
|
||||||
) : (
|
</>
|
||||||
<TextField
|
)}
|
||||||
name="time"
|
<ValidatedTextField
|
||||||
label={timeFieldLabel}
|
fieldErrors={fieldErrors || {}}
|
||||||
multiline
|
name="cmd"
|
||||||
fullWidth
|
|
||||||
value={timeFieldValue}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
<TextField
|
|
||||||
name="cmd_name"
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
|
|||||||
+290
-246
@@ -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,104 +234,119 @@ const Sensors = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = (duration_min: number) => {
|
const formatDurationMin = useCallback(
|
||||||
const totalMs = duration_min * MS_PER_MINUTE;
|
(duration_min: number) => {
|
||||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
const totalMs = duration_min * MS_PER_MINUTE;
|
||||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||||
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||||
|
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
parts.push(LL.NUM_DAYS({ num: days }));
|
parts.push(LL.NUM_DAYS({ num: days }));
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
if (hours > 0) {
|
||||||
parts.push(LL.NUM_HOURS({ num: hours }));
|
parts.push(LL.NUM_HOURS({ num: hours }));
|
||||||
}
|
}
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
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(
|
||||||
if (value === undefined) {
|
(value: unknown, uom: DeviceValueUOM) => {
|
||||||
return '';
|
if (value === undefined) {
|
||||||
}
|
return '';
|
||||||
if (typeof value !== 'number') {
|
}
|
||||||
return value as string;
|
if (typeof value !== 'number') {
|
||||||
}
|
return value as string;
|
||||||
switch (uom) {
|
}
|
||||||
case DeviceValueUOM.HOURS:
|
switch (uom) {
|
||||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
case DeviceValueUOM.HOURS:
|
||||||
case DeviceValueUOM.MINUTES:
|
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
case DeviceValueUOM.MINUTES:
|
||||||
case DeviceValueUOM.SECONDS:
|
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||||
return LL.NUM_SECONDS({ num: value });
|
case DeviceValueUOM.SECONDS:
|
||||||
case DeviceValueUOM.NONE:
|
return LL.NUM_SECONDS({ num: value });
|
||||||
return new Intl.NumberFormat().format(value);
|
case DeviceValueUOM.NONE:
|
||||||
case DeviceValueUOM.DEGREES:
|
return new Intl.NumberFormat().format(value);
|
||||||
case DeviceValueUOM.DEGREES_R:
|
case DeviceValueUOM.DEGREES:
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
case DeviceValueUOM.DEGREES_R:
|
||||||
return (
|
case DeviceValueUOM.FAHRENHEIT:
|
||||||
new Intl.NumberFormat(undefined, {
|
return (
|
||||||
minimumFractionDigits: 1
|
new Intl.NumberFormat(undefined, {
|
||||||
}).format(value) +
|
minimumFractionDigits: 1
|
||||||
' ' +
|
}).format(value) +
|
||||||
DeviceValueUOM_s[uom]
|
' ' +
|
||||||
);
|
DeviceValueUOM_s[uom]
|
||||||
default:
|
);
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
default:
|
||||||
}
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
};
|
}
|
||||||
|
},
|
||||||
|
[formatDurationMin, LL]
|
||||||
|
);
|
||||||
|
|
||||||
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
const updateTemperatureSensor = useCallback(
|
||||||
if (me.admin) {
|
(ts: TemperatureSensor) => {
|
||||||
ts.o_n = ts.n;
|
if (me.admin) {
|
||||||
setSelectedTemperatureSensor(ts);
|
ts.o_n = ts.n;
|
||||||
setTemperatureDialogOpen(true);
|
setSelectedTemperatureSensor(ts);
|
||||||
}
|
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(
|
||||||
await sendTemperatureSensor({
|
async (ts: TemperatureSensor) => {
|
||||||
id: ts.id,
|
await sendTemperatureSensor({
|
||||||
name: ts.n,
|
id: ts.id,
|
||||||
offset: ts.o,
|
name: ts.n,
|
||||||
is_system: ts.s
|
offset: ts.o,
|
||||||
})
|
is_system: ts.s
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.catch(() => {
|
||||||
setTemperatureDialogOpen(false);
|
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||||
setSelectedTemperatureSensor(undefined);
|
})
|
||||||
void fetchSensorData();
|
.finally(() => {
|
||||||
});
|
setTemperatureDialogOpen(false);
|
||||||
};
|
setSelectedTemperatureSensor(undefined);
|
||||||
|
void fetchSensorData();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sendTemperatureSensor, LL, fetchSensorData]
|
||||||
|
);
|
||||||
|
|
||||||
const updateAnalogSensor = (as: AnalogSensor) => {
|
const updateAnalogSensor = useCallback(
|
||||||
if (me.admin) {
|
(as: AnalogSensor) => {
|
||||||
setCreating(false);
|
if (me.admin) {
|
||||||
as.o_n = as.n;
|
setCreating(false);
|
||||||
setSelectedAnalogSensor(as);
|
as.o_n = as.n;
|
||||||
setAnalogDialogOpen(true);
|
setSelectedAnalogSensor(as);
|
||||||
}
|
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,167 +366,194 @@ const Sensors = () => {
|
|||||||
o_n: ''
|
o_n: ''
|
||||||
});
|
});
|
||||||
setAnalogDialogOpen(true);
|
setAnalogDialogOpen(true);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
const onAnalogDialogSave = useCallback(
|
||||||
await sendAnalogSensor({
|
async (as: AnalogSensor) => {
|
||||||
id: as.id,
|
await sendAnalogSensor({
|
||||||
gpio: as.g,
|
id: as.id,
|
||||||
name: as.n,
|
gpio: as.g,
|
||||||
offset: as.o,
|
name: as.n,
|
||||||
factor: as.f,
|
offset: as.o,
|
||||||
uom: as.u,
|
factor: as.f,
|
||||||
type: as.t,
|
uom: as.u,
|
||||||
deleted: as.d,
|
type: as.t,
|
||||||
is_system: as.s
|
deleted: as.d,
|
||||||
})
|
is_system: as.s
|
||||||
.then(() => {
|
|
||||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.catch(() => {
|
||||||
setAnalogDialogOpen(false);
|
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||||
setSelectedAnalogSensor(undefined);
|
})
|
||||||
void fetchSensorData();
|
.finally(() => {
|
||||||
});
|
setAnalogDialogOpen(false);
|
||||||
};
|
setSelectedAnalogSensor(undefined);
|
||||||
|
void fetchSensorData();
|
||||||
const RenderAnalogSensors = (
|
});
|
||||||
<Table
|
},
|
||||||
data={{ nodes: sensorData.as }}
|
[sendAnalogSensor, LL, fetchSensorData]
|
||||||
theme={analog_theme}
|
|
||||||
sort={analog_sort}
|
|
||||||
layout={{ custom: true }}
|
|
||||||
>
|
|
||||||
{(tableList: AnalogSensor[]) => (
|
|
||||||
<>
|
|
||||||
<Header>
|
|
||||||
<HeaderRow>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
|
||||||
>
|
|
||||||
GPIO
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell resize>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
|
||||||
>
|
|
||||||
{LL.NAME(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
|
||||||
>
|
|
||||||
{LL.TYPE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
<HeaderCell stiff>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
|
||||||
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })}
|
|
||||||
>
|
|
||||||
{LL.VALUE(0)}
|
|
||||||
</Button>
|
|
||||||
</HeaderCell>
|
|
||||||
</HeaderRow>
|
|
||||||
</Header>
|
|
||||||
<Body>
|
|
||||||
{tableList.map((as: AnalogSensor) => (
|
|
||||||
<Row
|
|
||||||
style={{ color: as.s ? 'grey' : 'inherit' }}
|
|
||||||
key={as.id}
|
|
||||||
item={as}
|
|
||||||
onClick={() => updateAnalogSensor(as)}
|
|
||||||
>
|
|
||||||
<Cell stiff>{as.g}</Cell>
|
|
||||||
<Cell>{as.n}</Cell>
|
|
||||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
|
||||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
|
||||||
as.g !== GPIO_25 &&
|
|
||||||
as.g !== GPIO_26) ||
|
|
||||||
as.t === AnalogType.DIGITAL_IN ||
|
|
||||||
as.t === AnalogType.PULSE ? (
|
|
||||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
|
||||||
) : (
|
|
||||||
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
|
||||||
)}
|
|
||||||
</Row>
|
|
||||||
))}
|
|
||||||
</Body>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const RenderTemperatureSensors = (
|
const RenderAnalogSensors = useMemo(
|
||||||
<Table
|
() => (
|
||||||
data={{ nodes: sensorData.ts }}
|
<Table
|
||||||
theme={temperature_theme}
|
data={{ nodes: sensorData.as }}
|
||||||
sort={temperature_sort}
|
theme={analog_theme}
|
||||||
layout={{ custom: true }}
|
sort={analog_sort}
|
||||||
>
|
layout={{ custom: true }}
|
||||||
{(tableList: TemperatureSensor[]) => (
|
>
|
||||||
<>
|
{(tableList: AnalogSensor[]) => (
|
||||||
<Header>
|
<>
|
||||||
<HeaderRow>
|
<Header>
|
||||||
<HeaderCell resize>
|
<HeaderRow>
|
||||||
<Button
|
<HeaderCell stiff>
|
||||||
fullWidth
|
<Button
|
||||||
style={HEADER_BUTTON_STYLE}
|
fullWidth
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
style={HEADER_BUTTON_STYLE}
|
||||||
onClick={() =>
|
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
||||||
}
|
>
|
||||||
|
GPIO
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell resize>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE}
|
||||||
|
endIcon={getSortIcon(analog_sort.state, 'NAME')}
|
||||||
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'NAME' })}
|
||||||
|
>
|
||||||
|
{LL.NAME(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell stiff>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE}
|
||||||
|
endIcon={getSortIcon(analog_sort.state, 'TYPE')}
|
||||||
|
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'TYPE' })}
|
||||||
|
>
|
||||||
|
{LL.TYPE(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell stiff>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
|
endIcon={getSortIcon(analog_sort.state, 'VALUE')}
|
||||||
|
onClick={() =>
|
||||||
|
analog_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.VALUE(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</Header>
|
||||||
|
<Body>
|
||||||
|
{tableList.map((as: AnalogSensor) => (
|
||||||
|
<Row
|
||||||
|
style={{ color: as.s ? 'grey' : 'inherit' }}
|
||||||
|
key={as.id}
|
||||||
|
item={as}
|
||||||
|
onClick={() => updateAnalogSensor(as)}
|
||||||
>
|
>
|
||||||
{LL.NAME(0)}
|
<Cell stiff>{as.g}</Cell>
|
||||||
</Button>
|
<Cell>{as.n}</Cell>
|
||||||
</HeaderCell>
|
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
||||||
<HeaderCell stiff>
|
{(as.t === AnalogType.DIGITAL_OUT &&
|
||||||
<Button
|
as.g !== GPIO_25 &&
|
||||||
fullWidth
|
as.g !== GPIO_26) ||
|
||||||
style={HEADER_BUTTON_STYLE_END}
|
as.t === AnalogType.DIGITAL_IN ||
|
||||||
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
as.t === AnalogType.PULSE ? (
|
||||||
onClick={() =>
|
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
||||||
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
) : (
|
||||||
}
|
<Cell stiff>{formatValue(as.v, as.u)}</Cell>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
analog_sort,
|
||||||
|
analog_theme,
|
||||||
|
getSortIcon,
|
||||||
|
sensorData.as,
|
||||||
|
LL,
|
||||||
|
updateAnalogSensor,
|
||||||
|
formatValue
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const RenderTemperatureSensors = useMemo(
|
||||||
|
() => (
|
||||||
|
<Table
|
||||||
|
data={{ nodes: sensorData.ts }}
|
||||||
|
theme={temperature_theme}
|
||||||
|
sort={temperature_sort}
|
||||||
|
layout={{ custom: true }}
|
||||||
|
>
|
||||||
|
{(tableList: TemperatureSensor[]) => (
|
||||||
|
<>
|
||||||
|
<Header>
|
||||||
|
<HeaderRow>
|
||||||
|
<HeaderCell resize>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE}
|
||||||
|
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||||
|
onClick={() =>
|
||||||
|
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.NAME(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
<HeaderCell stiff>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
|
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||||
|
onClick={() =>
|
||||||
|
temperature_sort.fns.onToggleSort({ sortKey: 'VALUE' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.VALUE(0)}
|
||||||
|
</Button>
|
||||||
|
</HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</Header>
|
||||||
|
<Body>
|
||||||
|
{tableList.map((ts: TemperatureSensor) => (
|
||||||
|
<Row
|
||||||
|
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||||
|
key={ts.id}
|
||||||
|
item={ts}
|
||||||
|
onClick={() => updateTemperatureSensor(ts)}
|
||||||
>
|
>
|
||||||
{LL.VALUE(0)}
|
<Cell>{ts.n}</Cell>
|
||||||
</Button>
|
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||||
</HeaderCell>
|
</Row>
|
||||||
</HeaderRow>
|
))}
|
||||||
</Header>
|
</Body>
|
||||||
<Body>
|
</>
|
||||||
{tableList.map((ts: TemperatureSensor) => (
|
)}
|
||||||
<Row
|
</Table>
|
||||||
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
),
|
||||||
key={ts.id}
|
[
|
||||||
item={ts}
|
temperature_sort,
|
||||||
onClick={() => updateTemperatureSensor(ts)}
|
temperature_theme,
|
||||||
>
|
getSortIcon,
|
||||||
<Cell>{ts.n}</Cell>
|
sensorData.ts,
|
||||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
LL,
|
||||||
</Row>
|
updateTemperatureSensor,
|
||||||
))}
|
formatValue
|
||||||
</Body>
|
]
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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,54 +53,84 @@ 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(
|
||||||
setEditItem(
|
() =>
|
||||||
(prev) =>
|
updateValue((updater) =>
|
||||||
updater(
|
setEditItem(
|
||||||
prev as unknown as Record<string, unknown>
|
(prev) =>
|
||||||
) as unknown as AnalogSensor
|
updater(
|
||||||
)
|
prev as unknown as Record<string, unknown>
|
||||||
|
) as unknown as AnalogSensor
|
||||||
|
)
|
||||||
|
),
|
||||||
|
[setEditItem]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isCounterOrRate =
|
// Memoize helper functions to check sensor type conditions
|
||||||
editItem.t === AnalogType.COUNTER ||
|
const isCounterOrRate = useMemo(
|
||||||
editItem.t === AnalogType.RATE ||
|
() =>
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
editItem.t === AnalogType.COUNTER ||
|
||||||
const isCounter =
|
editItem.t === AnalogType.RATE ||
|
||||||
editItem.t === AnalogType.COUNTER ||
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
[editItem.t]
|
||||||
const isFreqType =
|
);
|
||||||
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
const isCounter = useMemo(
|
||||||
const isPWM =
|
() =>
|
||||||
editItem.t === AnalogType.PWM_0 ||
|
editItem.t === AnalogType.COUNTER ||
|
||||||
editItem.t === AnalogType.PWM_1 ||
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
||||||
editItem.t === AnalogType.PWM_2;
|
[editItem.t]
|
||||||
const isDACOutGPIO =
|
);
|
||||||
editItem.t === AnalogType.DIGITAL_OUT &&
|
const isFreqType = useMemo(
|
||||||
(editItem.g === 25 || editItem.g === 26);
|
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
||||||
const isDigitalOutGPIO =
|
[editItem.t]
|
||||||
editItem.t === AnalogType.DIGITAL_OUT && editItem.g !== 25 && editItem.g !== 26;
|
);
|
||||||
|
const isPWM = useMemo(
|
||||||
|
() =>
|
||||||
|
editItem.t === AnalogType.PWM_0 ||
|
||||||
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
|
editItem.t === AnalogType.PWM_2,
|
||||||
|
[editItem.t]
|
||||||
|
);
|
||||||
|
const isDACOutGPIO = useMemo(
|
||||||
|
() =>
|
||||||
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
|
(editItem.g === 25 || editItem.g === 26),
|
||||||
|
[editItem.t, editItem.g]
|
||||||
|
);
|
||||||
|
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
|
||||||
key={name}
|
key={name}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabledTypeList?.includes(value)}
|
disabled={disabledTypeList?.includes(value)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
));
|
)),
|
||||||
|
[disabledTypeList]
|
||||||
|
);
|
||||||
|
|
||||||
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
const uomMenuItems = useMemo(
|
||||||
<MenuItem key={val} value={i}>
|
() =>
|
||||||
{val}
|
DeviceValueUOM_s.map((val, i) => (
|
||||||
</MenuItem>
|
<MenuItem key={val} value={i}>
|
||||||
));
|
{val}
|
||||||
|
</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') {
|
||||||
) => {
|
onClose();
|
||||||
if (reason !== 'backdropClick') {
|
}
|
||||||
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(
|
||||||
setEditItem as unknown as (
|
() =>
|
||||||
updater: (
|
updateValue(
|
||||||
prevState: Readonly<Record<string, unknown>>
|
setEditItem as unknown as (
|
||||||
) => Record<string, unknown>
|
updater: (
|
||||||
) => void
|
prevState: Readonly<Record<string, unknown>>
|
||||||
|
) => Record<string, unknown>
|
||||||
|
) => void
|
||||||
|
),
|
||||||
|
[setEditItem]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,13 +69,16 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
const handleClose = useCallback(
|
||||||
if (reason !== 'backdropClick') {
|
(_event: React.SyntheticEvent, reason?: string) => {
|
||||||
onClose();
|
if (reason !== 'backdropClick') {
|
||||||
}
|
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 {
|
||||||
@@ -210,18 +199,6 @@ export enum DeviceValueUOM {
|
|||||||
HERTZ
|
HERTZ
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceValueKind =
|
|
||||||
'value' | 'scheduler' | 'command' | 'deviceCommand' | 'deviceEntity';
|
|
||||||
|
|
||||||
export function getDeviceValueKind(item: DeviceValue): DeviceValueKind {
|
|
||||||
if (!item.c) return 'value';
|
|
||||||
if (item.v !== undefined && item.l !== undefined && item.u === undefined)
|
|
||||||
return 'scheduler';
|
|
||||||
if (item.v === undefined && item.l === undefined) return 'command';
|
|
||||||
if (item.u === DeviceValueUOM.NONE) return 'deviceCommand';
|
|
||||||
return 'deviceEntity';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeviceValueUOM_s = [
|
export const DeviceValueUOM_s = [
|
||||||
'',
|
'',
|
||||||
'°C',
|
'°C',
|
||||||
@@ -307,7 +284,6 @@ export const BOARD_PROFILES = {
|
|||||||
LOLIN: 'Lolin D32',
|
LOLIN: 'Lolin D32',
|
||||||
OLIMEX: 'Olimex ESP32-EVB',
|
OLIMEX: 'Olimex ESP32-EVB',
|
||||||
OLIMEXPOE: 'Olimex ESP32-POE',
|
OLIMEXPOE: 'Olimex ESP32-POE',
|
||||||
OLIMEXPOEW: 'Olimex ESP32-POE-ISO WROVER (PSRAM)',
|
|
||||||
C3MINI: 'Wemos C3 Mini',
|
C3MINI: 'Wemos C3 Mini',
|
||||||
S2MINI: 'Wemos S2 Mini',
|
S2MINI: 'Wemos S2 Mini',
|
||||||
S3MINI: 'Liligo S3'
|
S3MINI: 'Liligo S3'
|
||||||
@@ -363,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,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;
|
||||||
@@ -430,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 {
|
||||||
@@ -473,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(
|
||||||
origData as unknown as Record<string, unknown>,
|
() =>
|
||||||
dirtyFlags,
|
updateValueDirty(
|
||||||
setDirtyFlags,
|
origData as unknown as Record<string, unknown>,
|
||||||
updateDataValue as (value: unknown) => void
|
dirtyFlags,
|
||||||
|
setDirtyFlags,
|
||||||
|
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 { useContext, useEffect, 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,16 +28,12 @@ import {
|
|||||||
FormLoader,
|
FormLoader,
|
||||||
MessageBox,
|
MessageBox,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
ValidatedPasswordField,
|
|
||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
import { toast } from 'components/toast';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
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';
|
||||||
@@ -69,14 +67,6 @@ const ApplicationSettings = () => {
|
|||||||
update: writeSettings
|
update: writeSettings
|
||||||
});
|
});
|
||||||
|
|
||||||
// the menu drawer reads the system name from the authentication context
|
|
||||||
const { setSystemName } = useContext(AuthenticatedContext);
|
|
||||||
useEffect(() => {
|
|
||||||
if (origData?.system_name !== undefined) {
|
|
||||||
setSystemName(origData.system_name);
|
|
||||||
}
|
|
||||||
}, [origData?.system_name, setSystemName]);
|
|
||||||
|
|
||||||
const [restarting, setRestarting] = useState<boolean>();
|
const [restarting, setRestarting] = useState<boolean>();
|
||||||
|
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
@@ -116,79 +106,82 @@ const ApplicationSettings = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const SecondsInputProps = {
|
// Memoized input props to prevent recreation on every render
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
const SecondsInputProps = useMemo(
|
||||||
};
|
() => ({
|
||||||
|
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(
|
||||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
async (board_profile: string) => {
|
||||||
toast.error(error.message);
|
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||||
});
|
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(
|
||||||
const boardProfile = event.target.value;
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
updateFormValue(event);
|
const boardProfile = event.target.value;
|
||||||
if (boardProfile === 'CUSTOM') {
|
updateFormValue(event);
|
||||||
updateDataValue({
|
if (boardProfile === 'CUSTOM') {
|
||||||
...data,
|
updateDataValue({
|
||||||
board_profile: boardProfile
|
...data,
|
||||||
});
|
board_profile: boardProfile
|
||||||
} else {
|
});
|
||||||
void updateBoardProfile(boardProfile);
|
} else {
|
||||||
}
|
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) {
|
||||||
@@ -197,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>
|
||||||
@@ -370,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>
|
||||||
@@ -912,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(
|
||||||
origData as unknown as Record<string, unknown>,
|
() =>
|
||||||
dirtyFlags,
|
updateValueDirty(
|
||||||
setDirtyFlags,
|
origData as unknown as Record<string, unknown>,
|
||||||
updateDataValue as (value: unknown) => void
|
dirtyFlags,
|
||||||
|
setDirtyFlags,
|
||||||
|
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,22 +96,25 @@ 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_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||||
{
|
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||||
name: 'publish_time_thermostat',
|
{
|
||||||
label: LL.MQTT_INT_THERMOSTATS(),
|
name: 'publish_time_thermostat',
|
||||||
validated: false
|
label: LL.MQTT_INT_THERMOSTATS(),
|
||||||
},
|
validated: false
|
||||||
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
},
|
||||||
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), validated: false },
|
||||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
{ name: 'publish_time_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
||||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
{ name: 'publish_time_sensor', label: LL.SENSORS(), 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
|
||||||
origData as unknown as Record<string, unknown>,
|
const updateFormValue = useMemo(
|
||||||
dirtyFlags,
|
() =>
|
||||||
setDirtyFlags,
|
updateValueDirty(
|
||||||
updateDataValue as (value: unknown) => void
|
origData as unknown as Record<string, unknown>,
|
||||||
|
dirtyFlags,
|
||||||
|
setDirtyFlags,
|
||||||
|
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
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
const changeTimeZone = useCallback(
|
||||||
...settings,
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
tz_label: event.target.value,
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
tz_format: TIME_ZONES[event.target.value]
|
...settings,
|
||||||
}));
|
tz_label: event.target.value,
|
||||||
updateFormValue(event);
|
tz_format: TIME_ZONES[event.target.value]
|
||||||
};
|
}));
|
||||||
|
updateFormValue(event);
|
||||||
|
},
|
||||||
|
[updateFormValue]
|
||||||
|
);
|
||||||
|
|
||||||
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const changeTimeZoneT = useCallback(
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
...settings,
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
tz_label_t: event.target.value,
|
...settings,
|
||||||
tz_format_t: TIME_ZONES[event.target.value]
|
tz_label_t: 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,108 +1,190 @@
|
|||||||
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()})` : ''}`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
<SectionContent>
|
immediate: false
|
||||||
<List>
|
});
|
||||||
<ListMenuItem
|
|
||||||
icon={BuildIcon}
|
|
||||||
bgcolor="#72caf9"
|
|
||||||
label="EMS-ESP Firmware"
|
|
||||||
text={firmwareText}
|
|
||||||
to="/settings/version"
|
|
||||||
badge={upgradeAvailable}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
const doFormat = useCallback(async () => {
|
||||||
icon={TuneIcon}
|
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||||
bgcolor="#134ba2"
|
setRestarting(true);
|
||||||
label={LL.APPLICATION()}
|
setConfirmFactoryReset(false);
|
||||||
text={LL.APPLICATION_SETTINGS_1()}
|
});
|
||||||
to="application"
|
}, [sendAPI]);
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
const handleFactoryResetClose = useCallback(() => {
|
||||||
icon={SettingsEthernetIcon}
|
setConfirmFactoryReset(false);
|
||||||
bgcolor="#40828f"
|
}, []);
|
||||||
label={LL.NETWORK(0)}
|
|
||||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
|
||||||
to="network"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
const handleFactoryResetClick = useCallback(() => {
|
||||||
icon={SettingsInputAntennaIcon}
|
setConfirmFactoryReset(true);
|
||||||
bgcolor="#5f9a5f"
|
}, []);
|
||||||
label={LL.ACCESS_POINT(0)}
|
|
||||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
|
||||||
to="ap"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
const content = useMemo(() => {
|
||||||
icon={AccessTimeIcon}
|
return (
|
||||||
bgcolor="#c5572c"
|
<>
|
||||||
label="NTP"
|
<List>
|
||||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
<ListMenuItem
|
||||||
to="ntp"
|
icon={TuneIcon}
|
||||||
/>
|
bgcolor="#134ba2"
|
||||||
|
label={LL.APPLICATION()}
|
||||||
|
text={LL.APPLICATION_SETTINGS_1()}
|
||||||
|
to="application"
|
||||||
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={DeviceHubIcon}
|
icon={SettingsEthernetIcon}
|
||||||
bgcolor="#68374d"
|
bgcolor="#40828f"
|
||||||
label="MQTT"
|
label={LL.NETWORK(0)}
|
||||||
text={LL.CONFIGURE('MQTT')}
|
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||||
to="mqtt"
|
to="network"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={LockIcon}
|
icon={SettingsInputAntennaIcon}
|
||||||
label={LL.SECURITY(0)}
|
bgcolor="#5f9a5f"
|
||||||
text={LL.SECURITY_1()}
|
label={LL.ACCESS_POINT(0)}
|
||||||
to="security"
|
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||||
/>
|
to="ap"
|
||||||
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={ViewModuleIcon}
|
icon={AccessTimeIcon}
|
||||||
bgcolor="#efc34b"
|
bgcolor="#c5572c"
|
||||||
label={LL.MODULES()}
|
label="NTP"
|
||||||
text={LL.MODULES_1()}
|
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||||
to="modules"
|
to="ntp"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={ImportExportIcon}
|
icon={DeviceHubIcon}
|
||||||
bgcolor="#5d89f7"
|
bgcolor="#68374d"
|
||||||
label={LL.DOWNLOAD_UPLOAD()}
|
label="MQTT"
|
||||||
text={LL.DOWNLOAD_UPLOAD_1()}
|
text={LL.CONFIGURE('MQTT')}
|
||||||
to="downloadUpload"
|
to="mqtt"
|
||||||
/>
|
/>
|
||||||
</List>
|
|
||||||
</SectionContent>
|
<ListMenuItem
|
||||||
);
|
icon={LockIcon}
|
||||||
|
label={LL.SECURITY(0)}
|
||||||
|
text={LL.SECURITY_1()}
|
||||||
|
to="security"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={ViewModuleIcon}
|
||||||
|
bgcolor="#efc34b"
|
||||||
|
label={LL.MODULES()}
|
||||||
|
text={LL.MODULES_1()}
|
||||||
|
to="modules"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
icon={ImportExportIcon}
|
||||||
|
bgcolor="#5d89f7"
|
||||||
|
label={LL.DOWNLOAD_UPLOAD()}
|
||||||
|
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||||
|
to="downloadUpload"
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<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,946 +0,0 @@
|
|||||||
import { memo, useContext, useEffect, 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, refreshVersions } = 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: VersionInfo | undefined = versions?.stable
|
|
||||||
? { version: versions.stable.version, date: versions.stable.date }
|
|
||||||
: undefined;
|
|
||||||
const latestDevVersion: VersionInfo | undefined = versions?.dev
|
|
||||||
? { version: versions.dev.version, date: versions.dev.date }
|
|
||||||
: undefined;
|
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (internetLive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
void refreshVersions();
|
|
||||||
}, 3000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [internetLive, refreshVersions]);
|
|
||||||
|
|
||||||
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">
|
|
||||||
{LL.INTERNET_CONNECTION_CHECK()}…
|
|
||||||
</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="success"
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
gap: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!data.disable_reset && (
|
|
||||||
<Button
|
|
||||||
startIcon={<SettingsBackupRestoreIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleFactoryResetClick}
|
|
||||||
color="error"
|
|
||||||
>
|
|
||||||
{LL.FACTORY_RESET()}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
startIcon={<PowerSettingsNewIcon />}
|
|
||||||
variant="outlined"
|
|
||||||
onClick={handleRestartClick}
|
|
||||||
color="success"
|
|
||||||
>
|
|
||||||
{LL.RESTART()}
|
|
||||||
</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(
|
||||||
setSelectedNetwork(network);
|
(network: WiFiNetwork) => {
|
||||||
void navigate('/settings/network/settings');
|
setSelectedNetwork(network);
|
||||||
};
|
void navigate('/settings/network/settings');
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
const deselectNetwork = () => {
|
const deselectNetwork = useCallback(() => {
|
||||||
setSelectedNetwork(undefined);
|
setSelectedNetwork(undefined);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = useMemo(
|
||||||
...(selectedNetwork && { selectedNetwork }),
|
() => ({
|
||||||
selectNetwork,
|
...(selectedNetwork && { selectedNetwork }),
|
||||||
deselectNetwork
|
selectNetwork,
|
||||||
};
|
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';
|
||||||
@@ -22,10 +23,10 @@ import {
|
|||||||
|
|
||||||
import * as NetworkApi from 'api/network';
|
import * as NetworkApi from 'api/network';
|
||||||
import { API } from 'api/app';
|
import { API } from 'api/app';
|
||||||
import { readSystemStatus } from 'api/system';
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -36,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';
|
||||||
@@ -51,8 +50,6 @@ import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
|
|||||||
const NetworkSettings = () => {
|
const NetworkSettings = () => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const { data: hardwareData } = useRequest(readSystemStatus);
|
|
||||||
|
|
||||||
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
|
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
|
||||||
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
@@ -92,11 +89,10 @@ 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: '*'
|
||||||
eth_10mbit: current_data ? current_data.eth_10mbit : false
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -125,22 +121,22 @@ 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 || !hardwareData) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,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}
|
||||||
@@ -250,23 +246,6 @@ const NetworkSettings = () => {
|
|||||||
}
|
}
|
||||||
label={LL.NETWORK_LOW_BAND()}
|
label={LL.NETWORK_LOW_BAND()}
|
||||||
/>
|
/>
|
||||||
{hardwareData.esp_platform === 'ESP32' && (
|
|
||||||
<>
|
|
||||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
|
||||||
Ethernet
|
|
||||||
</Typography>
|
|
||||||
<BlockFormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
name="eth_10mbit"
|
|
||||||
checked={data.eth_10mbit}
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Force 10Mbit half-duplex"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
|
||||||
{LL.GENERAL_OPTIONS()}
|
{LL.GENERAL_OPTIONS()}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -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,31 +63,34 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
|
|
||||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||||
|
|
||||||
const renderNetwork = (network: WiFiNetwork) => (
|
const renderNetwork = useCallback(
|
||||||
<ListItem
|
(network: WiFiNetwork) => (
|
||||||
key={network.bssid}
|
<ListItem
|
||||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
key={network.bssid}
|
||||||
>
|
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||||
<ListItemAvatar>
|
>
|
||||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
<ListItemAvatar>
|
||||||
</ListItemAvatar>
|
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||||
<ListItemText
|
</ListItemAvatar>
|
||||||
primary={network.ssid}
|
<ListItemText
|
||||||
secondary={
|
primary={network.ssid}
|
||||||
'Security: ' +
|
secondary={
|
||||||
networkSecurityMode(network) +
|
'Security: ' +
|
||||||
', Ch: ' +
|
networkSecurityMode(network) +
|
||||||
network.channel +
|
', Ch: ' +
|
||||||
', bssid: ' +
|
network.channel +
|
||||||
network.bssid
|
', bssid: ' +
|
||||||
}
|
network.bssid
|
||||||
/>
|
}
|
||||||
<ListItemIcon>
|
/>
|
||||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
<ListItemIcon>
|
||||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||||
</Badge>
|
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||||
</ListItemIcon>
|
</Badge>
|
||||||
</ListItem>
|
</ListItemIcon>
|
||||||
|
</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,14 +55,16 @@ 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(
|
||||||
Table: `
|
() =>
|
||||||
|
useTheme({
|
||||||
|
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;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -72,7 +74,7 @@ const ManageUsers = () => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
.td {
|
.td {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #565656;
|
border-top: 1px solid #565656;
|
||||||
@@ -85,7 +87,7 @@ const ManageUsers = () => {
|
|||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
if (!data) return;
|
(toRemove: UserType) => {
|
||||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
if (!data) return;
|
||||||
updateDataValue({ ...data, users });
|
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||||
setChanged(changed + 1);
|
updateDataValue({ ...data, users });
|
||||||
};
|
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
|
||||||
...u,
|
const user_table = useMemo(
|
||||||
id: u.username
|
() =>
|
||||||
})) as UserType2[];
|
data.users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
id: u.username
|
||||||
|
})) 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,14 +36,16 @@ const SystemActivity = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||||
|
|
||||||
const stats_theme = tableTheme({
|
const stats_theme = tableTheme(
|
||||||
Table: `
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
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;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
color: #90CAF9;
|
color: #90CAF9;
|
||||||
@@ -51,7 +55,7 @@ const SystemActivity = () => {
|
|||||||
border-bottom: 1px solid #565656;
|
border-bottom: 1px solid #565656;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
.td {
|
.td {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-top: 1px solid #565656;
|
border-top: 1px solid #565656;
|
||||||
@@ -65,20 +69,26 @@ const SystemActivity = () => {
|
|||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:not(:first-of-type) {
|
&:not(:first-of-type) {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
});
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const showName = (id: number) => {
|
const showName = useCallback(
|
||||||
const name: keyof Translation['STATUS_NAMES'] =
|
(id: number) => {
|
||||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
const name: keyof Translation['STATUS_NAMES'] =
|
||||||
return LL.STATUS_NAMES[name]();
|
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||||
};
|
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) {
|
||||||
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data) {
|
const content = useMemo(() => {
|
||||||
return (
|
if (!data) {
|
||||||
<SectionContent>
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
<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,20 +124,16 @@ const NetworkStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
if (!data) {
|
const content = useMemo(() => {
|
||||||
|
if (!data) {
|
||||||
|
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
||||||
|
const statusColor = networkStatusHighlight(data, theme);
|
||||||
|
const qualityColor = networkQualityHighlight(data, theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
|
||||||
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
|
|
||||||
const statusColor = networkStatusHighlight(data, theme);
|
|
||||||
const qualityColor = networkQualityHighlight(data, theme);
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
+276
-111
@@ -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,103 +223,227 @@ 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SectionContent>
|
|
||||||
<List>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
|
||||||
<MonitorHeartIcon />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
|
||||||
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={MemoryIcon}
|
|
||||||
bgcolor="#68374d"
|
|
||||||
label={LL.HARDWARE()}
|
|
||||||
text={`${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}`}
|
|
||||||
to="/status/hardwarestatus"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={DirectionsBusIcon}
|
|
||||||
bgcolor={busStatusHighlight}
|
|
||||||
label={LL.DATA_TRAFFIC()}
|
|
||||||
text={busStatus}
|
|
||||||
to="/status/activity"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={
|
|
||||||
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
|
|
||||||
? WifiIcon
|
|
||||||
: RouterIcon
|
|
||||||
}
|
|
||||||
bgcolor={networkStatusHighlight}
|
|
||||||
label={LL.NETWORK(1)}
|
|
||||||
text={networkStatus}
|
|
||||||
to="/status/network"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={DeviceHubIcon}
|
|
||||||
bgcolor={activeHighlight(data.mqtt_status)}
|
|
||||||
label="MQTT"
|
|
||||||
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
|
|
||||||
to="/status/mqtt"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={AccessTimeIcon}
|
|
||||||
bgcolor={ntpStatusHighlight}
|
|
||||||
label="NTP"
|
|
||||||
text={ntpStatus}
|
|
||||||
to="/status/ntp"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={SettingsInputAntennaIcon}
|
|
||||||
bgcolor={activeHighlight(data.ap_status)}
|
|
||||||
label={LL.ACCESS_POINT(0)}
|
|
||||||
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
|
|
||||||
to="/status/ap"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
|
||||||
disabled={!me.admin}
|
|
||||||
icon={LogoDevIcon}
|
|
||||||
bgcolor="#40828f"
|
|
||||||
label={LL.LOG_OF(LL.SYSTEM(0))}
|
|
||||||
text={LL.VIEW_LOG()}
|
|
||||||
to="/status/log"
|
|
||||||
/>
|
|
||||||
</List>
|
|
||||||
</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 (
|
||||||
|
<>
|
||||||
|
<List>
|
||||||
|
<ListMenuItem
|
||||||
|
icon={BuildIcon}
|
||||||
|
bgcolor="#72caf9"
|
||||||
|
label="EMS-ESP Firmware"
|
||||||
|
text={firmwareVersion}
|
||||||
|
to="version"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
|
||||||
|
<MonitorHeartIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primary={LL.STATUS_OF(LL.SYSTEM(0))}
|
||||||
|
secondary={`${systemStatus} (${LL.UPTIME()}: ${uptimeText})`}
|
||||||
|
/>
|
||||||
|
{me.admin && (
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleRestartClick}
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ListItem>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={MemoryIcon}
|
||||||
|
bgcolor="#68374d"
|
||||||
|
label={LL.HARDWARE()}
|
||||||
|
text={freeMemoryText}
|
||||||
|
to="/status/hardwarestatus"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={DirectionsBusIcon}
|
||||||
|
bgcolor={busStatusHighlight}
|
||||||
|
label={LL.DATA_TRAFFIC()}
|
||||||
|
text={busStatus}
|
||||||
|
to="/status/activity"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={networkIcon}
|
||||||
|
bgcolor={networkStatusHighlight}
|
||||||
|
label={LL.NETWORK(1)}
|
||||||
|
text={networkStatus}
|
||||||
|
to="/status/network"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={DeviceHubIcon}
|
||||||
|
bgcolor={activeHighlight(data.mqtt_status)}
|
||||||
|
label="MQTT"
|
||||||
|
text={mqttStatusText}
|
||||||
|
to="/status/mqtt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={AccessTimeIcon}
|
||||||
|
bgcolor={ntpStatusHighlight}
|
||||||
|
label="NTP"
|
||||||
|
text={ntpStatus}
|
||||||
|
to="/status/ntp"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={SettingsInputAntennaIcon}
|
||||||
|
bgcolor={activeHighlight(data.ap_status)}
|
||||||
|
label={LL.ACCESS_POINT(0)}
|
||||||
|
text={apStatusText}
|
||||||
|
to="/status/ap"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListMenuItem
|
||||||
|
disabled={!me.admin}
|
||||||
|
icon={LogoDevIcon}
|
||||||
|
bgcolor="#40828f"
|
||||||
|
label={LL.LOG_OF(LL.SYSTEM(0))}
|
||||||
|
text={LL.VIEW_LOG()}
|
||||||
|
to="/status/log"
|
||||||
|
/>
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{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,31 +57,39 @@ const SystemMonitor = () => {
|
|||||||
void send();
|
void send();
|
||||||
}, 1000); // check every 1 second
|
}, 1000); // check every 1 second
|
||||||
|
|
||||||
const status = data?.status;
|
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
||||||
|
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
|
||||||
? LL.APPLICATION_RESTARTING()
|
? LL.APPLICATION_RESTARTING()
|
||||||
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
: status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
|
||||||
? LL.RESTARTING_PRE()
|
? LL.RESTARTING_PRE()
|
||||||
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
: status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
|
||||||
? '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,17 +38,18 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const Icon = LEVEL_ICONS[level];
|
const { Icon, backgroundColor } = useMemo(() => {
|
||||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
const Icon = LEVEL_ICONS[level];
|
||||||
const [paletteKeyName, shade] = palettePath.split('.') as [
|
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||||
keyof typeof theme.palette,
|
const [key, shade] = palettePath.split('.') as [
|
||||||
string
|
keyof typeof theme.palette,
|
||||||
];
|
string
|
||||||
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
];
|
||||||
string,
|
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
||||||
string
|
const backgroundColor = paletteKey[shade];
|
||||||
>;
|
|
||||||
const backgroundColor = paletteKey[shade];
|
return { Icon, backgroundColor };
|
||||||
|
}, [level, theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -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,8 +1,7 @@
|
|||||||
import { memo, useContext } 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 { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
import { PROJECT_NAME } from 'env';
|
import { PROJECT_NAME } from 'env';
|
||||||
|
|
||||||
import { DRAWER_WIDTH } from './Layout';
|
import { DRAWER_WIDTH } from './Layout';
|
||||||
@@ -25,33 +24,22 @@ interface LayoutDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||||
const { systemName } = useContext(AuthenticatedContext);
|
// Memoize drawer content to prevent unnecessary re-renders
|
||||||
|
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>
|
|
||||||
{systemName && (
|
|
||||||
<Typography color="secondary" variant="body2">
|
|
||||||
{systemName}
|
|
||||||
</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,52 +11,60 @@ 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
|
||||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
const buttonStyles: SxProps<Theme> = useMemo(
|
||||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
() => ({
|
||||||
borderRadius: '8px',
|
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
margin: '2px 8px',
|
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||||
'&:hover': {
|
borderRadius: '8px',
|
||||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
margin: '2px 8px',
|
||||||
},
|
'&:hover': {
|
||||||
'&::before': {
|
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||||
content: '""',
|
},
|
||||||
position: 'absolute',
|
'&::before': {
|
||||||
left: 0,
|
content: '""',
|
||||||
top: 0,
|
position: 'absolute',
|
||||||
bottom: 0,
|
left: 0,
|
||||||
width: selected ? '3px' : '0px',
|
top: 0,
|
||||||
backgroundColor: '#90caf9',
|
bottom: 0,
|
||||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
width: selected ? '3px' : '0px',
|
||||||
}
|
backgroundColor: '#90caf9',
|
||||||
};
|
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',
|
() => ({
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
color: selected ? '#90caf9' : '#9e9e9e',
|
||||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
transitionProperty: 'color, transform'
|
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||||
};
|
transitionProperty: 'color, transform'
|
||||||
|
}),
|
||||||
|
[selected]
|
||||||
|
);
|
||||||
|
|
||||||
const textStyles: SxProps<Theme> = {
|
const textStyles: SxProps<Theme> = useMemo(
|
||||||
color: selected ? '#90caf9' : '#f5f5f5',
|
() => ({
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
color: selected ? '#90caf9' : '#f5f5f5',
|
||||||
transitionProperty: 'color, font-weight'
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
};
|
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(
|
||||||
void navigate(path);
|
(_event: unknown, path: string) => {
|
||||||
};
|
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,9 +91,7 @@ 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) => {
|
||||||
|
|||||||
@@ -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,58 +1,36 @@
|
|||||||
import { useCallback, useEffect, 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 [systemName, setSystemName] = useState<string>();
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
const response = event.data as VersionsResponse;
|
|
||||||
setVersions(response);
|
|
||||||
setSystemName(response.system_name);
|
|
||||||
})
|
|
||||||
.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');
|
||||||
@@ -62,10 +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);
|
|
||||||
setSystemName(undefined);
|
|
||||||
if (doRedirect) {
|
if (doRedirect) {
|
||||||
void navigate('/', { replace: true });
|
redirect('/');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,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(() => {
|
||||||
@@ -92,20 +67,20 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
|
// cache object to prevent re-renders
|
||||||
|
const obj = useMemo(
|
||||||
|
() => ({
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
refresh,
|
||||||
|
...(me && { me })
|
||||||
|
}),
|
||||||
|
[signIn, signOut, me, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
return (
|
return (
|
||||||
<AuthenticationContext.Provider
|
<AuthenticationContext.Provider value={obj}>
|
||||||
value={{
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
refresh,
|
|
||||||
refreshVersions,
|
|
||||||
setSystemName,
|
|
||||||
...(me && { me }),
|
|
||||||
...(versions && { versions }),
|
|
||||||
...(systemName !== undefined && { systemName })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</AuthenticationContext.Provider>
|
</AuthenticationContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +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>;
|
|
||||||
systemName?: string;
|
|
||||||
setSystemName: (systemName: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 207 B |
@@ -3,6 +3,7 @@ import type { Translation } from '../i18n-types';
|
|||||||
const cs: Translation = {
|
const cs: Translation = {
|
||||||
LANGUAGE: 'Jazyk',
|
LANGUAGE: 'Jazyk',
|
||||||
RETRY: 'Zkusit znovu',
|
RETRY: 'Zkusit znovu',
|
||||||
|
LOADING: 'Načítání',
|
||||||
IS_REQUIRED: '{0} je vyžadováno',
|
IS_REQUIRED: '{0} je vyžadováno',
|
||||||
SIGN_IN: 'Přihlásit se',
|
SIGN_IN: 'Přihlásit se',
|
||||||
SIGN_OUT: 'Odhlásit se',
|
SIGN_OUT: 'Odhlásit se',
|
||||||
@@ -30,17 +31,19 @@ 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í',
|
||||||
SENSORS: 'Senzory',
|
SENSORS: 'Senzory',
|
||||||
|
RUN_COMMAND: 'Zavolat příkaz',
|
||||||
CHANGE_VALUE: 'Změnit hodnotu',
|
CHANGE_VALUE: 'Změnit hodnotu',
|
||||||
CANCEL: 'Zrušit',
|
CANCEL: 'Zrušit',
|
||||||
REMOVE_ALL: 'Odebrat vše',
|
REMOVE_ALL: 'Odebrat vše',
|
||||||
APPLY_CHANGES: 'Použít změny ({0})',
|
APPLY_CHANGES: 'Použít změny ({0})',
|
||||||
UPDATE: 'Aktualizovat',
|
UPDATE: 'Aktualizovat',
|
||||||
|
EXECUTE: 'Provést',
|
||||||
REMOVE: 'Odebrat',
|
REMOVE: 'Odebrat',
|
||||||
PROBLEM_UPDATING: 'Problém s aktualizací',
|
PROBLEM_UPDATING: 'Problém s aktualizací',
|
||||||
PROBLEM_LOADING: 'Problém s načítáním',
|
PROBLEM_LOADING: 'Problém s načítáním',
|
||||||
@@ -49,6 +52,8 @@ const cs: Translation = {
|
|||||||
SETTINGS: 'Nastavení',
|
SETTINGS: 'Nastavení',
|
||||||
UPDATED_OF: '{0} Aktualizováno',
|
UPDATED_OF: '{0} Aktualizováno',
|
||||||
UPDATE_OF: '{0} Aktualizace',
|
UPDATE_OF: '{0} Aktualizace',
|
||||||
|
REMOVED_OF: '{0} Odebráno',
|
||||||
|
DELETION_OF: '{0} Smazání',
|
||||||
OFFSET: 'Offset',
|
OFFSET: 'Offset',
|
||||||
FACTOR: 'Faktor',
|
FACTOR: 'Faktor',
|
||||||
FREQ: 'Frekvence',
|
FREQ: 'Frekvence',
|
||||||
@@ -61,14 +66,17 @@ const cs: Translation = {
|
|||||||
TEMP_SENSORS: 'Teplotní senzory',
|
TEMP_SENSORS: 'Teplotní senzory',
|
||||||
WRITE_CMD_SENT: 'Zapisovací příkaz odeslán',
|
WRITE_CMD_SENT: 'Zapisovací příkaz odeslán',
|
||||||
EMS_BUS_WARNING: 'EMS sběrnice je odpojena. Pokud toto varování přetrvává i po několika sekundách, zkontrolujte nastavení a profil desky',
|
EMS_BUS_WARNING: 'EMS sběrnice je odpojena. Pokud toto varování přetrvává i po několika sekundách, zkontrolujte nastavení a profil desky',
|
||||||
|
EMS_BUS_SCANNING: 'Prohledávání zařízení na EMS sběrnici...',
|
||||||
CONNECTED: 'Připojeno',
|
CONNECTED: 'Připojeno',
|
||||||
TX_ISSUES: 'Problémy s Tx - zkontrolujte režim Tx',
|
TX_ISSUES: 'Problémy s Tx - zkontrolujte režim Tx',
|
||||||
DISCONNECTED: 'Odpojeno',
|
DISCONNECTED: 'Odpojeno',
|
||||||
|
EMS_SCAN: 'Opravdu chcete zahájit úplné skenování zařízení na EMS sběrnici?',
|
||||||
DATA_TRAFFIC: 'Datový provoz',
|
DATA_TRAFFIC: 'Datový provoz',
|
||||||
EMS_DEVICE: 'EMS zařízení',
|
EMS_DEVICE: 'EMS zařízení',
|
||||||
SUCCESS: 'ÚSPĚCH',
|
SUCCESS: 'ÚSPĚCH',
|
||||||
FAIL: 'SELHÁNÍ',
|
FAIL: 'SELHÁNÍ',
|
||||||
QUALITY: 'KVALITA',
|
QUALITY: 'KVALITA',
|
||||||
|
SCAN: 'Skenovat',
|
||||||
STATUS_NAMES: [
|
STATUS_NAMES: [
|
||||||
'Přijaté EMS telegramy (Rx)',
|
'Přijaté EMS telegramy (Rx)',
|
||||||
'Čtení EMS (Tx)',
|
'Čtení EMS (Tx)',
|
||||||
@@ -94,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',
|
||||||
@@ -119,6 +127,7 @@ const cs: Translation = {
|
|||||||
ENUM_FORMAT: 'Formát enum v API/MQTT',
|
ENUM_FORMAT: 'Formát enum v API/MQTT',
|
||||||
INDEX: 'Index',
|
INDEX: 'Index',
|
||||||
ENABLE_PARASITE: 'Povolit 1-Wire parazitní napájení',
|
ENABLE_PARASITE: 'Povolit 1-Wire parazitní napájení',
|
||||||
|
LOGGING: 'Protokolování',
|
||||||
LOG_HEX: 'Protokolovat EMS telegramy v hexadecimálním formátu',
|
LOG_HEX: 'Protokolovat EMS telegramy v hexadecimálním formátu',
|
||||||
ENABLE_SYSLOG: 'Povolit Syslog',
|
ENABLE_SYSLOG: 'Povolit Syslog',
|
||||||
LOG_LEVEL: 'Úroveň protokolování',
|
LOG_LEVEL: 'Úroveň protokolování',
|
||||||
@@ -163,6 +172,7 @@ const cs: Translation = {
|
|||||||
CLOSE: 'Zavřít',
|
CLOSE: 'Zavřít',
|
||||||
USE: 'Použít',
|
USE: 'Použít',
|
||||||
FACTORY_RESET: 'Obnovení továrního nastavení',
|
FACTORY_RESET: 'Obnovení továrního nastavení',
|
||||||
|
SYSTEM_FACTORY_TEXT: 'Zařízení bylo obnoveno do továrního nastavení a nyní se restartuje',
|
||||||
SYSTEM_FACTORY_TEXT_DIALOG: 'Opravdu chcete resetovat zařízení EMS-ESP do továrního nastavení?',
|
SYSTEM_FACTORY_TEXT_DIALOG: 'Opravdu chcete resetovat zařízení EMS-ESP do továrního nastavení?',
|
||||||
AVAILABLE_VERSION: 'Nejnovější dostupné verze',
|
AVAILABLE_VERSION: 'Nejnovější dostupné verze',
|
||||||
STABLE: 'Stabilní',
|
STABLE: 'Stabilní',
|
||||||
@@ -237,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',
|
||||||
@@ -247,8 +258,11 @@ const cs: Translation = {
|
|||||||
NETWORK_SCAN: 'Skenovat WiFi sítě',
|
NETWORK_SCAN: 'Skenovat WiFi sítě',
|
||||||
IDLE: 'Nečinný',
|
IDLE: 'Nečinný',
|
||||||
LOST: 'Ztraceno',
|
LOST: 'Ztraceno',
|
||||||
|
SCANNING: 'Skenování',
|
||||||
SCAN_AGAIN: 'Skenovat znovu',
|
SCAN_AGAIN: 'Skenovat znovu',
|
||||||
|
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',
|
||||||
@@ -274,14 +288,18 @@ 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_2: 'každou minutu',
|
||||||
|
SCHEDULE_TIMER_3: 'každou hodinu',
|
||||||
CUSTOM_ENTITIES: 'Vlastní entity',
|
CUSTOM_ENTITIES: 'Vlastní entity',
|
||||||
ENTITIES_HELP_1: 'Definujte vlastní EMS entity nebo dynamické uživatelské proměnné',
|
ENTITIES_HELP_1: 'Definujte vlastní EMS entity nebo dynamické uživatelské proměnné',
|
||||||
ENTITIES_UPDATED: 'Entity aktualizovány',
|
ENTITIES_UPDATED: 'Entity aktualizovány',
|
||||||
@@ -297,7 +315,9 @@ const cs: Translation = {
|
|||||||
ACTIVELOW: 'Aktivní nízká',
|
ACTIVELOW: 'Aktivní nízká',
|
||||||
UNCHANGED: 'Beze změny',
|
UNCHANGED: 'Beze změny',
|
||||||
ALWAYS: 'Vždy',
|
ALWAYS: 'Vždy',
|
||||||
|
ACTIVITY: 'Aktivita',
|
||||||
CONFIGURE: '{0} Konfigurovat',
|
CONFIGURE: '{0} Konfigurovat',
|
||||||
|
SYSTEM_MEMORY: 'Paměť systému',
|
||||||
APPLICATION_SETTINGS_1: 'Upravit nastavení aplikace EMS-ESP',
|
APPLICATION_SETTINGS_1: 'Upravit nastavení aplikace EMS-ESP',
|
||||||
SECURITY_1: 'Přidat nebo odebrat uživatele',
|
SECURITY_1: 'Přidat nebo odebrat uživatele',
|
||||||
DOWNLOAD_UPLOAD_1: 'Stáhnout a nahrát nastavení a firmware',
|
DOWNLOAD_UPLOAD_1: 'Stáhnout a nahrát nastavení a firmware',
|
||||||
@@ -309,6 +329,7 @@ const cs: Translation = {
|
|||||||
RENAME: 'Přejmenovat',
|
RENAME: 'Přejmenovat',
|
||||||
ENABLE_MODBUS: 'Povolit Modbus',
|
ENABLE_MODBUS: 'Povolit Modbus',
|
||||||
VIEW_LOG: 'Zobrazit záznam pro diagnostiku problémů',
|
VIEW_LOG: 'Zobrazit záznam pro diagnostiku problémů',
|
||||||
|
UPLOAD_DRAG: 'přetáhněte soubor sem nebo klikněte pro výběr',
|
||||||
SERVICES: 'Služby',
|
SERVICES: 'Služby',
|
||||||
ALLVALUES: 'Všechny hodnoty',
|
ALLVALUES: 'Všechny hodnoty',
|
||||||
SPECIAL_FUNCTIONS: 'Speciální funkce',
|
SPECIAL_FUNCTIONS: 'Speciální funkce',
|
||||||
@@ -322,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',
|
||||||
@@ -334,22 +354,18 @@ const cs: Translation = {
|
|||||||
THIS_VERSION: 'Tato verze',
|
THIS_VERSION: 'Tato verze',
|
||||||
PLATFORM: 'Platforma',
|
PLATFORM: 'Platforma',
|
||||||
RELEASE_TYPE: 'Typ sestavení',
|
RELEASE_TYPE: 'Typ sestavení',
|
||||||
|
INTERNET_CONNECTION_REQUIRED: 'Pro automatickou kontrolu a instalaci aktualizací je třeba internetové připojení',
|
||||||
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
|
SWITCH_RELEASE_TYPE: 'Přepnout na {0} verzi',
|
||||||
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
|
FIRMWARE_VERSION_INFO: 'Informace o verzi firmwaru',
|
||||||
|
NO_DATA: 'žádné údaje',
|
||||||
USER_PROFILE: 'Uživatelský profil',
|
USER_PROFILE: 'Uživatelský profil',
|
||||||
STORED_VERSIONS: 'Uložené verze',
|
STORED_VERSIONS: 'Uložené verze',
|
||||||
ONLINE_HELP: 'online nápověda',
|
ONLINE_HELP: 'online nápověda',
|
||||||
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_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
|
|
||||||
COMMAND_EXECUTED: 'Příkaz úspěšně spuštěn',
|
|
||||||
RUN: 'Spustit příkaz',
|
|
||||||
INTERNET_CONNECTION_CHECK: 'Kontrola online pro dostupné verze'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default cs;
|
export default cs;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user