mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-09-14 14:14:09 +00:00
Compare commits
526
Commits
main
...
7212439f1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7212439f1b | ||
|
|
6f7f343abd | ||
|
|
42e6e31476 | ||
|
|
96cf019f0a | ||
|
|
59b0417297 | ||
|
|
2369277a9e | ||
|
|
e166db8b70 | ||
|
|
36884867f7 | ||
|
|
7fb8eb3592 | ||
|
|
2e988df817 | ||
|
|
483e124484 | ||
|
|
34db81ab4b | ||
|
|
e9a422bd27 | ||
|
|
4a8ad71b63 | ||
|
|
6487a3be70 | ||
|
|
2eb804de0b | ||
|
|
6801f7b8cf | ||
|
|
c9b90e282f | ||
|
|
11eeb521d5 | ||
|
|
67c8b8429b | ||
|
|
c624e82ada | ||
|
|
722e4665af | ||
|
|
ba7c9cdc1b | ||
|
|
8c647331f2 | ||
|
|
9a98f6b2ff | ||
|
|
1631b607c5 | ||
|
|
4646572c80 | ||
|
|
9b5f9e7a28 | ||
|
|
4575f62e36 | ||
|
|
aa8481c42e | ||
|
|
454b45ee65 | ||
|
|
500f9ef5c5 | ||
|
|
96f2e6d5a7 | ||
|
|
a469507fb6 | ||
|
|
2bef33f81a | ||
|
|
e584adaa07 | ||
|
|
faa327cd08 | ||
|
|
ec4572c9fc | ||
|
|
1398aac828 | ||
|
|
a25f73f0ec | ||
|
|
3d402b0c94 | ||
|
|
c91488d2fe | ||
|
|
ac36b072e8 | ||
|
|
77fac47584 | ||
|
|
c349c2ad0b | ||
|
|
2c268bfc06 | ||
|
|
0a66991902 | ||
|
|
d6fcf16257 | ||
|
|
3ab402c56d | ||
|
|
92f36c6849 | ||
|
|
0ae46e9382 | ||
|
|
7989ab5b3f | ||
|
|
b7aa153f84 | ||
|
|
c760218024 | ||
|
|
3ee5b1ea1d | ||
|
|
9d5edd8e08 | ||
|
|
5397d5d33a | ||
|
|
a80ec9cf7e | ||
|
|
bc960980eb | ||
|
|
5199375088 | ||
|
|
0ff0ff7d6c | ||
|
|
7721993937 | ||
|
|
ddd0c39e4a | ||
|
|
6512eac9af | ||
|
|
2bb88a6d3a | ||
|
|
e9b300f20b | ||
|
|
c4c046f15f | ||
|
|
2350a1e1a4 | ||
|
|
99346c1db4 | ||
|
|
5ac1b486a7 | ||
|
|
1cd3cb854e | ||
|
|
eef1367ed2 | ||
|
|
bcad8f0819 | ||
|
|
df1065eb8e | ||
|
|
f36c02b9ed | ||
|
|
2e3ab7be8e | ||
|
|
2f63172cbf | ||
|
|
03326c1b47 | ||
|
|
036722f825 | ||
|
|
8bb464f2a9 | ||
|
|
534989e4bb | ||
|
|
eca8641c64 | ||
|
|
0a5d0fbf88 | ||
|
|
aa76e94b43 | ||
|
|
fd284ce57d | ||
|
|
e5e3c0bd40 | ||
|
|
7c4ff6e3e3 | ||
|
|
04e787fd80 | ||
|
|
1040450cb2 | ||
|
|
06f5ee5c4c | ||
|
|
58bf0b39c2 | ||
|
|
5a9ac0fd25 | ||
|
|
7f308f4036 | ||
|
|
61d3ad023a | ||
|
|
1cfae73137 | ||
|
|
2d4583ff01 | ||
|
|
b46dfd7f54 | ||
|
|
388b741d94 | ||
|
|
1a603e70ec | ||
|
|
2a6b436b2e | ||
|
|
d21f5f9ab6 | ||
|
|
745d87c6d2 | ||
|
|
17f76e594d | ||
|
|
722a41fb43 | ||
|
|
f861924435 | ||
|
|
ec8c94f733 | ||
|
|
8fe8d87924 | ||
|
|
74e260b4be | ||
|
|
3e501ade4f | ||
|
|
679b152dd0 | ||
|
|
00201b1202 | ||
|
|
f2eb1b2e7f | ||
|
|
39b02591d9 | ||
|
|
92d63d6f5d | ||
|
|
392812d489 | ||
|
|
88a777ca5f | ||
|
|
87a77586db | ||
|
|
ca26f93ae7 | ||
|
|
2551c255c4 | ||
|
|
437b8447a2 | ||
|
|
4042d47736 | ||
|
|
0bc5cc0fe8 | ||
|
|
fa265e8ea4 | ||
|
|
30cc720dcf | ||
|
|
cdc5011eb6 | ||
|
|
fe8cf2620d | ||
|
|
e818a5a943 | ||
|
|
6dde0ffc79 | ||
|
|
a790cf594e | ||
|
|
c260914dac | ||
|
|
9558e1d5ab | ||
|
|
7f98139deb | ||
|
|
ea8e0c1819 | ||
|
|
be0737f783 | ||
|
|
6cedc0adc1 | ||
|
|
06a81db88a | ||
|
|
5ee08443f9 | ||
|
|
515eb2a16b | ||
|
|
497d7cf7f3 | ||
|
|
ea5be9508e | ||
|
|
f5c15f83b4 | ||
|
|
e3bec544be | ||
|
|
27eadcb875 | ||
|
|
31de3ffc82 | ||
|
|
7664d50d69 | ||
|
|
5357f633d3 | ||
|
|
0406ee0529 | ||
|
|
423515ec37 | ||
|
|
9426ae9df6 | ||
|
|
b1944601ef | ||
|
|
c4cc6a9386 | ||
|
|
ad48f0010f | ||
|
|
d5e3571e17 | ||
|
|
cb6e452864 | ||
|
|
c3ff4422c8 | ||
|
|
1ce691500f | ||
|
|
05b1eaf175 | ||
|
|
4f2215b126 | ||
|
|
2cb7677098 | ||
|
|
dc096a9856 | ||
|
|
8496a911da | ||
|
|
4bff05a1c6 | ||
|
|
ff90662be1 | ||
|
|
8fb69826f9 | ||
|
|
7d94dc7cf0 | ||
|
|
f93b5290ff | ||
|
|
88d7124874 | ||
|
|
7ee6dcf0e0 | ||
|
|
9370ae69ec | ||
|
|
afee7246c6 | ||
|
|
2102180c46 | ||
|
|
064548316e | ||
|
|
e53aaffd4d | ||
|
|
0d14601385 | ||
|
|
96e3fdc323 | ||
|
|
fd359ab9ed | ||
|
|
192357f4f3 | ||
|
|
223700a1a8 | ||
|
|
643744d8d9 | ||
|
|
4085a96145 | ||
|
|
ba7ea60af5 | ||
|
|
ecf416dc0a | ||
|
|
8a45e79071 | ||
|
|
5c4dfcb9ae | ||
|
|
f4cee54042 | ||
|
|
6e9049c82b | ||
|
|
4498d853b0 | ||
|
|
f6308d61eb | ||
|
|
776ffdc931 | ||
|
|
14df556e36 | ||
|
|
f3e4040d72 | ||
|
|
511f5c1681 | ||
|
|
844b48accb | ||
|
|
4bba059ed5 | ||
|
|
cc1808289a | ||
|
|
cfcc84d0c4 | ||
|
|
cbfebabfa3 | ||
|
|
c55b8c5e2e | ||
|
|
a2afc4342e | ||
|
|
b85085790a | ||
|
|
2d0bb34d17 | ||
|
|
8f3adc800f | ||
|
|
791285e7f4 | ||
|
|
b1bc8110cc | ||
|
|
0b5d643863 | ||
|
|
1acc71a6b9 | ||
|
|
41ff124d10 | ||
|
|
9a225569fd | ||
|
|
c572155d8d | ||
|
|
d79f27422f | ||
|
|
844ee751b7 | ||
|
|
7b6d60691c | ||
|
|
c7816a644f | ||
|
|
a0196ff9b2 | ||
|
|
24ae72a7d1 | ||
|
|
0099c4faae | ||
|
|
0a7e0dc1a3 | ||
|
|
62e32d5ddd | ||
|
|
f97f4d83f1 | ||
|
|
aaff759227 | ||
|
|
1314f643f7 | ||
|
|
55a5ffc6df | ||
|
|
e6a04b2c4f | ||
|
|
06293ebd2f | ||
|
|
36f63da36f | ||
|
|
e989891652 | ||
|
|
c2f0cdab15 | ||
|
|
dc1a9ad993 | ||
|
|
2522218385 | ||
|
|
258daef1cf | ||
|
|
df7e95b644 | ||
|
|
e67ce25eb9 | ||
|
|
88768aaf75 | ||
|
|
2543d2f484 | ||
|
|
96f5510153 | ||
|
|
1d6813fe30 | ||
|
|
0867e166e1 | ||
|
|
48b3ab9085 | ||
|
|
7f78e27649 | ||
|
|
46c5560222 | ||
|
|
cef5e69aa1 | ||
|
|
601395079e | ||
|
|
bbb05594e4 | ||
|
|
2f5400b49a | ||
|
|
ace071e974 | ||
|
|
a406d6d911 | ||
|
|
ba7d9b33d8 | ||
|
|
04934c6bca | ||
|
|
7409c7286f | ||
|
|
51c0157d1d | ||
|
|
0b9eec0849 | ||
|
|
8785bde7b6 | ||
|
|
31166794dd | ||
|
|
2b90b59386 | ||
|
|
280341a11c | ||
|
|
e6274174b1 | ||
|
|
984d587613 | ||
|
|
2b4def1737 | ||
|
|
6ecb45cc86 | ||
|
|
3fd0573339 | ||
|
|
d670bc2b07 | ||
|
|
5292f7ed17 | ||
|
|
9bd987abb0 | ||
|
|
4bb24da89d | ||
|
|
7491e0d9b1 | ||
|
|
d85c1dc8e8 | ||
|
|
ecb8b5df0c | ||
|
|
972fb9cce5 | ||
|
|
90894898c2 | ||
|
|
144d6589ef | ||
|
|
a7588779a3 | ||
|
|
4bc7ed3db2 | ||
|
|
5f9eb8d6d2 | ||
|
|
2f85b367b0 | ||
|
|
824c036f9a | ||
|
|
0bb27f03f3 | ||
|
|
1602f9bf02 | ||
|
|
800cd5bec8 | ||
|
|
85a12bf581 | ||
|
|
a1f24b38fa | ||
|
|
44168216fe | ||
|
|
dee4f3531d | ||
|
|
bf07f904fc | ||
|
|
26c2b1b14e | ||
|
|
7153bfefba | ||
|
|
807b77d418 | ||
|
|
c69a7b03ef | ||
|
|
a39626bb0e | ||
|
|
68e14875b3 | ||
|
|
2d6d664109 | ||
|
|
dd58973118 | ||
|
|
17d3f182a0 | ||
|
|
1961090c4c | ||
|
|
33fda705c0 | ||
|
|
208717a896 | ||
|
|
6afc6b1baa | ||
|
|
a4ddd73fc8 | ||
|
|
4459b1e333 | ||
|
|
b2a66a0789 | ||
|
|
33d1315395 | ||
|
|
a0cae13f53 | ||
|
|
d1e2634594 | ||
|
|
b2247f5f58 | ||
|
|
ae46ca2b8a | ||
|
|
82ab22518e | ||
|
|
224bf63ded | ||
|
|
a7fbde48ef | ||
|
|
3a3d0fb05e | ||
|
|
8ed71e0192 | ||
|
|
434b6a81c4 | ||
|
|
ec20f0743c | ||
|
|
09a72068e6 | ||
|
|
fcc56a9bf6 | ||
|
|
84d321f1b4 | ||
|
|
ea7c2a1548 | ||
|
|
34d12bfb49 | ||
|
|
ff0990ec0e | ||
|
|
459270e035 | ||
|
|
2af709b3d5 | ||
|
|
b84d53dc39 | ||
|
|
57997d0acb | ||
|
|
76a23870d5 | ||
|
|
193e43ddc5 | ||
|
|
fc451863c0 | ||
|
|
db69c43d35 | ||
|
|
97d727a19b | ||
|
|
ff952935a2 | ||
|
|
a9cdab1ce9 | ||
|
|
015c100a1d | ||
|
|
e7a14183d2 | ||
|
|
5e1b47707f | ||
|
|
56fc303816 | ||
|
|
d7a6a6f803 | ||
|
|
03d969cb39 | ||
|
|
c55df4eb6e | ||
|
|
cc631ff07f | ||
|
|
b34a1b57d6 | ||
|
|
9cad606a55 | ||
|
|
6587ddcead | ||
|
|
ad05eec952 | ||
|
|
847fa4f46c | ||
|
|
c5897b7ee1 | ||
|
|
99ef4c0c18 | ||
|
|
cc118adec6 | ||
|
|
ca94e37495 | ||
|
|
e2bd721c3e | ||
|
|
033ce24fb7 | ||
|
|
eab7cdd7b5 | ||
|
|
666ba41f67 | ||
|
|
2579450eae | ||
|
|
87a3ca8393 | ||
|
|
9ff4be41f7 | ||
|
|
da3ed6cd3a | ||
|
|
23519a8a90 | ||
|
|
242708358e | ||
|
|
3cc3c74e5a | ||
|
|
cb4cb39396 | ||
|
|
363799c9c6 | ||
|
|
132f83aa79 | ||
|
|
f998714225 | ||
|
|
323fc1bb99 | ||
|
|
3062d3f0e3 | ||
|
|
8f37bb7623 | ||
|
|
a57ed90756 | ||
|
|
eaf8332d16 | ||
|
|
522286ff74 | ||
|
|
747047556e | ||
|
|
df3d75c702 | ||
|
|
e40beeadd4 | ||
|
|
751f10603d | ||
|
|
751c540cb3 | ||
|
|
d7bbc329bb | ||
|
|
41cd49a61c | ||
|
|
fd5a39702b | ||
|
|
4d3408254e | ||
|
|
2cbb5ec5f2 | ||
|
|
3b765b308e | ||
|
|
53ac82520e | ||
|
|
381fcf4080 | ||
|
|
a3f0faf022 | ||
|
|
b3a8737a71 | ||
|
|
6e76bcc9af | ||
|
|
6473c55317 | ||
|
|
1a880f14a0 | ||
|
|
e39af36589 | ||
|
|
c5b262af8a | ||
|
|
43ec5c1925 | ||
|
|
5e260f0239 | ||
|
|
ab67f97b40 | ||
|
|
9ac35e2e14 | ||
|
|
7c6259dddd | ||
|
|
1cff1abc33 | ||
|
|
d834d46586 | ||
|
|
1107e1bdf3 | ||
|
|
3a11327e7e | ||
|
|
74062bab57 | ||
|
|
6802336b6b | ||
|
|
a9db134d3a | ||
|
|
ee7be1d907 | ||
|
|
5ecda88457 | ||
|
|
7056c446fa | ||
|
|
147c09ae64 | ||
|
|
112adf9eb0 | ||
|
|
0e08334132 | ||
|
|
3d51acf9e7 | ||
|
|
fd6ea5ed7e | ||
|
|
d18e5b1f14 | ||
|
|
20327d817d | ||
|
|
051c332426 | ||
|
|
a09258325e | ||
|
|
61dca0cbda | ||
|
|
2bff299193 | ||
|
|
1329b13db3 | ||
|
|
9dd894f0fe | ||
|
|
30d1ae5642 | ||
|
|
79aceef382 | ||
|
|
a28e52210a | ||
|
|
08eb294213 | ||
|
|
888baed81a | ||
|
|
4de3955db2 | ||
|
|
25f08c7624 | ||
|
|
1e8013100c | ||
|
|
62c8f55568 | ||
|
|
c40d828749 | ||
|
|
84ad08887a | ||
|
|
ed0a678020 | ||
|
|
37107d8500 | ||
|
|
dde6a8c5db | ||
|
|
e2750b8572 | ||
|
|
acd23925b5 | ||
|
|
b0db054e11 | ||
|
|
51cea8e757 | ||
|
|
bbb086ea41 | ||
|
|
539e6ed080 | ||
|
|
1d33a26318 | ||
|
|
86a20fc97a | ||
|
|
d6e00c4534 | ||
|
|
a813d38108 | ||
|
|
685a49c212 | ||
|
|
2c8eb534af | ||
|
|
5210fab4cb | ||
|
|
49787d27f1 | ||
|
|
dfe7b46461 | ||
|
|
f8257de0dd | ||
|
|
3b3ecc9f1d | ||
|
|
84105acf5d | ||
|
|
def5173692 | ||
|
|
6b31fef1af | ||
|
|
c9c059ca65 | ||
|
|
4d3b31e5a1 | ||
|
|
51d90095aa | ||
|
|
16c0370443 | ||
|
|
67bb38dcf4 | ||
|
|
049231a36e | ||
|
|
349d6b7375 | ||
|
|
b72b368d3c | ||
|
|
7f9fd44a02 | ||
|
|
a400c5974c | ||
|
|
afca995fe5 | ||
|
|
81504fedc5 | ||
|
|
3da3345683 | ||
|
|
c6c2889306 | ||
|
|
b60f0d260a | ||
|
|
cd750e4777 | ||
|
|
4e5d503b35 | ||
|
|
bd09e17e49 | ||
|
|
835eb743bb | ||
|
|
69a129d80e | ||
|
|
434bf483fd | ||
|
|
2b8e170b40 | ||
|
|
dc9b95f3e7 | ||
|
|
1616b0da0a | ||
|
|
91c457b22b | ||
|
|
70c60647c7 | ||
|
|
c0bea66d27 | ||
|
|
ed7cc078ed | ||
|
|
60b7d6d795 | ||
|
|
947f29cca0 | ||
|
|
d2a13ec0da | ||
|
|
cc39ba409e | ||
|
|
ac9db6256e | ||
|
|
096f628d97 | ||
|
|
22312812bb | ||
|
|
3584975acb | ||
|
|
30b9ca4e6c | ||
|
|
7c6ff01ebe | ||
|
|
a54edcaf5b | ||
|
|
e446954844 | ||
|
|
4a2d0d6787 | ||
|
|
9725314135 | ||
|
|
e610f0d57f | ||
|
|
8244af2940 | ||
|
|
40f371d23b | ||
|
|
817b791e59 | ||
|
|
25a7aac360 | ||
|
|
37115a174d | ||
|
|
1397f81fd0 | ||
|
|
56365cb403 | ||
|
|
dfd245ee7b | ||
|
|
9c81e4b34d | ||
|
|
67676df131 | ||
|
|
a73b129596 | ||
|
|
4600d886b5 | ||
|
|
0fe45a2405 | ||
|
|
db87213242 | ||
|
|
5c3c010d5a | ||
|
|
c804cedd7a | ||
|
|
aa30ca99bf | ||
|
|
c0ca9d1069 | ||
|
|
5e79e1d57f | ||
|
|
8c732f9f1e | ||
|
|
5e94c2f636 | ||
|
|
64e5d29996 | ||
|
|
b320d8ded2 | ||
|
|
0be1b20996 | ||
|
|
6c55460622 | ||
|
|
d627404dc2 | ||
|
|
f317123c26 | ||
|
|
e4df1887b0 | ||
|
|
34142c3e85 | ||
|
|
6e7f8bdf02 | ||
|
|
3dd9fcfb58 | ||
|
|
35e2954b8b | ||
|
|
59aa63db0f | ||
|
|
7a41a190f8 | ||
|
|
6741232450 |
@@ -77,3 +77,23 @@ 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,10 +27,17 @@ 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
|
||||||
@@ -61,3 +68,23 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
+5
-1
@@ -2,6 +2,7 @@
|
|||||||
.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
|
||||||
@@ -63,7 +64,7 @@ words-found-verbose.txt
|
|||||||
# sonarlint
|
# sonarlint
|
||||||
compile_commands.json
|
compile_commands.json
|
||||||
|
|
||||||
# pioarduino + hybrid
|
# other files
|
||||||
managed_components
|
managed_components
|
||||||
dependencies.lock
|
dependencies.lock
|
||||||
CMakeLists.txt
|
CMakeLists.txt
|
||||||
@@ -75,3 +76,6 @@ 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
@@ -1,101 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+31
-1
@@ -5,6 +5,36 @@ 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.3] 1 August 2026
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
|
||||||
|
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
|
||||||
|
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||||
|
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
|
||||||
|
- fix setting date/time on Junkers thermostats
|
||||||
|
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
|
||||||
|
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
|
||||||
|
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
|
||||||
|
- possible memory leaks fixed.
|
||||||
|
- repeated startup schedules compute value
|
||||||
|
- call `system/message` called from scheduler processes compute twice
|
||||||
|
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
||||||
|
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
|
||||||
|
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
|
||||||
|
- block too many GET requests [mentioned in #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)
|
||||||
|
|
||||||
## [3.8.2] 12 May 2026
|
## [3.8.2] 12 May 2026
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
@@ -945,4 +975,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>
|
||||||
+16
-20
@@ -2,32 +2,28 @@
|
|||||||
|
|
||||||
For more details go to [emsesp.org](https://emsesp.org/).
|
For more details go to [emsesp.org](https://emsesp.org/).
|
||||||
|
|
||||||
## [3.8.3]
|
## [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
|
## Added
|
||||||
|
|
||||||
- KM300 at address 0x4A [#3084](https://github.com/emsesp/EMS-ESP32/issues/3084)
|
- user-requested LED blink [#3063](https://github.com/emsesp/EMS-ESP32/issues/3063)
|
||||||
|
- Commands Service that can be called via MQTT or API or used in the Scheduler Service
|
||||||
|
- option to disable factory reset [#3150](https://github.com/emsesp/EMS-ESP32/issues/3150)
|
||||||
|
- TLS support with 4MB boards without PSRAM
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- signed value for solarInfuence [#3077](https://github.com/emsesp/EMS-ESP32/issues/3077)
|
- shunting yard show json
|
||||||
- set bin file upload limit to 1M again [#3086](https://github.com/emsesp/EMS-ESP32/issues/3086)
|
|
||||||
- check arithmetric operations on strings [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
|
||||||
- prevent system crash during WiFi network discovery on ESP32-S3 hardware [#3128](https://github.com/emsesp/EMS-ESP32/pull/3128)
|
|
||||||
- fix setting date/time on Junkers thermostats
|
|
||||||
- offset of hpminflowtemp [#3144](https://github.com/emsesp/EMS-ESP32/issues/3144)
|
|
||||||
- water circulation command [#3149](https://github.com/emsesp/EMS-ESP32/pull/3149)
|
|
||||||
- start scheduler after customEntities, delay scheduler loop after startup, handling of `system/restart` command [#3146](https://github.com/emsesp/EMS-ESP32/issues/3146)
|
|
||||||
- possible memory leaks fixed.
|
|
||||||
- repeated startup schedules compute value
|
|
||||||
- call `system/message` called from scheduler processes compute twice
|
|
||||||
- water module SM100/200 as dhw1/dhw2 [#3164](https://github.com/emsesp/EMS-ESP32/issues/3164)
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- call compute value direct, enlarge TCP stack [#3127](https://github.com/emsesp/EMS-ESP32/discussions/3127)
|
- various memory optimizations [#3083](https://github.com/emsesp/EMS-ESP32/issues/3083)
|
||||||
- Dewtemperature for Easycontrol calculated by ems-esp [#3135](https://github.com/emsesp/EMS-ESP32/issues/3135)
|
- Scheduler name is now mandatory
|
||||||
- add option for sync thermostat to ntp, allow different time zones [#3148](https://github.com/emsesp/EMS-ESP32/discussions/3148), **defaults to sync**.
|
- network fallback to AP only after start [#3090](https://github.com/emsesp/EMS-ESP32/issues/3090)
|
||||||
- block too many GET requests [mentioned in #3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
- replaced Web async-validator with custom validator and toast with native snackbar to reduce bundle size
|
||||||
- set 10 sec timeout for http requests [#3104](https://github.com/emsesp/EMS-ESP32/issues/3104)
|
- Gateway and Connect devices are shown in the Devices page, but disabled [3126](https://github.com/emsesp/EMS-ESP32/discussions/3126)
|
||||||
- language code "cs" [#3161](https://github.com/emsesp/EMS-ESP32/issues/3161)
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +65,6 @@ 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\"
|
||||||
@@ -113,8 +112,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 -Wswitch-enum
|
CPPFLAGS += -Wall -Wextra -Werror
|
||||||
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension
|
CPPFLAGS += -Wno-unused-parameter -Wno-missing-braces -Wno-vla-cxx-extension -Wno-switch-enum -Wno-unused-lambda-capture
|
||||||
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,7 +5,6 @@
|
|||||||
},
|
},
|
||||||
"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,7 +6,6 @@
|
|||||||
"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": "Espressif ESP32-S3 32M Flash OPI PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota 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,
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"arduino",
|
"arduino",
|
||||||
"espidf"
|
"espidf"
|
||||||
],
|
],
|
||||||
"name": "Espressif ESP32 16M Flash, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota 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": "Espressif ESP32 16M Flash DIO PSRAM, 4608KB Code/OTA, 2MB FS",
|
"name": "Tasmota 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,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
+159
-25
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"type": "systembackup",
|
"type": "systembackup",
|
||||||
"version": "3.8.2",
|
"version": "3.9.0",
|
||||||
|
"date": "2026-06-14T10:59:16",
|
||||||
"systembackup": [
|
"systembackup": [
|
||||||
{
|
{
|
||||||
"type": "settings",
|
"type": "settings",
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
"tx_power": 0
|
"tx_power": 0
|
||||||
},
|
},
|
||||||
"AP": {
|
"AP": {
|
||||||
"provision_mode": 1,
|
"provision_mode": 2,
|
||||||
"ssid": "ems-esp",
|
"ssid": "ems-esp",
|
||||||
"password": "ems-esp-neo",
|
"password": "ems-esp-neo",
|
||||||
"channel": 1,
|
"channel": 1,
|
||||||
@@ -31,13 +32,13 @@
|
|||||||
"MQTT": {
|
"MQTT": {
|
||||||
"enableTLS": false,
|
"enableTLS": false,
|
||||||
"rootCA": "",
|
"rootCA": "",
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"host": "",
|
"host": "192.168.1.2",
|
||||||
"port": 1883,
|
"port": 1883,
|
||||||
"base": "ems-esp",
|
"base": "ems-esp",
|
||||||
"username": "",
|
"username": "username",
|
||||||
"password": "",
|
"password": "password",
|
||||||
"client_id": "esp32-b8ffc9ec",
|
"client_id": "esp32",
|
||||||
"keep_alive": 60,
|
"keep_alive": 60,
|
||||||
"clean_session": false,
|
"clean_session": false,
|
||||||
"entity_format": 1,
|
"entity_format": 1,
|
||||||
@@ -51,17 +52,17 @@
|
|||||||
"publish_time_heartbeat": 60,
|
"publish_time_heartbeat": 60,
|
||||||
"mqtt_qos": 0,
|
"mqtt_qos": 0,
|
||||||
"mqtt_retain": false,
|
"mqtt_retain": false,
|
||||||
"ha_enabled": false,
|
"ha_enabled": true,
|
||||||
"nested_format": 1,
|
"nested_format": 1,
|
||||||
"discovery_prefix": "homeassistant",
|
"discovery_prefix": "homeassistant",
|
||||||
"discovery_type": 0,
|
"discovery_type": 0,
|
||||||
"ha_number_mode": 0,
|
"ha_number_mode": 1,
|
||||||
"publish_single": false,
|
"publish_single": false,
|
||||||
"publish_single2cmd": false,
|
"publish_single2cmd": false,
|
||||||
"send_response": false
|
"send_response": false
|
||||||
},
|
},
|
||||||
"NTP": {
|
"NTP": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"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"
|
||||||
@@ -82,11 +83,12 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"version": "3.8.2-dev.22",
|
"version": "3.9.0",
|
||||||
"board_profile": "E32V2_2",
|
"board_profile": "E32V2_2",
|
||||||
"platform": "ESP32",
|
"platform": "ESP32",
|
||||||
|
"system_name": "prod",
|
||||||
"locale": "en",
|
"locale": "en",
|
||||||
"tx_mode": 5,
|
"tx_mode": 1,
|
||||||
"ems_bus_id": 73,
|
"ems_bus_id": 73,
|
||||||
"syslog_enabled": false,
|
"syslog_enabled": false,
|
||||||
"syslog_level": 3,
|
"syslog_level": 3,
|
||||||
@@ -97,7 +99,7 @@
|
|||||||
"boiler_heatingoff": false,
|
"boiler_heatingoff": false,
|
||||||
"remote_timeout": 24,
|
"remote_timeout": 24,
|
||||||
"remote_timeout_en": false,
|
"remote_timeout_en": false,
|
||||||
"shower_timer": false,
|
"shower_timer": true,
|
||||||
"shower_alert": false,
|
"shower_alert": false,
|
||||||
"shower_alert_coldshot": 10,
|
"shower_alert_coldshot": 10,
|
||||||
"shower_alert_trigger": 7,
|
"shower_alert_trigger": 7,
|
||||||
@@ -107,7 +109,7 @@
|
|||||||
"dallas_gpio": 14,
|
"dallas_gpio": 14,
|
||||||
"dallas_parasite": false,
|
"dallas_parasite": false,
|
||||||
"led_gpio": 32,
|
"led_gpio": 32,
|
||||||
"hide_led": false,
|
"hide_led": true,
|
||||||
"led_type": 1,
|
"led_type": 1,
|
||||||
"low_clock": false,
|
"low_clock": false,
|
||||||
"telnet_enabled": true,
|
"telnet_enabled": true,
|
||||||
@@ -131,26 +133,74 @@
|
|||||||
"modbus_port": 502,
|
"modbus_port": 502,
|
||||||
"modbus_max_clients": 10,
|
"modbus_max_clients": 10,
|
||||||
"modbus_timeout": 300,
|
"modbus_timeout": 300,
|
||||||
"developer_mode": false
|
"developer_mode": true,
|
||||||
|
"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,
|
||||||
@@ -186,7 +236,36 @@
|
|||||||
{
|
{
|
||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -198,10 +277,65 @@
|
|||||||
{
|
{
|
||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5966,7 +5966,6 @@ uint8
|
|||||||
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
uint8
|
uint8
|
||||||
| hc1.heatondelay | heat-on delay | uint8 (>=1<=48) | hours | true | HC | 47 | 1 | 1 |
|
| hc1.heatondelay | heat-on delay | uint8 (>=1<=48) | hours | true | HC | 47 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
@@ -6116,7 +6115,6 @@ uint8
|
|||||||
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
|
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
@@ -6283,7 +6281,6 @@ uint8
|
|||||||
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
| hc1.roomtempdiff | room temp difference | uint8 (>=0<=0) | K | true | HC | 42 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
| hc1.hpminflowtemp | HP min. flow temp. | uint8 (>=0<=0) | C | true | HC | 43 | 1 | 1 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
|
| hc1.remotehum | room humidity from remote | cmd | % | true | HC | 46 | 1 | 1 |
|
||||||
uint8
|
uint8
|
||||||
@@ -6806,7 +6803,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -6867,7 +6863,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -6926,7 +6921,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -6985,7 +6979,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7046,7 +7039,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7107,7 +7099,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7166,7 +7157,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7227,7 +7217,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7286,7 +7275,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7345,7 +7333,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
@@ -7406,7 +7393,6 @@ uint8
|
|||||||
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
| hc1.ecotemp | eco temperature | uint8 (>=0<=0) | C | true | HC | 5 | 1 | 1/2 |
|
||||||
int8
|
int8
|
||||||
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
| hc1.nofrosttemp | nofrost temperature | int8 (>=0<=0) | C | true | HC | 18 | 1 | 1/2 |
|
||||||
| hc1.control | control device | enum | | true | HC | 44 | 1 | 1 |
|
|
||||||
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
| hc1.program | program | enum | | true | HC | 27 | 1 | 1 |
|
||||||
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
| hc1.remotetemp | room temperature from remote | cmd | C | true | HC | 45 | 1 | 1/10 |
|
||||||
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
| hc1.targetflowtemp | target flow temperature | uint8 | C | false | HC | 19 | 1 | 1 |
|
||||||
|
|||||||
+5984
-5998
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ build_flags =
|
|||||||
; WiFi settings
|
; WiFi settings
|
||||||
-D FACTORY_WIFI_SSID=\"\"
|
-D FACTORY_WIFI_SSID=\"\"
|
||||||
-D FACTORY_WIFI_PASSWORD=\"\"
|
-D FACTORY_WIFI_PASSWORD=\"\"
|
||||||
-D FACTORY_WIFI_HOSTNAME=\"ems-esp\"
|
-D FACTORY_NETWORK_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=0
|
-D FACTORY_NTP_THERMOSTAT_SYNC=1
|
||||||
|
|
||||||
; MQTT settings
|
; MQTT settings
|
||||||
-D FACTORY_MQTT_ENABLED=false
|
-D FACTORY_MQTT_ENABLED=false
|
||||||
|
|||||||
+22
-31
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "EMS-ESP",
|
"name": "EMS-ESP",
|
||||||
"version": "3.8.2",
|
"version": "3.9.0",
|
||||||
"description": "EMS-ESP WebUI",
|
"description": "EMS-ESP WebUI",
|
||||||
"homepage": "https://emsesp.org",
|
"homepage": "https://emsesp.org",
|
||||||
"author": "proddy, emsesp.org",
|
"author": "emsesp.org",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -28,44 +28,35 @@
|
|||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.2.0",
|
"@mui/material": "^9.2.0",
|
||||||
"@preact/compat": "^18.3.2",
|
|
||||||
"@table-library/react-table-library": "4.1.15",
|
"@table-library/react-table-library": "4.1.15",
|
||||||
"alova": "3.5.1",
|
"alova": "^3.5.3",
|
||||||
"async-validator": "^4.2.5",
|
|
||||||
"etag": "^1.8.1",
|
|
||||||
"formidable": "^3.5.4",
|
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"magic-string": "^0.30.21",
|
"preact": "^10.29.8",
|
||||||
"mime-types": "^3.0.2",
|
"react": "^19.2.8",
|
||||||
"preact": "^10.29.7",
|
"react-dom": "^19.2.8",
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7",
|
|
||||||
"react-icons": "^5.7.0",
|
"react-icons": "^5.7.0",
|
||||||
"react-router": "^7.18.1",
|
"react-router": "^8.3.0",
|
||||||
"react-toastify": "^11.1.0",
|
"typesafe-i18n": "^5.27.1"
|
||||||
"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/compat": "^18.3.2",
|
"@preact/preset-vite": "^2.10.6",
|
||||||
"@preact/preset-vite": "^2.10.5",
|
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/node": "^25.9.5",
|
"@types/node": "^26.1.2",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.4",
|
||||||
"axe-core": "^4.12.1",
|
"concurrently": "^10.0.4",
|
||||||
"concurrently": "^9.2.4",
|
"eslint": "^10.8.0",
|
||||||
"eslint": "^10.7.0",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.5",
|
"etag": "^1.8.1",
|
||||||
|
"mime-types": "^3.0.2",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"rollup-plugin-visualizer": "^7.0.1",
|
"rollup-plugin-visualizer": "^7.0.1",
|
||||||
"terser": "^5.49.0",
|
"terser": "^5.49.0",
|
||||||
"typescript-eslint": "^8.63.0",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.1.4",
|
"typescript-eslint": "^8.65.0",
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
"vite": "^8.2.0",
|
||||||
"vite-tsconfig-paths": "^6.1.1"
|
"vite-plugin-imagemin": "^0.6.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
|
"packageManager": "pnpm@11.18.0+sha512.33d83c77da82f49fba836925c6f1b841181ec3132b670639bd012f7075f5c7cf634c5f870147c19aae7478fac01df09d8892e880454896edd23ee9b33757563c"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+626
-1319
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,8 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
cwebp-bin: set this to true or false
|
cwebp-bin: false
|
||||||
esbuild: set this to true or false
|
esbuild: false
|
||||||
gifsicle: set this to true or false
|
gifsicle: false
|
||||||
jpegtran-bin: set this to true or false
|
jpegtran-bin: false
|
||||||
mozjpeg: set this to true or false
|
mozjpeg: false
|
||||||
optipng-bin: set this to true or false
|
optipng-bin: false
|
||||||
pngquant-bin: set this to true or false
|
pngquant-bin: 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,6 +46,38 @@ ${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);
|
||||||
@@ -116,7 +148,12 @@ writeStream.write(ARDUINO_INCLUDES);
|
|||||||
|
|
||||||
const buildPath = resolve(sourcePath);
|
const buildPath = resolve(sourcePath);
|
||||||
for (const filePath of getFilesSync(buildPath)) {
|
for (const filePath of getFilesSync(buildPath)) {
|
||||||
writeFile(relative(buildPath, filePath), readFileSync(filePath));
|
const relativeFilePath = relative(buildPath, 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: 5.6 KiB After Width: | Height: | Size: 2.4 KiB |
+36
-36
@@ -1,14 +1,15 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
import { ToastContainer, Zoom } from 'react-toastify';
|
import { Outlet } from 'react-router';
|
||||||
|
|
||||||
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 AVAILABLE_LOCALES = [
|
const ALL_LOCALES = [
|
||||||
'de',
|
'de',
|
||||||
'en',
|
'en',
|
||||||
'it',
|
'it',
|
||||||
@@ -22,51 +23,50 @@ const AVAILABLE_LOCALES = [
|
|||||||
'cs'
|
'cs'
|
||||||
] as Locales[];
|
] as Locales[];
|
||||||
|
|
||||||
// Static toast configuration - no need to recreate on every render
|
// Optional build-time allow-list (e.g. VITE_APP_LOCALES="en,de,nl"). When unset,
|
||||||
const TOAST_CONTAINER_PROPS = {
|
// every locale is available. `en` is always kept as the fallback locale, and the
|
||||||
position: 'bottom-left' as const,
|
// progmem generator embeds the matching subset into firmware flash.
|
||||||
autoClose: 3000,
|
const localeAllowList = (import.meta.env.VITE_APP_LOCALES ?? '')
|
||||||
hideProgressBar: false,
|
.split(',')
|
||||||
newestOnTop: false,
|
.map((locale) => locale.trim())
|
||||||
closeOnClick: true,
|
.filter(Boolean);
|
||||||
rtl: false,
|
|
||||||
pauseOnFocusLoss: true,
|
const AVAILABLE_LOCALES: Locales[] = localeAllowList.length
|
||||||
draggable: false,
|
? ALL_LOCALES.filter(
|
||||||
pauseOnHover: false,
|
(locale) => locale === 'en' || localeAllowList.includes(locale)
|
||||||
transition: Zoom,
|
)
|
||||||
closeButton: false,
|
: ALL_LOCALES;
|
||||||
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');
|
||||||
|
|
||||||
// Memoize locale initialization to prevent unnecessary re-runs
|
|
||||||
const initializeLocale = useCallback(async () => {
|
|
||||||
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
|
||||||
const newLocale = (localStorage.getItem('lang') || browserLocale) as Locales;
|
|
||||||
localStorage.setItem('lang', newLocale);
|
|
||||||
setLocale(newLocale);
|
|
||||||
await loadLocaleAsync(newLocale);
|
|
||||||
setWasLoaded(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const initializeLocale = async () => {
|
||||||
|
const browserLocale = detectLocale('en', AVAILABLE_LOCALES, navigatorDetector);
|
||||||
|
const stored = localStorage.getItem('lang');
|
||||||
|
// Ignore a stored locale that isn't available (e.g. trimmed from this build).
|
||||||
|
const newLocale =
|
||||||
|
stored && AVAILABLE_LOCALES.includes(stored as Locales)
|
||||||
|
? (stored as Locales)
|
||||||
|
: browserLocale;
|
||||||
|
localStorage.setItem('lang', newLocale);
|
||||||
|
setLocale(newLocale);
|
||||||
|
await loadLocaleAsync(newLocale);
|
||||||
|
setWasLoaded(true);
|
||||||
|
};
|
||||||
void initializeLocale();
|
void initializeLocale();
|
||||||
}, [initializeLocale]);
|
}, []);
|
||||||
|
|
||||||
if (!wasLoaded) return null;
|
if (!wasLoaded) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TypesafeI18n locale={locale}>
|
<TypesafeI18n locale={locale}>
|
||||||
<CustomTheme>
|
<CustomTheme>
|
||||||
<AppRouting />
|
<Authentication>
|
||||||
<ToastContainer {...TOAST_CONTAINER_PROPS} />
|
<Outlet />
|
||||||
|
</Authentication>
|
||||||
|
<Toaster />
|
||||||
</CustomTheme>
|
</CustomTheme>
|
||||||
</TypesafeI18n>
|
</TypesafeI18n>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { memo, useContext } from 'react';
|
|
||||||
import { Navigate, Route, Routes } from 'react-router';
|
|
||||||
|
|
||||||
import CustomEntities from 'app/main/CustomEntities';
|
|
||||||
import Customizations from 'app/main/Customizations';
|
|
||||||
import Dashboard from 'app/main/Dashboard';
|
|
||||||
import Devices from 'app/main/Devices';
|
|
||||||
import Help from 'app/main/Help';
|
|
||||||
import Modules from 'app/main/Modules';
|
|
||||||
import Scheduler from 'app/main/Scheduler';
|
|
||||||
import Sensors from 'app/main/Sensors';
|
|
||||||
import UserProfile from 'app/main/UserProfile';
|
|
||||||
import APSettings from 'app/settings/APSettings';
|
|
||||||
import ApplicationSettings from 'app/settings/ApplicationSettings';
|
|
||||||
import DownloadUpload from 'app/settings/DownloadUpload';
|
|
||||||
import MqttSettings from 'app/settings/MqttSettings';
|
|
||||||
import NTPSettings from 'app/settings/NTPSettings';
|
|
||||||
import Settings from 'app/settings/Settings';
|
|
||||||
import Network from 'app/settings/network/Network';
|
|
||||||
import Security from 'app/settings/security/Security';
|
|
||||||
import APStatus from 'app/status/APStatus';
|
|
||||||
import Activity from 'app/status/Activity';
|
|
||||||
import HardwareStatus from 'app/status/HardwareStatus';
|
|
||||||
import MqttStatus from 'app/status/MqttStatus';
|
|
||||||
import NTPStatus from 'app/status/NTPStatus';
|
|
||||||
import NetworkStatus from 'app/status/NetworkStatus';
|
|
||||||
import Status from 'app/status/Status';
|
|
||||||
import SystemLog from 'app/status/SystemLog';
|
|
||||||
import Version from 'app/status/Version';
|
|
||||||
import { Layout } from 'components';
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
|
||||||
|
|
||||||
const AuthenticatedRouting = memo(() => {
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/dashboard/*" element={<Dashboard />} />
|
|
||||||
<Route path="/devices/*" element={<Devices />} />
|
|
||||||
<Route path="/sensors/*" element={<Sensors />} />
|
|
||||||
<Route path="/help/*" element={<Help />} />
|
|
||||||
<Route path="/user/*" element={<UserProfile />} />
|
|
||||||
|
|
||||||
<Route path="/status/*" element={<Status />} />
|
|
||||||
<Route path="/status/hardwarestatus/*" element={<HardwareStatus />} />
|
|
||||||
<Route path="/status/activity" element={<Activity />} />
|
|
||||||
<Route path="/status/log" element={<SystemLog />} />
|
|
||||||
<Route path="/status/mqtt" element={<MqttStatus />} />
|
|
||||||
<Route path="/status/ntp" element={<NTPStatus />} />
|
|
||||||
<Route path="/status/ap" element={<APStatus />} />
|
|
||||||
<Route path="/status/network" element={<NetworkStatus />} />
|
|
||||||
<Route path="/status/version" element={<Version />} />
|
|
||||||
|
|
||||||
{me.admin && (
|
|
||||||
<>
|
|
||||||
<Route path="/settings" element={<Settings />} />
|
|
||||||
<Route path="/settings/application" element={<ApplicationSettings />} />
|
|
||||||
<Route path="/settings/mqtt" element={<MqttSettings />} />
|
|
||||||
<Route path="/settings/ntp" element={<NTPSettings />} />
|
|
||||||
<Route path="/settings/ap" element={<APSettings />} />
|
|
||||||
<Route path="/settings/modules" element={<Modules />} />
|
|
||||||
<Route path="/settings/downloadUpload" element={<DownloadUpload />} />
|
|
||||||
|
|
||||||
<Route path="/settings/network/*" element={<Network />} />
|
|
||||||
<Route path="/settings/security/*" element={<Security />} />
|
|
||||||
|
|
||||||
<Route path="/customizations" element={<Customizations />} />
|
|
||||||
<Route path="/scheduler" element={<Scheduler />} />
|
|
||||||
<Route path="/customentities" element={<CustomEntities />} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Route path="/*" element={<Navigate to="/" />} />
|
|
||||||
</Routes>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default AuthenticatedRouting;
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { memo, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
import ForwardIcon from '@mui/icons-material/Forward';
|
import ForwardIcon from '@mui/icons-material/Forward';
|
||||||
import { Box, Button, Paper, Typography } from '@mui/material';
|
import { Box, Button, Paper, Typography } from '@mui/material';
|
||||||
@@ -7,18 +6,19 @@ 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,7 +43,6 @@ const SignIn = memo(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoize callback to prevent recreation on every render
|
|
||||||
const updateLoginRequestValue = useMemo(
|
const updateLoginRequestValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
updateValue((updater) =>
|
updateValue((updater) =>
|
||||||
@@ -65,7 +64,7 @@ const SignIn = memo(() => {
|
|||||||
});
|
});
|
||||||
}, [callSignIn, signInRequest, LL]);
|
}, [callSignIn, signInRequest, LL]);
|
||||||
|
|
||||||
const validateAndSignIn = useCallback(async () => {
|
const validateAndSignIn = 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')
|
||||||
@@ -77,7 +76,7 @@ const SignIn = memo(() => {
|
|||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}, [signInRequest, signIn, LL]);
|
};
|
||||||
|
|
||||||
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
const submitOnEnter = useMemo(() => onEnterCallback(signIn), [signIn]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
APIcall,
|
APIcall,
|
||||||
Action,
|
Action,
|
||||||
Activity,
|
Activity,
|
||||||
|
CommandItem,
|
||||||
|
Commands,
|
||||||
CoreData,
|
CoreData,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
DeviceData,
|
DeviceData,
|
||||||
@@ -102,8 +104,7 @@ 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: si.cmd,
|
o_cmd_name: si.cmd_name,
|
||||||
o_value: si.value,
|
|
||||||
o_name: si.name
|
o_name: si.name
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -111,6 +112,24 @@ 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,12 +57,3 @@ export const alovaInstance = createAlova({
|
|||||||
onSuccess: handleResponse
|
onSuccess: handleResponse
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const alovaInstanceGH = createAlova({
|
|
||||||
baseURL:
|
|
||||||
process.env.NODE_ENV === 'development'
|
|
||||||
? '/gh'
|
|
||||||
: 'https://api.github.com/repos/emsesp/EMS-ESP32/releases',
|
|
||||||
statesHook: ReactHook,
|
|
||||||
requestAdapter: xhrRequestAdapter()
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { LogSettings, SystemStatus } from 'types';
|
import type { LogSettings, SystemStatus } from 'types';
|
||||||
|
|
||||||
import { alovaInstance, alovaInstanceGH } from './endpoints';
|
import { alovaInstance } from './endpoints';
|
||||||
|
|
||||||
// systemStatus - also used to ping in System Monitor for pinging
|
// systemStatus - also used to ping in System Monitor for pinging
|
||||||
export const readSystemStatus = () =>
|
export const readSystemStatus = () =>
|
||||||
@@ -13,29 +13,6 @@ 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) => {
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useBlocker } from 'react-router';
|
||||||
|
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import { Box, Button, IconButton, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
import { callAction } from '@/api/app';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Cell,
|
||||||
|
Header,
|
||||||
|
HeaderCell,
|
||||||
|
HeaderRow,
|
||||||
|
Row,
|
||||||
|
Table
|
||||||
|
} from '@table-library/react-table-library/table';
|
||||||
|
import { useTheme } from '@table-library/react-table-library/theme';
|
||||||
|
import { updateState, useRequest } from 'alova/client';
|
||||||
|
import {
|
||||||
|
BlockNavigation,
|
||||||
|
ButtonRow,
|
||||||
|
FormLoader,
|
||||||
|
SectionContent,
|
||||||
|
useLayoutTitle
|
||||||
|
} from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
import { useInterval } from 'utils';
|
||||||
|
|
||||||
|
import { readCommands, writeCommands } from '../../api/app';
|
||||||
|
import CommandsDialog from './CommandsDialog';
|
||||||
|
import type { CommandItem, Commands as CommandsType } from './types';
|
||||||
|
import { commandItemValidation } from './validators';
|
||||||
|
|
||||||
|
const INTERVAL_DELAY = 30000;
|
||||||
|
const MIN_ID = -100;
|
||||||
|
const MAX_ID = 100;
|
||||||
|
|
||||||
|
const DEFAULT_COMMAND_ITEM: Omit<CommandItem, 'id'> = {
|
||||||
|
cmd: '',
|
||||||
|
value: '',
|
||||||
|
name: '',
|
||||||
|
deleted: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandsTheme = {
|
||||||
|
Table: `
|
||||||
|
--data-table-library_grid-template-columns: repeat(1, minmax(100px, 1fr)) repeat(1, minmax(100px, 1fr)) 160px 32px;
|
||||||
|
`,
|
||||||
|
BaseRow: `
|
||||||
|
font-size: 14px;
|
||||||
|
.td {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
BaseCell: `
|
||||||
|
&:nth-of-type(1) {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
HeaderRow: `
|
||||||
|
text-transform: uppercase;
|
||||||
|
background-color: black;
|
||||||
|
color: #90CAF9;
|
||||||
|
.th {
|
||||||
|
border-bottom: 1px solid #565656;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
Row: `
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
.td {
|
||||||
|
border-bottom: 1px solid #565656;
|
||||||
|
}
|
||||||
|
&:hover .td {
|
||||||
|
background-color: #177ac9;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
};
|
||||||
|
|
||||||
|
const Commands = () => {
|
||||||
|
const { LL } = useI18nContext();
|
||||||
|
const [numChanges, setNumChanges] = useState<number>(0);
|
||||||
|
const blocker = useBlocker(numChanges !== 0);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<CommandItem>();
|
||||||
|
const [creating, setCreating] = useState<boolean>(false);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useLayoutTitle(LL.COMMANDS());
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: commands,
|
||||||
|
send: fetchCommands,
|
||||||
|
error
|
||||||
|
} = useRequest(readCommands, {
|
||||||
|
initialData: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const { send: updateCommands } = useRequest(
|
||||||
|
(data: CommandsType) => writeCommands(data),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasChanged = (ci: CommandItem) =>
|
||||||
|
ci.id !== ci.o_id ||
|
||||||
|
(ci.name || '') !== (ci.o_name || '') ||
|
||||||
|
ci.cmd !== ci.o_cmd ||
|
||||||
|
ci.value !== ci.o_value ||
|
||||||
|
ci.deleted !== ci.o_deleted;
|
||||||
|
|
||||||
|
useInterval(() => {
|
||||||
|
if (numChanges === 0) {
|
||||||
|
void fetchCommands();
|
||||||
|
}
|
||||||
|
}, INTERVAL_DELAY);
|
||||||
|
|
||||||
|
const theme = useTheme(commandsTheme);
|
||||||
|
|
||||||
|
const saveCommands = async () => {
|
||||||
|
try {
|
||||||
|
await updateCommands({
|
||||||
|
commands: commands
|
||||||
|
.filter((ci: CommandItem) => !ci.deleted)
|
||||||
|
.map((ci: CommandItem) => ({
|
||||||
|
id: ci.id,
|
||||||
|
cmd: ci.cmd,
|
||||||
|
value: ci.value,
|
||||||
|
name: ci.name
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
toast.success(LL.UPDATED_OF(LL.COMMANDS()));
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
await fetchCommands();
|
||||||
|
setNumChanges(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { send: executeCommand } = useRequest(
|
||||||
|
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
.onSuccess(() => {
|
||||||
|
toast.success(LL.EXECUTE_COMMAND_SENT());
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = async (name: string) => {
|
||||||
|
await executeCommand(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (ci: CommandItem) => {
|
||||||
|
setCreating(false);
|
||||||
|
setSelectedItem(ci);
|
||||||
|
setDialogOpen(true);
|
||||||
|
if (ci.o_name === undefined) {
|
||||||
|
ci.o_name = ci.name;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogClose = () => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogCancel = async () => {
|
||||||
|
await fetchCommands().then(() => {
|
||||||
|
setNumChanges(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDialogSave = (updatedItem: CommandItem) => {
|
||||||
|
setDialogOpen(false);
|
||||||
|
void updateState(readCommands(), (data: CommandItem[]) => {
|
||||||
|
const new_data = creating
|
||||||
|
? [...data, updatedItem]
|
||||||
|
: data.map((ci) =>
|
||||||
|
ci.id === updatedItem.id ? { ...ci, ...updatedItem } : ci
|
||||||
|
);
|
||||||
|
setNumChanges(new_data.filter((ci) => hasChanged(ci)).length);
|
||||||
|
return new_data;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
setCreating(true);
|
||||||
|
const newItem: CommandItem = {
|
||||||
|
id: Math.floor(Math.random() * (MAX_ID - MIN_ID) + MIN_ID),
|
||||||
|
...DEFAULT_COMMAND_ITEM
|
||||||
|
};
|
||||||
|
setSelectedItem(newItem);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCommands = commands.filter((ci: CommandItem) => !ci.deleted);
|
||||||
|
|
||||||
|
const renderCommands = () => {
|
||||||
|
if (!commands) {
|
||||||
|
return (
|
||||||
|
<FormLoader onRetry={fetchCommands} errorMessage={error?.message || ''} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
data={{ nodes: filteredCommands }}
|
||||||
|
theme={theme}
|
||||||
|
layout={{ custom: true }}
|
||||||
|
>
|
||||||
|
{(tableList: CommandItem[]) => (
|
||||||
|
<>
|
||||||
|
<Header>
|
||||||
|
<HeaderRow>
|
||||||
|
<HeaderCell stiff>{LL.COMMAND(0)}</HeaderCell>
|
||||||
|
<HeaderCell stiff>{LL.VALUE(0)}</HeaderCell>
|
||||||
|
<HeaderCell stiff>{LL.NAME(0)}</HeaderCell>
|
||||||
|
<HeaderCell></HeaderCell>
|
||||||
|
</HeaderRow>
|
||||||
|
</Header>
|
||||||
|
<Body>
|
||||||
|
{tableList.map((ci: CommandItem) => (
|
||||||
|
<Row key={ci.id} item={ci} onClick={() => editItem(ci)}>
|
||||||
|
<Cell>{ci.cmd}</Cell>
|
||||||
|
<Cell>{ci.value}</Cell>
|
||||||
|
<Cell>{ci.name}</Cell>
|
||||||
|
<Cell>
|
||||||
|
{numChanges === 0 && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => execute(ci.name)}
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<PlayArrowIcon color="primary" sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Cell>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
{blocker ? <BlockNavigation blocker={blocker} /> : null}
|
||||||
|
<Typography sx={{ mb: 2 }} color="warning" variant="body1">
|
||||||
|
{LL.COMMANDS_HELP_1()}.
|
||||||
|
</Typography>
|
||||||
|
{renderCommands()}
|
||||||
|
|
||||||
|
{selectedItem && (
|
||||||
|
<CommandsDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
creating={creating}
|
||||||
|
onClose={onDialogClose}
|
||||||
|
onSave={onDialogSave}
|
||||||
|
selectedItem={selectedItem}
|
||||||
|
validator={commandItemValidation(commands, selectedItem)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
{numChanges !== 0 && (
|
||||||
|
<ButtonRow>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onDialogCancel}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="contained"
|
||||||
|
color="info"
|
||||||
|
onClick={saveCommands}
|
||||||
|
>
|
||||||
|
{LL.APPLY_CHANGES(numChanges)}
|
||||||
|
</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
|
||||||
|
<ButtonRow>
|
||||||
|
<Button
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={addItem}
|
||||||
|
>
|
||||||
|
{LL.ADD(0)}
|
||||||
|
</Button>
|
||||||
|
</ButtonRow>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Commands;
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import DoneIcon from '@mui/icons-material/Done';
|
||||||
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||||
|
import RemoveIcon from '@mui/icons-material/RemoveCircleOutlined';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
TextField
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { callAction } from '@/api/app';
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import { ValidatedTextField } from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
import { updateValue } from 'utils';
|
||||||
|
import { ValidationError, validate } from 'validators';
|
||||||
|
import type Schema from 'validators/schema';
|
||||||
|
import type { ValidateFieldsError } from 'validators/schema';
|
||||||
|
|
||||||
|
import type { CommandItem } from './types';
|
||||||
|
|
||||||
|
interface CommandsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
creating: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (ci: CommandItem) => void;
|
||||||
|
selectedItem: CommandItem;
|
||||||
|
validator: Schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandsDialog = ({
|
||||||
|
open,
|
||||||
|
creating,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
selectedItem,
|
||||||
|
validator
|
||||||
|
}: CommandsDialogProps) => {
|
||||||
|
const { LL } = useI18nContext();
|
||||||
|
const [hasChanges, setHasChanges] = useState<boolean>(false);
|
||||||
|
const [editItem, setEditItem] = useState<CommandItem>(selectedItem);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
|
const updateFormValue = updateValue(
|
||||||
|
setEditItem as unknown as React.Dispatch<
|
||||||
|
React.SetStateAction<Record<string, unknown>>
|
||||||
|
>
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
setEditItem(selectedItem);
|
||||||
|
}
|
||||||
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
|
const hasChanged = (ci: CommandItem) =>
|
||||||
|
ci.id !== ci.o_id ||
|
||||||
|
(ci.name || '') !== (ci.o_name || '') ||
|
||||||
|
ci.cmd !== ci.o_cmd ||
|
||||||
|
ci.value !== ci.o_value ||
|
||||||
|
ci.deleted !== ci.o_deleted;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasChanges(hasChanged(editItem));
|
||||||
|
}, [editItem]);
|
||||||
|
|
||||||
|
const handleSave = async (itemToSave: CommandItem) => {
|
||||||
|
try {
|
||||||
|
setFieldErrors(undefined);
|
||||||
|
await validate(validator, itemToSave);
|
||||||
|
onSave(itemToSave);
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
|
} finally {
|
||||||
|
// setHasChanges(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
await handleSave(editItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { send: executeCommand } = useRequest(
|
||||||
|
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
.onSuccess(() => {
|
||||||
|
toast.success(LL.EXECUTE_COMMAND_SENT());
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
await executeCommand(editItem.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
onSave({ ...editItem, deleted: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (
|
||||||
|
_event: React.SyntheticEvent,
|
||||||
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
|
) => {
|
||||||
|
if (reason !== 'backdropClick') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{creating ? `${LL.ADD(1)} ${LL.NEW(0)}` : LL.EDIT()}
|
||||||
|
{LL.COMMAND(1)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="cmd"
|
||||||
|
label={LL.COMMAND(0)}
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
value={editItem.cmd}
|
||||||
|
margin="normal"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
name="value"
|
||||||
|
label={LL.VALUE(0)}
|
||||||
|
multiline
|
||||||
|
margin="normal"
|
||||||
|
fullWidth
|
||||||
|
value={editItem.value}
|
||||||
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors || {}}
|
||||||
|
name="name"
|
||||||
|
label={LL.NAME(0)}
|
||||||
|
value={editItem.name}
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
{!creating && (
|
||||||
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
|
<Button
|
||||||
|
startIcon={<RemoveIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="warning"
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
{LL.REMOVE()}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
startIcon={creating ? <AddIcon /> : <DoneIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={save}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{creating ? LL.ADD(0) : LL.UPDATE()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!creating && !hasChanges && editItem.cmd !== '' && (
|
||||||
|
<Button
|
||||||
|
startIcon={<PlayArrowIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={execute}
|
||||||
|
color="success"
|
||||||
|
>
|
||||||
|
{LL.EXECUTE()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommandsDialog;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -26,6 +25,7 @@ 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,20 +57,18 @@ const CustomEntities = () => {
|
|||||||
initialData: []
|
initialData: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
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 = useCallback((ei: EntityItem) => {
|
const hasEntityChanged = (ei: EntityItem) => {
|
||||||
return (
|
return (
|
||||||
ei.id !== ei.o_id ||
|
ei.id !== ei.o_id ||
|
||||||
ei.ram !== ei.o_ram ||
|
ei.ram !== ei.o_ram ||
|
||||||
@@ -86,21 +84,19 @@ 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 = useMemo(
|
const entity_theme = useTheme({
|
||||||
() =>
|
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;
|
||||||
}
|
}
|
||||||
@@ -120,7 +116,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;
|
||||||
@@ -129,7 +125,7 @@ const CustomEntities = () => {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Row: `
|
Row: `
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -140,11 +136,9 @@ const CustomEntities = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const saveEntities = useCallback(async () => {
|
const saveEntities = async () => {
|
||||||
await writeEntities({
|
await writeEntities({
|
||||||
entities: entities
|
entities: entities
|
||||||
.filter((ei: EntityItem) => !ei.deleted)
|
.filter((ei: EntityItem) => !ei.deleted)
|
||||||
@@ -173,44 +167,41 @@ const CustomEntities = () => {
|
|||||||
await fetchEntities();
|
await fetchEntities();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [entities, writeEntities, LL, fetchEntities]);
|
};
|
||||||
|
|
||||||
const editEntityItem = useCallback((ei: EntityItem) => {
|
const editEntityItem = (ei: EntityItem) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setSelectedEntityItem(ei);
|
setSelectedEntityItem(ei);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogCancel = useCallback(async () => {
|
const onDialogCancel = async () => {
|
||||||
await fetchEntities().then(() => {
|
await fetchEntities().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchEntities]);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: EntityItem) => {
|
||||||
(updatedItem: EntityItem) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
||||||
void updateState(readCustomEntities(), (data: EntityItem[]) => {
|
const new_data = creating
|
||||||
const new_data = creating
|
? [
|
||||||
? [
|
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
||||||
...data.filter((ei) => creating || ei.o_id !== updatedItem.o_id),
|
updatedItem
|
||||||
updatedItem
|
]
|
||||||
]
|
: data.map((ei) =>
|
||||||
: data.map((ei) =>
|
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
||||||
ei.id === updatedItem.id ? { ...ei, ...updatedItem } : ei
|
);
|
||||||
);
|
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
||||||
setNumChanges(new_data.filter((ei) => hasEntityChanged(ei)).length);
|
return new_data;
|
||||||
return new_data;
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[creating, hasEntityChanged]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDialogDup = useCallback((item: EntityItem) => {
|
const onDialogDup = (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),
|
||||||
@@ -228,9 +219,9 @@ const CustomEntities = () => {
|
|||||||
value: item.value
|
value: item.value
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const addEntityItem = useCallback(() => {
|
const addEntityItem = () => {
|
||||||
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),
|
||||||
@@ -248,30 +239,27 @@ const CustomEntities = () => {
|
|||||||
value: ''
|
value: ''
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const formatValue = useCallback((value: unknown, uom: number) => {
|
const formatValue = (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 = useCallback((value: number, digit: number) => {
|
const showHex = (value: number, digit: number) => {
|
||||||
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
return `0x${value.toString(16).toUpperCase().padStart(digit, '0')}`;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const filteredAndSortedEntities = useMemo(
|
const filteredAndSortedEntities =
|
||||||
() =>
|
entities
|
||||||
entities
|
?.filter((ei: EntityItem) => !ei.deleted)
|
||||||
?.filter((ei: EntityItem) => !ei.deleted)
|
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [];
|
||||||
.sort((a: EntityItem, b: EntityItem) => a.name.localeCompare(b.name)) ?? [],
|
|
||||||
[entities]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderEntity = useCallback(() => {
|
const renderEntity = () => {
|
||||||
if (!entities) {
|
if (!entities) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchEntities} errorMessage={error?.message || ''} />
|
||||||
@@ -328,17 +316,7 @@ const CustomEntities = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
entities,
|
|
||||||
error,
|
|
||||||
fetchEntities,
|
|
||||||
entity_theme,
|
|
||||||
editEntityItem,
|
|
||||||
LL,
|
|
||||||
filteredAndSortedEntities,
|
|
||||||
showHex,
|
|
||||||
formatValue
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,14 +68,10 @@ 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 = useMemo(
|
const updateFormValue = updateValue(
|
||||||
() =>
|
setEditItem as unknown as React.Dispatch<
|
||||||
updateValue(
|
React.SetStateAction<Record<string, unknown>>
|
||||||
setEditItem as unknown as React.Dispatch<
|
>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,16 +101,16 @@ const CustomEntitiesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -138,27 +134,21 @@ const CustomEntitiesDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
const remove = () => {
|
||||||
const itemWithDeleted = { ...editItem, deleted: true };
|
onSave({ ...editItem, deleted: true });
|
||||||
onSave(itemWithDeleted);
|
};
|
||||||
}, [editItem, onSave]);
|
|
||||||
|
|
||||||
const dup = useCallback(() => {
|
const dup = () => {
|
||||||
onDup(editItem);
|
onDup(editItem);
|
||||||
}, [editItem, onDup]);
|
};
|
||||||
|
|
||||||
// Memoize UOM menu items to avoid recreating on every render
|
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>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
<Dialog sx={dialogStyle} open={open} onClose={handleClose}>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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';
|
||||||
@@ -46,6 +45,7 @@ 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,19 +171,17 @@ const Customizations = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const entities_theme = useMemo(
|
const entities_theme = useTheme({
|
||||||
() =>
|
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;
|
||||||
}
|
}
|
||||||
@@ -194,7 +192,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;
|
||||||
@@ -206,7 +204,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;
|
||||||
@@ -222,7 +220,7 @@ const Customizations = () => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
Cell: `
|
Cell: `
|
||||||
&:nth-of-type(2) {
|
&:nth-of-type(2) {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
@@ -236,9 +234,7 @@ const Customizations = () => {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
function hasEntityChanged(de: DeviceEntity) {
|
function hasEntityChanged(de: DeviceEntity) {
|
||||||
return (
|
return (
|
||||||
@@ -287,26 +283,23 @@ const Customizations = () => {
|
|||||||
return value as string;
|
return value as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCommand = useCallback((de: DeviceEntity) => {
|
const isCommand = (de: DeviceEntity) => {
|
||||||
return de.n && de.n[0] === '!';
|
return de.n && de.n[0] === '!';
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const formatName = useCallback(
|
const formatName = (de: DeviceEntity, withShortname: boolean) => {
|
||||||
(de: DeviceEntity, withShortname: boolean) => {
|
let name: string;
|
||||||
let name: string;
|
if (isCommand(de)) {
|
||||||
if (isCommand(de)) {
|
name = de.t
|
||||||
name = de.t
|
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
||||||
? `${LL.COMMAND(1)}: ${de.t} ${de.n?.slice(1)}`
|
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
||||||
: `${LL.COMMAND(1)}: ${de.n?.slice(1)}`;
|
} else if (de.cn && de.cn !== '') {
|
||||||
} else if (de.cn && de.cn !== '') {
|
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
||||||
name = de.t ? `${de.t} ${de.cn}` : de.cn;
|
} else {
|
||||||
} else {
|
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
||||||
name = de.t ? `${de.t} ${de.n}` : de.n || '';
|
}
|
||||||
}
|
return withShortname ? `${name} ${de.id}` : name;
|
||||||
return withShortname ? `${name} ${de.id}` : name;
|
};
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const getMaskNumber = (newMask: string[]) => {
|
const getMaskNumber = (newMask: string[]) => {
|
||||||
let new_mask = 0;
|
let new_mask = 0;
|
||||||
@@ -336,33 +329,27 @@ const Customizations = () => {
|
|||||||
return new_masks;
|
return new_masks;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter_entity = useCallback(
|
const filter_entity = (de: DeviceEntity) =>
|
||||||
(de: DeviceEntity) =>
|
(de.m & selectedFilters || !selectedFilters) &&
|
||||||
(de.m & selectedFilters || !selectedFilters) &&
|
formatName(de, true).toLowerCase().includes(search.toLowerCase());
|
||||||
formatName(de, true).toLowerCase().includes(search.toLowerCase()),
|
|
||||||
[selectedFilters, search, formatName]
|
|
||||||
);
|
|
||||||
|
|
||||||
const maskDisabled = useCallback(
|
const maskDisabled = (set: boolean) => {
|
||||||
(set: boolean) => {
|
setDeviceEntities((prev) =>
|
||||||
setDeviceEntities((prev) =>
|
prev.map((de) => {
|
||||||
prev.map((de) => {
|
if (filter_entity(de)) {
|
||||||
if (filter_entity(de)) {
|
const excludeMask =
|
||||||
const excludeMask =
|
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
DeviceEntityMask.DV_API_MQTT_EXCLUDE | DeviceEntityMask.DV_WEB_EXCLUDE;
|
return {
|
||||||
return {
|
...de,
|
||||||
...de,
|
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
||||||
m: set ? de.m | excludeMask : de.m & ~excludeMask
|
};
|
||||||
};
|
}
|
||||||
}
|
return de;
|
||||||
return de;
|
})
|
||||||
})
|
);
|
||||||
);
|
};
|
||||||
},
|
|
||||||
[filter_entity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetCustomization = useCallback(async () => {
|
const resetCustomization = async () => {
|
||||||
try {
|
try {
|
||||||
await sendResetCustomizations();
|
await sendResetCustomizations();
|
||||||
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
toast.info(LL.CUSTOMIZATIONS_RESTART());
|
||||||
@@ -372,30 +359,27 @@ const Customizations = () => {
|
|||||||
setConfirmReset(false);
|
setConfirmReset(false);
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
}
|
}
|
||||||
}, [sendResetCustomizations, LL]);
|
};
|
||||||
|
|
||||||
const onDialogClose = () => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (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 = useCallback(
|
const onDialogSave = (updatedItem: DeviceEntity) => {
|
||||||
(updatedItem: DeviceEntity) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
updateDeviceEntity(updatedItem);
|
||||||
updateDeviceEntity(updatedItem);
|
};
|
||||||
},
|
|
||||||
[updateDeviceEntity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDeviceEntity = useCallback((de: DeviceEntity) => {
|
const editDeviceEntity = (de: DeviceEntity) => {
|
||||||
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
if (de.n === undefined || (de.n && de.n[0] === '!')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -406,9 +390,9 @@ const Customizations = () => {
|
|||||||
|
|
||||||
setSelectedDeviceEntity(de);
|
setSelectedDeviceEntity(de);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const saveCustomization = useCallback(async () => {
|
const saveCustomization = async () => {
|
||||||
if (!devices || !deviceEntities || selectedDevice === -1) {
|
if (!devices || !deviceEntities || selectedDevice === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -441,9 +425,9 @@ const Customizations = () => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setOriginalSettings(deviceEntities);
|
setOriginalSettings(deviceEntities);
|
||||||
});
|
});
|
||||||
}, [devices, deviceEntities, selectedDevice, sendCustomizationEntities, LL]);
|
};
|
||||||
|
|
||||||
const renameDevice = useCallback(async () => {
|
const renameDevice = async () => {
|
||||||
await sendDeviceName({
|
await sendDeviceName({
|
||||||
id: selectedDevice,
|
id: selectedDevice,
|
||||||
name: selectedDeviceName,
|
name: selectedDeviceName,
|
||||||
@@ -459,14 +443,7 @@ const Customizations = () => {
|
|||||||
setRename(false);
|
setRename(false);
|
||||||
await fetchCoreData();
|
await fetchCoreData();
|
||||||
});
|
});
|
||||||
}, [
|
};
|
||||||
selectedDevice,
|
|
||||||
selectedDeviceName,
|
|
||||||
selectedDeviceBrand,
|
|
||||||
sendDeviceName,
|
|
||||||
LL,
|
|
||||||
fetchCoreData
|
|
||||||
]);
|
|
||||||
|
|
||||||
const renderDeviceList = () => (
|
const renderDeviceList = () => (
|
||||||
<>
|
<>
|
||||||
@@ -512,7 +489,8 @@ 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>
|
||||||
@@ -562,10 +540,7 @@ const Customizations = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredEntities = useMemo(
|
const filteredEntities = deviceEntities.filter((de) => filter_entity(de));
|
||||||
() => deviceEntities.filter((de) => filter_entity(de)),
|
|
||||||
[deviceEntities, filter_entity]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderDeviceData = () => {
|
const renderDeviceData = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
import { memo, useEffect, 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,23 +57,16 @@ 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 = useMemo(
|
const updateFormValue = updateValue(
|
||||||
() =>
|
setEditItem as unknown as React.Dispatch<
|
||||||
updateValue(
|
React.SetStateAction<Record<string, unknown>>
|
||||||
setEditItem as unknown as React.Dispatch<
|
>
|
||||||
React.SetStateAction<Record<string, unknown>>
|
|
||||||
>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isWriteableNumber = useMemo(
|
const isWriteableNumber =
|
||||||
() =>
|
typeof editItem.v === 'number' &&
|
||||||
typeof editItem.v === 'number' &&
|
editItem.w &&
|
||||||
editItem.w &&
|
!(editItem.m & DeviceEntityMask.DV_READONLY);
|
||||||
!(editItem.m & DeviceEntityMask.DV_READONLY),
|
|
||||||
[editItem.v, editItem.w, editItem.m]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -82,16 +75,16 @@ const CustomizationsDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(() => {
|
const save = () => {
|
||||||
if (
|
if (
|
||||||
isWriteableNumber &&
|
isWriteableNumber &&
|
||||||
editItem.mi &&
|
editItem.mi &&
|
||||||
@@ -102,34 +95,31 @@ const CustomizationsDialog = ({
|
|||||||
} else {
|
} else {
|
||||||
onSave(editItem);
|
onSave(editItem);
|
||||||
}
|
}
|
||||||
}, [isWriteableNumber, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const updateDeviceEntity = useCallback((updatedItem: DeviceEntity) => {
|
const updateDeviceEntity = (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>{dialogTitle}</DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${LL.ENTITY()}`}</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 label={LL.WRITEABLE()} value={writeableIcon} />
|
<LabelValue
|
||||||
|
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, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { memo, useContext, useEffect, 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,6 +29,7 @@ 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';
|
||||||
@@ -75,42 +76,38 @@ 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 = useCallback(
|
const deviceValueDialogSave = async (devicevalue: DeviceValue) => {
|
||||||
async (devicevalue: DeviceValue) => {
|
if (!selectedDashboardItem) {
|
||||||
if (!selectedDashboardItem) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
// skip if we're executing an immediate schedule as this is handled in DevicesDialog::doAction()
|
||||||
|
if (devicevalue.v !== undefined) {
|
||||||
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
const id = selectedDashboardItem.parentNode.id; // this is the parent ID
|
||||||
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v })
|
await sendDeviceValue({ id, c: devicevalue.c ?? '', v: devicevalue.v });
|
||||||
.then(() => {
|
}
|
||||||
toast.success(LL.WRITE_CMD_SENT());
|
setDeviceValueDialogOpen(false);
|
||||||
})
|
setSelectedDashboardItem(undefined);
|
||||||
.catch((error: Error) => {
|
};
|
||||||
toast.error(error.message);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setDeviceValueDialogOpen(false);
|
|
||||||
setSelectedDashboardItem(undefined);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[selectedDashboardItem, sendDeviceValue, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dashboard_theme = useMemo(
|
const dashboard_theme = useTheme({
|
||||||
() =>
|
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 {
|
||||||
@@ -120,7 +117,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;
|
||||||
}
|
}
|
||||||
@@ -128,9 +125,7 @@ const Dashboard = memo(() => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const tree = useTree(
|
const tree = useTree(
|
||||||
{ nodes: [...data.nodes] },
|
{ nodes: [...data.nodes] },
|
||||||
@@ -164,79 +159,71 @@ 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 = useCallback(
|
const showType = (n?: string, t?: number) => {
|
||||||
(n?: string, t?: number) => {
|
// if we have a name show it
|
||||||
// if we have a name show it
|
if (n) {
|
||||||
if (n) {
|
return n;
|
||||||
return n;
|
}
|
||||||
|
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) {
|
}
|
||||||
// otherwise pick translation based on type
|
return '';
|
||||||
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.SCHEDULER:
|
|
||||||
return LL.SCHEDULER();
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showName = useCallback(
|
const showName = (di: DashboardItem) => {
|
||||||
(di: DashboardItem) => {
|
if (di.id < 100) {
|
||||||
if (di.id < 100) {
|
// if its a device (parent node) and has entities
|
||||||
// if its a device (parent node) and has entities
|
if (di.nodes?.length) {
|
||||||
if (di.nodes?.length) {
|
return (
|
||||||
return (
|
<span style={{ fontSize: '15px' }}>
|
||||||
<span style={{ fontSize: '15px' }}>
|
<DeviceIcon type_id={di.t ?? 0} />
|
||||||
<DeviceIcon type_id={di.t ?? 0} />
|
{showType(di.n, di.t)}
|
||||||
{showType(di.n, di.t)}
|
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
||||||
<span style={{ color: 'lightblue' }}> ({di.nodes?.length})</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (di.dv) {
|
}
|
||||||
return <span>{di.dv.id.slice(2)}</span>;
|
if (di.dv) {
|
||||||
}
|
return <span>{di.dv.id.slice(2)}</span>;
|
||||||
return null;
|
}
|
||||||
},
|
return null;
|
||||||
[showType]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const hasMask = useCallback(
|
const hasMask = (id: string, mask: number) =>
|
||||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editDashboardValue = useCallback(
|
const editDashboardValue = (di: DashboardItem) => {
|
||||||
(di: DashboardItem) => {
|
// don't execute on parent nodes
|
||||||
if (me.admin && di.dv?.c) {
|
if (!me.admin || di.id <= 99) {
|
||||||
setSelectedDashboardItem(di);
|
return;
|
||||||
setDeviceValueDialogOpen(true);
|
}
|
||||||
}
|
|
||||||
},
|
if (di.dv?.c) {
|
||||||
[me.admin]
|
setSelectedDashboardItem(di);
|
||||||
);
|
setDeviceValueDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleShowAll = (
|
const handleShowAll = (
|
||||||
_event: React.MouseEvent<HTMLElement>,
|
_event: React.MouseEvent<HTMLElement>,
|
||||||
@@ -248,10 +235,9 @@ const Dashboard = memo(() => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFavEntities = useMemo(
|
const hasFavEntities = data.nodes.filter(
|
||||||
() => data.nodes.filter((item: DashboardItem) => item.id <= 90).length,
|
(item: DashboardItem) => item.id <= 90
|
||||||
[data.nodes]
|
).length;
|
||||||
);
|
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -344,20 +330,34 @@ const Dashboard = memo(() => {
|
|||||||
<Cell>
|
<Cell>
|
||||||
{me.admin &&
|
{me.admin &&
|
||||||
di.dv?.c &&
|
di.dv?.c &&
|
||||||
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) && (
|
!hasMask(di.dv.id, DeviceEntityMask.DV_READONLY) &&
|
||||||
|
(di.dv.v === '' || di.dv.v === undefined ? (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={LL.RUN_COMMAND()}
|
||||||
|
onClick={() => editDashboardValue(di)}
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<PlayArrowIcon
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
) : (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
aria-label={
|
aria-label={
|
||||||
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
LL.CHANGE_VALUE() + ' ' + LL.VALUE(0)
|
||||||
}
|
}
|
||||||
onClick={() => editDashboardValue(di)}
|
onClick={() => editDashboardValue(di)}
|
||||||
|
style={{ backgroundColor: 'transparent' }}
|
||||||
>
|
>
|
||||||
<EditIcon
|
<EditIcon
|
||||||
color="primary"
|
color="primary"
|
||||||
sx={{ fontSize: 16 }}
|
sx={{ fontSize: 16 }}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
))}
|
||||||
</Cell>
|
</Cell>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ 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
|
||||||
|
|||||||
+112
-122
@@ -4,12 +4,10 @@ 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';
|
||||||
@@ -65,6 +63,7 @@ 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';
|
||||||
@@ -133,21 +132,19 @@ const Devices = memo(() => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const leftOffset = useCallback(() => {
|
const leftOffset = () => {
|
||||||
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 = useMemo(
|
const common_theme = useTheme({
|
||||||
() =>
|
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;
|
||||||
@@ -155,7 +152,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 {
|
||||||
@@ -165,88 +162,78 @@ const Devices = memo(() => {
|
|||||||
background-color: #177ac9;
|
background-color: #177ac9;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const device_theme = useMemo(
|
const device_theme = useTheme([
|
||||||
() =>
|
common_theme,
|
||||||
useTheme([
|
{
|
||||||
common_theme,
|
BaseRow: `
|
||||||
{
|
font-size: 15px;
|
||||||
BaseRow: `
|
.td {
|
||||||
font-size: 15px;
|
height: 28px;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
Table: `
|
||||||
.td {
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 130px;
|
||||||
height: 32px;
|
`,
|
||||||
}
|
HeaderRow: `
|
||||||
`,
|
.th {
|
||||||
BaseCell: `
|
padding: 8px;
|
||||||
&:nth-of-type(1) {
|
`,
|
||||||
border-left: 1px solid #177ac9;
|
Row: `
|
||||||
},
|
&: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 {
|
||||||
[common_theme]
|
height: 32px;
|
||||||
);
|
}
|
||||||
|
`,
|
||||||
|
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) {
|
||||||
@@ -345,10 +332,8 @@ const Devices = memo(() => {
|
|||||||
return sc;
|
return sc;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasMask = useCallback(
|
const hasMask = (id: string, mask: number) =>
|
||||||
(id: string, mask: number) => (parseInt(id.slice(0, 2), 16) & mask) === mask,
|
(parseInt(id.slice(0, 2), 16) & mask) === mask;
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDownloadCsv = () => {
|
const handleDownloadCsv = () => {
|
||||||
const deviceIndex = coreData.devices.findIndex(
|
const deviceIndex = coreData.devices.findIndex(
|
||||||
@@ -573,16 +558,26 @@ 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}>
|
<Row key={device.id} item={device} disabled={device.e === 0}>
|
||||||
<Cell>
|
<Cell>
|
||||||
<DeviceIcon type_id={device.t} />
|
<DeviceIcon type_id={device.t} />
|
||||||
|
|
||||||
{device.n}
|
<span style={{ color: device.e === 0 ? 'grey' : 'white' }}>
|
||||||
<span style={{ color: 'lightblue' }}>
|
{device.n}
|
||||||
({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>
|
||||||
@@ -607,41 +602,35 @@ const Devices = memo(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDeviceValue = useCallback((dv: DeviceValue) => {
|
const showDeviceValue = (dv: DeviceValue) => {
|
||||||
setSelectedDeviceValue(dv);
|
setSelectedDeviceValue(dv);
|
||||||
setDeviceValueDialogOpen(true);
|
setDeviceValueDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const renderNameCell = useCallback(
|
const renderNameCell = (dv: DeviceValue) => (
|
||||||
(dv: DeviceValue) => (
|
<>
|
||||||
<>
|
{dv.id.slice(2)}
|
||||||
{dv.id.slice(2)}
|
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_FAVORITE) && (
|
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<StarIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_READONLY) && (
|
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<EditOffOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
||||||
{hasMask(dv.id, DeviceEntityMask.DV_API_MQTT_EXCLUDE) && (
|
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
||||||
<CommentsDisabledOutlinedIcon color="primary" sx={{ fontSize: 12 }} />
|
)}
|
||||||
)}
|
</>
|
||||||
</>
|
|
||||||
),
|
|
||||||
[hasMask]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const shown_data = useMemo(() => {
|
const shown_data = onlyFav
|
||||||
if (onlyFav) {
|
? deviceData.nodes.filter(
|
||||||
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
|
||||||
@@ -798,6 +787,7 @@ 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,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,
|
||||||
@@ -18,13 +19,16 @@ import {
|
|||||||
Typography
|
Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import { callAction } from '@/api/app';
|
||||||
import { dialogStyle } from 'CustomTheme';
|
import { dialogStyle } from 'CustomTheme';
|
||||||
import type Schema from 'async-validator';
|
import { useRequest } from 'alova/client';
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import { ValidatedTextField } from 'components';
|
import { ValidatedTextField } from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValue } from 'utils';
|
import { numberValue, updateValue } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
import type Schema from 'validators/schema';
|
||||||
|
import type { ValidateFieldsError } from 'validators/schema';
|
||||||
|
|
||||||
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
import { DeviceValueUOM, DeviceValueUOM_s } from './types';
|
||||||
import type { DeviceValue } from './types';
|
import type { DeviceValue } from './types';
|
||||||
@@ -52,7 +56,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 = useMemo(() => updateValue(setEditItem), [setEditItem]);
|
const updateFormValue = updateValue(setEditItem);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -61,36 +65,48 @@ const DevicesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const { send: executeCommand } = useRequest(
|
||||||
|
(id: string) => callAction({ action: 'executeCommand', param: id }),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
.onSuccess(() => {
|
||||||
|
toast.success(LL.EXECUTE_COMMAND_SENT());
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const doAction = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
if (editItem.v === undefined && editItem.c !== undefined) {
|
||||||
|
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 = useCallback(
|
const setUom = (uom?: DeviceValueUOM) => {
|
||||||
(uom?: DeviceValueUOM) => {
|
if (uom === undefined) {
|
||||||
if (uom === undefined) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
switch (uom) {
|
||||||
switch (uom) {
|
case DeviceValueUOM.HOURS:
|
||||||
case DeviceValueUOM.HOURS:
|
return LL.HOURS();
|
||||||
return LL.HOURS();
|
case DeviceValueUOM.MINUTES:
|
||||||
case DeviceValueUOM.MINUTES:
|
return LL.MINUTES();
|
||||||
return LL.MINUTES();
|
case DeviceValueUOM.SECONDS:
|
||||||
case DeviceValueUOM.SECONDS:
|
return LL.SECONDS();
|
||||||
return LL.SECONDS();
|
default:
|
||||||
default:
|
return DeviceValueUOM_s[uom];
|
||||||
return DeviceValueUOM_s[uom];
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showHelperText = useCallback((dv: DeviceValue) => {
|
const showHelperText = (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) {
|
||||||
@@ -101,26 +117,21 @@ const DevicesDialog = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const isCommand = useMemo(
|
const isCommand =
|
||||||
() => selectedItem.v === '' && selectedItem.c,
|
(selectedItem.v === '' || selectedItem.v === undefined) &&
|
||||||
[selectedItem.v, selectedItem.c]
|
Boolean(selectedItem.c);
|
||||||
);
|
const isSchedulerImmediate = selectedItem.v === undefined;
|
||||||
|
const dialogTitle = isCommand
|
||||||
const dialogTitle = useMemo(() => {
|
? isSchedulerImmediate
|
||||||
if (isCommand) return LL.RUN_COMMAND();
|
? LL.EXECUTE() + ' ' + LL.SCHEDULE(0)
|
||||||
return writeable ? LL.CHANGE_VALUE() : LL.VALUE(0);
|
: LL.RUN_COMMAND()
|
||||||
}, [isCommand, writeable, LL]);
|
: writeable
|
||||||
|
? LL.CHANGE_VALUE()
|
||||||
const buttonLabel = useMemo(() => {
|
: LL.VALUE(0);
|
||||||
return isCommand ? LL.EXECUTE() : LL.UPDATE();
|
const buttonLabel = 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);
|
||||||
|
|
||||||
@@ -131,67 +142,69 @@ 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>
|
||||||
<Grid container>
|
{!isSchedulerImmediate && (
|
||||||
<Grid size={12}>
|
<Grid container>
|
||||||
{editItem.l ? (
|
<Grid size={12}>
|
||||||
<TextField
|
{editItem.l ? (
|
||||||
name="v"
|
<TextField
|
||||||
value={editItem.v}
|
name="v"
|
||||||
aria-label={valueLabel}
|
value={editItem.v}
|
||||||
disabled={!writeable}
|
aria-label={valueLabel}
|
||||||
sx={{ width: '30ch' }}
|
disabled={!writeable}
|
||||||
select
|
sx={{ width: '30ch' }}
|
||||||
onChange={updateFormValue}
|
select
|
||||||
>
|
onChange={updateFormValue}
|
||||||
{editItem.l.map((val) => (
|
>
|
||||||
<MenuItem value={val} key={val}>
|
{editItem.l.map((val) => (
|
||||||
{val}
|
<MenuItem value={val} key={val}>
|
||||||
</MenuItem>
|
{val}
|
||||||
))}
|
</MenuItem>
|
||||||
</TextField>
|
))}
|
||||||
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
</TextField>
|
||||||
<ValidatedTextField
|
) : editItem.s || editItem.u !== DeviceValueUOM.NONE ? (
|
||||||
fieldErrors={fieldErrors || {}}
|
<ValidatedTextField
|
||||||
name="v"
|
fieldErrors={fieldErrors || {}}
|
||||||
label={valueLabel}
|
name="v"
|
||||||
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
label={valueLabel}
|
||||||
autoFocus
|
value={numberValue(Math.round((editItem.v as number) * 10) / 10)}
|
||||||
disabled={!writeable}
|
autoFocus
|
||||||
type="number"
|
disabled={!writeable}
|
||||||
sx={{ width: '30ch' }}
|
type="number"
|
||||||
onChange={updateFormValue}
|
sx={{ width: '30ch' }}
|
||||||
slotProps={{
|
onChange={updateFormValue}
|
||||||
htmlInput: editItem.s
|
slotProps={{
|
||||||
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
htmlInput: editItem.s
|
||||||
: {},
|
? { min: editItem.m, max: editItem.x, step: editItem.s }
|
||||||
input: {
|
: {},
|
||||||
startAdornment: (
|
input: {
|
||||||
<InputAdornment position="start">
|
startAdornment: (
|
||||||
{setUom(editItem.u)}
|
<InputAdornment position="start">
|
||||||
</InputAdornment>
|
{setUom(editItem.u)}
|
||||||
)
|
</InputAdornment>
|
||||||
}
|
)
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
) : (
|
/>
|
||||||
<ValidatedTextField
|
) : (
|
||||||
fieldErrors={fieldErrors || {}}
|
<ValidatedTextField
|
||||||
name="v"
|
fieldErrors={fieldErrors || {}}
|
||||||
label={valueLabel}
|
name="v"
|
||||||
value={editItem.v}
|
label={valueLabel}
|
||||||
disabled={!writeable}
|
value={editItem.v}
|
||||||
sx={{ width: '30ch' }}
|
disabled={!writeable}
|
||||||
multiline={!editItem.u}
|
sx={{ width: '30ch' }}
|
||||||
onChange={updateFormValue}
|
multiline={!editItem.u}
|
||||||
/>
|
onChange={updateFormValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{writeable && helperText && (
|
||||||
|
<Grid>
|
||||||
|
<FormHelperText>{helperText}</FormHelperText>
|
||||||
|
</Grid>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
{writeable && helperText && (
|
)}
|
||||||
<Grid>
|
|
||||||
<FormHelperText>{helperText}</FormHelperText>
|
|
||||||
</Grid>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -213,10 +226,12 @@ const DevicesDialog = ({
|
|||||||
{LL.CANCEL()}
|
{LL.CANCEL()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
startIcon={<WarningIcon color="warning" />}
|
startIcon={
|
||||||
|
isCommand ? <PlayArrowIcon /> : <WarningIcon color="warning" />
|
||||||
|
}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={save}
|
onClick={doAction}
|
||||||
color="primary"
|
color={isCommand ? 'success' : 'primary'}
|
||||||
>
|
>
|
||||||
{buttonLabel}
|
{buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
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';
|
||||||
@@ -11,7 +9,6 @@ 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
|
||||||
@@ -20,123 +17,95 @@ const MASK_VALUES = [
|
|||||||
DeviceEntityMask.DV_DELETED // 128
|
DeviceEntityMask.DV_DELETED // 128
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const getMaskNumber = (newMask: string[]): number =>
|
||||||
* Converts an array of mask strings to a bitmask number
|
newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
||||||
*/
|
|
||||||
const getMaskNumber = (newMask: string[]): number => {
|
|
||||||
return newMask.reduce((mask, entry) => mask | Number(entry), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
const getMaskString = (mask: number): string[] =>
|
||||||
* Converts a bitmask number to an array of mask strings
|
MASK_VALUES.filter((value) => (mask & value) === value).map((value) =>
|
||||||
*/
|
|
||||||
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 = useCallback(
|
const handleChange = (_event: unknown, mask: string[]) => {
|
||||||
(_event: unknown, mask: string[]) => {
|
const newMask = getMaskNumber(mask);
|
||||||
// Convert selected masks to a number
|
const updatedDe = { ...de };
|
||||||
const newMask = getMaskNumber(mask);
|
|
||||||
const updatedDe = { ...de };
|
|
||||||
|
|
||||||
// Apply business logic for mask interactions
|
// If entity has no name and is set to readonly, also exclude from web
|
||||||
// If entity has no name and is set to readonly, also exclude from web
|
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
||||||
if (updatedDe.n === '' && hasMask(newMask, DeviceEntityMask.DV_READONLY)) {
|
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
||||||
updatedDe.m = newMask | DeviceEntityMask.DV_WEB_EXCLUDE;
|
} else {
|
||||||
} else {
|
updatedDe.m = newMask;
|
||||||
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={maskStringValue}
|
value={getMaskString(de.m)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
>
|
>
|
||||||
<ToggleButton value="8" disabled={isFavoriteDisabled}>
|
<ToggleButton
|
||||||
<OptionIcon type="favorite" isSet={isFavoriteSet} />
|
value="8"
|
||||||
|
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 value="4" disabled={isReadonlyDisabled}>
|
<ToggleButton
|
||||||
<OptionIcon type="readonly" isSet={isReadonlySet} />
|
value="4"
|
||||||
|
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 value="2" disabled={isApiMqttExcludeDisabled}>
|
<ToggleButton
|
||||||
<OptionIcon type="api_mqtt_exclude" isSet={isApiMqttExcludeSet} />
|
value="2"
|
||||||
|
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 value="1" disabled={isWebExcludeDisabled}>
|
<ToggleButton
|
||||||
<OptionIcon type="web_exclude" isSet={isWebExcludeSet} />
|
value="1"
|
||||||
|
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 type="deleted" isSet={isDeletedSet} />
|
<OptionIcon
|
||||||
|
type="deleted"
|
||||||
|
isSet={hasMask(de.m, DeviceEntityMask.DV_DELETED)}
|
||||||
|
/>
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
import { memo, useContext, 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';
|
||||||
@@ -25,6 +24,7 @@ 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,6 +60,8 @@ 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());
|
||||||
@@ -72,12 +74,7 @@ const HelpComponent = () => {
|
|||||||
});
|
});
|
||||||
const [imgError, setImgError] = useState<boolean>(false);
|
const [imgError, setImgError] = useState<boolean>(false);
|
||||||
|
|
||||||
const getCustomSupportMethod = useMemo(
|
useRequest(callAction({ action: 'getCustomSupport' })).onSuccess((event) => {
|
||||||
() => 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[] };
|
||||||
@@ -100,47 +97,26 @@ const HelpComponent = () => {
|
|||||||
toast.error(String(error.error?.message || 'An error occurred'));
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Optimize API call memoization
|
const helpLinks: HelpLink[] = [
|
||||||
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 handleDownloadSystemInfo = useCallback(() => {
|
const imageSrc =
|
||||||
void sendAPI(apiCall);
|
imgError || !customSupport.img_url ? DEFAULT_IMAGE_URL : customSupport.img_url;
|
||||||
}, [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>
|
||||||
@@ -157,13 +133,13 @@ const HelpComponent = () => {
|
|||||||
component="img"
|
component="img"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
sx={IMAGE_STYLES}
|
sx={IMAGE_STYLES}
|
||||||
onError={handleImageError}
|
onError={() => setImgError(true)}
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{me?.admin && (
|
||||||
<List>
|
<List>
|
||||||
{helpLinks.map(({ href, icon, label }) => (
|
{helpLinks.map(({ href, icon, label }) => (
|
||||||
<ListItem key={href}>
|
<ListItem key={href}>
|
||||||
@@ -191,7 +167,7 @@ const HelpComponent = () => {
|
|||||||
startIcon={<DownloadIcon />}
|
startIcon={<DownloadIcon />}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={handleDownloadSystemInfo}
|
onClick={() => void sendAPI(SYSTEM_INFO_API)}
|
||||||
>
|
>
|
||||||
{LL.SUPPORT_INFORMATION(0)}
|
{LL.SUPPORT_INFORMATION(0)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -214,7 +190,6 @@ 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,6 +1,5 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, 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';
|
||||||
@@ -25,6 +24,7 @@ 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,58 +69,53 @@ const Modules = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const modules_theme = useTheme(
|
const modules_theme = useTheme({
|
||||||
useMemo(
|
Table: `
|
||||||
() => ({
|
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
||||||
Table: `
|
`,
|
||||||
--data-table-library_grid-template-columns: 48px 180px 120px 100px repeat(1, minmax(160px, 1fr)) 180px;
|
BaseRow: `
|
||||||
`,
|
font-size: 14px;
|
||||||
BaseRow: `
|
.td {
|
||||||
font-size: 14px;
|
height: 32px;
|
||||||
.td {
|
}
|
||||||
height: 32px;
|
`,
|
||||||
}
|
BaseCell: `
|
||||||
`,
|
&:nth-of-type(1) {
|
||||||
BaseCell: `
|
text-align: center;
|
||||||
&:nth-of-type(1) {
|
}
|
||||||
text-align: center;
|
`,
|
||||||
}
|
HeaderRow: `
|
||||||
`,
|
text-transform: uppercase;
|
||||||
HeaderRow: `
|
background-color: black;
|
||||||
text-transform: uppercase;
|
color: #90CAF9;
|
||||||
background-color: black;
|
.th {
|
||||||
color: #90CAF9;
|
border-bottom: 1px solid #565656;
|
||||||
.th {
|
height: 36px;
|
||||||
border-bottom: 1px solid #565656;
|
}
|
||||||
height: 36px;
|
`,
|
||||||
}
|
Row: `
|
||||||
`,
|
background-color: #1e1e1e;
|
||||||
Row: `
|
position: relative;
|
||||||
background-color: #1e1e1e;
|
cursor: pointer;
|
||||||
position: relative;
|
.td {
|
||||||
cursor: pointer;
|
border-top: 1px solid #565656;
|
||||||
.td {
|
border-bottom: 1px solid #565656;
|
||||||
border-top: 1px solid #565656;
|
}
|
||||||
border-bottom: 1px solid #565656;
|
&:hover .td {
|
||||||
}
|
border-top: 1px solid #177ac9;
|
||||||
&:hover .td {
|
border-bottom: 1px solid #177ac9;
|
||||||
border-top: 1px solid #177ac9;
|
}
|
||||||
border-bottom: 1px solid #177ac9;
|
&:nth-of-type(odd) .td {
|
||||||
}
|
background-color: #303030;
|
||||||
&:nth-of-type(odd) .td {
|
}
|
||||||
background-color: #303030;
|
`
|
||||||
}
|
});
|
||||||
`
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const onDialogClose = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const updateModuleItem = useCallback((updatedItem: ModuleItem) => {
|
const updateModuleItem = (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
|
||||||
@@ -128,28 +123,25 @@ const Modules = () => {
|
|||||||
setNumChanges(new_data.filter(hasModulesChanged).length);
|
setNumChanges(new_data.filter(hasModulesChanged).length);
|
||||||
return new_data;
|
return new_data;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: ModuleItem) => {
|
||||||
(updatedItem: ModuleItem) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
updateModuleItem(updatedItem);
|
||||||
updateModuleItem(updatedItem);
|
};
|
||||||
},
|
|
||||||
[updateModuleItem]
|
|
||||||
);
|
|
||||||
|
|
||||||
const editModuleItem = useCallback((mi: ModuleItem) => {
|
const editModuleItem = (mi: ModuleItem) => {
|
||||||
setSelectedModuleItem(mi);
|
setSelectedModuleItem(mi);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onCancel = useCallback(async () => {
|
const onCancel = async () => {
|
||||||
await fetchModules().then(() => {
|
await fetchModules().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchModules]);
|
};
|
||||||
|
|
||||||
const saveModules = useCallback(async () => {
|
const saveModules = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
modules.map((condensed_mi: ModuleItem) =>
|
modules.map((condensed_mi: ModuleItem) =>
|
||||||
@@ -167,9 +159,9 @@ const Modules = () => {
|
|||||||
await fetchModules();
|
await fetchModules();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
}
|
}
|
||||||
}, [modules, updateModules, LL, fetchModules]);
|
};
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const renderContent = () => {
|
||||||
if (!modules) {
|
if (!modules) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchModules} errorMessage={error?.message || ''} />
|
||||||
@@ -262,22 +254,12 @@ 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}
|
||||||
{content}
|
{renderContent()}
|
||||||
{selectedModuleItem && (
|
{selectedModuleItem && (
|
||||||
<ModulesDialog
|
<ModulesDialog
|
||||||
open={dialogOpen}
|
open={dialogOpen}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,14 +37,10 @@ const ModulesDialog = ({
|
|||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
const [editItem, setEditItem] = useState<ModuleItem>(selectedItem);
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
const updateFormValue = updateValue(
|
||||||
() =>
|
setEditItem as unknown as React.Dispatch<
|
||||||
updateValue(
|
React.SetStateAction<Record<string, unknown>>
|
||||||
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
|
||||||
@@ -54,18 +50,13 @@ const ModulesDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = () => {
|
||||||
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>{dialogTitle}</DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${editItem.key}`}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Grid container>
|
<Grid container>
|
||||||
<BlockFormControlLabel
|
<BlockFormControlLabel
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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';
|
||||||
@@ -26,10 +25,11 @@ 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 { readSchedule, writeSchedule } from '../../api/app';
|
import { readCommands, 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,14 +54,13 @@ const DEFAULT_SCHEDULE_ITEM: Omit<ScheduleItem, 'id' | 'o_id'> = {
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
flags: FLAG_ALL_DAYS,
|
flags: FLAG_ALL_DAYS,
|
||||||
time: '',
|
time: '',
|
||||||
cmd: '',
|
cmd_name: '',
|
||||||
value: '',
|
|
||||||
name: ''
|
name: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleTheme = {
|
const scheduleTheme = {
|
||||||
Table: `
|
Table: `
|
||||||
--data-table-library_grid-template-columns: 36px 210px 100px 192px repeat(1, minmax(100px, 1fr)) 160px;
|
--data-table-library_grid-template-columns: 36px 220px repeat(1, minmax(20px, 1fr)) 192px 160px;
|
||||||
`,
|
`,
|
||||||
BaseRow: `
|
BaseRow: `
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -70,11 +69,8 @@ const scheduleTheme = {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
BaseCell: `
|
BaseCell: `
|
||||||
&:nth-of-type(2) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
&:nth-of-type(1) {
|
&:nth-of-type(1) {
|
||||||
text-align: center;
|
text-align: 8px;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
HeaderRow: `
|
HeaderRow: `
|
||||||
@@ -100,7 +96,6 @@ 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'
|
||||||
@@ -125,6 +120,11 @@ 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 = useCallback((si: ScheduleItem) => {
|
const hasScheduleChanged = (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,18 +140,15 @@ 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 !== si.o_cmd ||
|
si.cmd_name !== si.o_cmd_name
|
||||||
si.value !== si.o_value
|
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
if (numChanges === 0) {
|
if (numChanges === 0) {
|
||||||
void fetchSchedule();
|
void fetchSchedule();
|
||||||
}
|
}
|
||||||
}, [numChanges, fetchSchedule]);
|
}, INTERVAL_DELAY);
|
||||||
|
|
||||||
useInterval(intervalCallback, INTERVAL_DELAY);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const formatter = new Intl.DateTimeFormat(locale, {
|
const formatter = new Intl.DateTimeFormat(locale, {
|
||||||
@@ -169,7 +166,7 @@ const Scheduler = () => {
|
|||||||
|
|
||||||
const schedule_theme = useTheme(scheduleTheme);
|
const schedule_theme = useTheme(scheduleTheme);
|
||||||
|
|
||||||
const saveSchedule = useCallback(async () => {
|
const saveSchedule = async () => {
|
||||||
try {
|
try {
|
||||||
await updateSchedule({
|
await updateSchedule({
|
||||||
schedule: schedule
|
schedule: schedule
|
||||||
@@ -179,8 +176,7 @@ 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: condensed_si.cmd,
|
cmd_name: condensed_si.cmd_name,
|
||||||
value: condensed_si.value,
|
|
||||||
name: condensed_si.name
|
name: condensed_si.name
|
||||||
}))
|
}))
|
||||||
});
|
});
|
||||||
@@ -192,46 +188,43 @@ const Scheduler = () => {
|
|||||||
await fetchSchedule();
|
await fetchSchedule();
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
}
|
}
|
||||||
}, [LL, schedule, updateSchedule, fetchSchedule]);
|
};
|
||||||
|
|
||||||
const editScheduleItem = useCallback((si: ScheduleItem) => {
|
const editScheduleItem = (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 = useCallback(() => {
|
const onDialogClose = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onDialogCancel = useCallback(async () => {
|
const onDialogCancel = async () => {
|
||||||
await fetchSchedule().then(() => {
|
await fetchSchedule().then(() => {
|
||||||
setNumChanges(0);
|
setNumChanges(0);
|
||||||
});
|
});
|
||||||
}, [fetchSchedule]);
|
};
|
||||||
|
|
||||||
const onDialogSave = useCallback(
|
const onDialogSave = (updatedItem: ScheduleItem) => {
|
||||||
(updatedItem: ScheduleItem) => {
|
setDialogOpen(false);
|
||||||
setDialogOpen(false);
|
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
||||||
void updateState(readSchedule(), (data: ScheduleItem[]) => {
|
const new_data = creating
|
||||||
const new_data = creating
|
? [...data, updatedItem]
|
||||||
? [...data, updatedItem]
|
: data.map((si) =>
|
||||||
: data.map((si) =>
|
si.id === updatedItem.id ? { ...si, ...updatedItem } : 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 = useCallback(() => {
|
const addScheduleItem = () => {
|
||||||
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),
|
||||||
@@ -239,36 +232,29 @@ const Scheduler = () => {
|
|||||||
};
|
};
|
||||||
setSelectedScheduleItem(newItem);
|
setSelectedScheduleItem(newItem);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const filteredAndSortedSchedule = useMemo(
|
const filteredAndSortedSchedule = schedule
|
||||||
() =>
|
.filter((si: ScheduleItem) => !si.deleted)
|
||||||
schedule
|
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags);
|
||||||
.filter((si: ScheduleItem) => !si.deleted)
|
|
||||||
.sort((a: ScheduleItem, b: ScheduleItem) => a.flags - b.flags),
|
|
||||||
[schedule]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dayBox = useCallback(
|
const dayBox = (si: ScheduleItem, flag: number, isLast = false) => {
|
||||||
(si: ScheduleItem, flag: number) => {
|
const dayIndex = Math.log(flag) / LOG_2;
|
||||||
const dayIndex = Math.log(flag) / LOG_2;
|
const isActive = (si.flags & flag) === flag;
|
||||||
const isActive = (si.flags & flag) === flag;
|
|
||||||
|
|
||||||
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>
|
||||||
<Divider orientation="vertical" flexItem />
|
{!isLast && <Divider orientation="vertical" flexItem />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
};
|
||||||
[dow]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scheduleType = useCallback((si: ScheduleItem) => {
|
const scheduleType = (si: ScheduleItem) => {
|
||||||
const label = scheduleTypeLabels[si.flags];
|
const label = scheduleTypeLabels[si.flags];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -278,9 +264,9 @@ const Scheduler = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const renderSchedule = useCallback(() => {
|
const renderSchedule = () => {
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
return (
|
return (
|
||||||
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
<FormLoader onRetry={fetchSchedule} errorMessage={error?.message || ''} />
|
||||||
@@ -299,9 +285,10 @@ const Scheduler = () => {
|
|||||||
<HeaderRow>
|
<HeaderRow>
|
||||||
<HeaderCell />
|
<HeaderCell />
|
||||||
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
<HeaderCell stiff>{LL.SCHEDULE(0)}</HeaderCell>
|
||||||
<HeaderCell stiff>{LL.TIME(0)}/Cond.</HeaderCell>
|
<HeaderCell stiff>
|
||||||
|
{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>
|
||||||
@@ -311,12 +298,16 @@ const Scheduler = () => {
|
|||||||
<Cell stiff>
|
<Cell stiff>
|
||||||
<CircleIcon
|
<CircleIcon
|
||||||
color={si.active ? 'success' : 'error'}
|
color={si.active ? 'success' : 'error'}
|
||||||
sx={{ fontSize: ICON_SIZE, verticalAlign: 'middle' }}
|
sx={{
|
||||||
|
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)
|
||||||
) : (
|
) : (
|
||||||
@@ -327,14 +318,13 @@ 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)}
|
{dayBox(si, ScheduleFlag.SCHEDULE_SUN, true)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Cell>
|
</Cell>
|
||||||
<Cell>{si.time}</Cell>
|
<Cell>{si.time === '' ? LL.SCHEDULER_HELP_2() : si.time}</Cell>
|
||||||
<Cell>{si.cmd}</Cell>
|
<Cell>{si.cmd_name}</Cell>
|
||||||
<Cell>{si.value}</Cell>
|
|
||||||
<Cell>{si.name}</Cell>
|
<Cell>{si.name}</Cell>
|
||||||
</Row>
|
</Row>
|
||||||
))}
|
))}
|
||||||
@@ -343,17 +333,7 @@ const Scheduler = () => {
|
|||||||
)}
|
)}
|
||||||
</Table>
|
</Table>
|
||||||
);
|
);
|
||||||
}, [
|
};
|
||||||
schedule,
|
|
||||||
error,
|
|
||||||
fetchSchedule,
|
|
||||||
filteredAndSortedSchedule,
|
|
||||||
schedule_theme,
|
|
||||||
editScheduleItem,
|
|
||||||
LL,
|
|
||||||
dayBox,
|
|
||||||
scheduleType
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
@@ -372,6 +352,7 @@ 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 { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,6 +14,7 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Grid,
|
Grid,
|
||||||
|
MenuItem,
|
||||||
TextField,
|
TextField,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
@@ -21,12 +22,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';
|
||||||
@@ -60,6 +61,12 @@ 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;
|
||||||
@@ -68,6 +75,7 @@ interface SchedulerDialogProps {
|
|||||||
selectedItem: ScheduleItem;
|
selectedItem: ScheduleItem;
|
||||||
validator: Schema;
|
validator: Schema;
|
||||||
dow: string[];
|
dow: string[];
|
||||||
|
commandNames: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const SchedulerDialog = ({
|
const SchedulerDialog = ({
|
||||||
@@ -77,33 +85,24 @@ 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 = useMemo(
|
const updateFormValue = updateValue(
|
||||||
() =>
|
setEditItem as unknown as React.Dispatch<
|
||||||
updateValue(
|
React.SetStateAction<Record<string, unknown>>
|
||||||
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
|
||||||
@@ -112,129 +111,89 @@ const SchedulerDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
// Helper function to handle save operations
|
const handleSave = async (itemToSave: ScheduleItem) => {
|
||||||
const handleSave = useCallback(
|
try {
|
||||||
async (itemToSave: ScheduleItem) => {
|
setFieldErrors(undefined);
|
||||||
try {
|
await validate(validator, itemToSave);
|
||||||
setFieldErrors(undefined);
|
onSave(itemToSave);
|
||||||
await validate(validator, itemToSave);
|
} catch (error) {
|
||||||
onSave(itemToSave);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
} catch (error) {
|
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[validator, onSave]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
|
||||||
await handleSave(editItem);
|
|
||||||
}, [editItem, handleSave]);
|
|
||||||
|
|
||||||
const saveandactivate = useCallback(async () => {
|
|
||||||
await handleSave({ ...editItem, active: true });
|
|
||||||
}, [editItem, handleSave]);
|
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
|
||||||
onSave({ ...editItem, deleted: true });
|
|
||||||
}, [editItem, onSave]);
|
|
||||||
|
|
||||||
// Optimize DOW flag conversion
|
|
||||||
const getFlagDOWnumber = useCallback((flags: string[]) => {
|
|
||||||
return flags.reduce((acc, flag) => acc | Number(flag), 0) & FLAG_MASK_127;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getFlagDOWstring = useCallback((f: number) => {
|
|
||||||
return FLAG_VALUES.filter((flag) => (f & flag) === flag).map((flag) =>
|
|
||||||
String(flag)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Day of week display component
|
|
||||||
const DayOfWeekButton = useCallback(
|
|
||||||
(flag: number) => {
|
|
||||||
const dayIndex = Math.log2(flag);
|
|
||||||
const isSelected = (editItem.flags & flag) === flag;
|
|
||||||
return (
|
|
||||||
<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 timeFieldLabel = useMemo(() => {
|
const save = async () => {
|
||||||
|
await handleSave(editItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = () => {
|
||||||
|
onSave({ ...editItem, deleted: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const DayOfWeekButton = (flag: number) => {
|
||||||
|
const dayIndex = Math.log2(flag);
|
||||||
|
const isSelected = (editItem.flags & flag) === flag;
|
||||||
|
return (
|
||||||
|
<Typography
|
||||||
|
sx={{ fontSize: TYPOGRAPHY_FONT_SIZE }}
|
||||||
|
color={isSelected ? 'primary' : 'grey'}
|
||||||
|
>
|
||||||
|
{dow[dayIndex]}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = (
|
||||||
|
_event: React.SyntheticEvent,
|
||||||
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
|
) => {
|
||||||
|
if (reason !== 'backdropClick') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScheduleTypeChange = (
|
||||||
|
_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}>
|
||||||
@@ -287,14 +246,6 @@ 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 && (
|
||||||
@@ -312,78 +263,74 @@ const SchedulerDialog = ({
|
|||||||
</ToggleButtonGroup>
|
</ToggleButtonGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isImmediateSchedule && (
|
<Grid container>
|
||||||
<>
|
<BlockFormControlLabel
|
||||||
<Grid container>
|
control={
|
||||||
<BlockFormControlLabel
|
<Checkbox
|
||||||
control={
|
checked={editItem.active}
|
||||||
<Checkbox
|
onChange={updateFormValue}
|
||||||
checked={editItem.active}
|
name="active"
|
||||||
onChange={updateFormValue}
|
|
||||||
name="active"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label={LL.ACTIVE()}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
}
|
||||||
<Grid container>
|
label={LL.ACTIVE()}
|
||||||
{needsTimeField ? (
|
/>
|
||||||
<>
|
<CircleIcon
|
||||||
<TextField
|
color={editItem.active ? 'success' : 'error'}
|
||||||
name="time"
|
sx={{
|
||||||
type="time"
|
fontSize: 16,
|
||||||
label={timeFieldLabel}
|
mt: '12px'
|
||||||
value={timeFieldValue}
|
}}
|
||||||
margin="normal"
|
/>
|
||||||
onChange={updateFormValue}
|
</Grid>
|
||||||
/>
|
|
||||||
{isTimerSchedule && (
|
<Grid container>
|
||||||
<Typography
|
{needsTimeField ? (
|
||||||
sx={{ ml: 2, mt: 4 }}
|
<>
|
||||||
color="warning"
|
<TextField
|
||||||
variant="body2"
|
name="time"
|
||||||
>
|
type="time"
|
||||||
{LL.SCHEDULER_HELP_2()}
|
label={timeFieldLabel}
|
||||||
</Typography>
|
value={timeFieldValue}
|
||||||
)}
|
margin="normal"
|
||||||
</>
|
onChange={updateFormValue}
|
||||||
) : (
|
/>
|
||||||
<TextField
|
{isTimerSchedule && (
|
||||||
name="time"
|
<Typography sx={{ ml: 2, mt: 4 }} color="warning" variant="body2">
|
||||||
label={timeFieldLabel}
|
00:00 = {LL.SCHEDULER_HELP_2()}
|
||||||
multiline
|
</Typography>
|
||||||
fullWidth
|
|
||||||
value={timeFieldValue}
|
|
||||||
margin="normal"
|
|
||||||
onChange={updateFormValue}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</>
|
||||||
</>
|
) : (
|
||||||
)}
|
<TextField
|
||||||
<ValidatedTextField
|
name="time"
|
||||||
fieldErrors={fieldErrors || {}}
|
label={timeFieldLabel}
|
||||||
name="cmd"
|
multiline
|
||||||
label={LL.COMMAND(0)}
|
fullWidth
|
||||||
multiline
|
value={timeFieldValue}
|
||||||
fullWidth
|
margin="normal"
|
||||||
value={editItem.cmd}
|
onChange={updateFormValue}
|
||||||
margin="normal"
|
/>
|
||||||
onChange={updateFormValue}
|
)}
|
||||||
/>
|
</Grid>
|
||||||
<TextField
|
<TextField
|
||||||
name="value"
|
name="cmd_name"
|
||||||
label={LL.VALUE(0)}
|
label={LL.COMMAND(0)}
|
||||||
multiline
|
value={editItem.cmd_name}
|
||||||
margin="normal"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
value={editItem.value}
|
select
|
||||||
|
margin="normal"
|
||||||
onChange={updateFormValue}
|
onChange={updateFormValue}
|
||||||
/>
|
>
|
||||||
|
{commandNames.map((name) => (
|
||||||
|
<MenuItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="name"
|
name="name"
|
||||||
label={LL.NAME(0) + ' (' + LL.OPTIONAL() + ')'}
|
label={LL.NAME(0)}
|
||||||
value={editItem.name}
|
value={editItem.name}
|
||||||
fullWidth
|
fullWidth
|
||||||
margin="normal"
|
margin="normal"
|
||||||
@@ -420,16 +367,6 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
+246
-290
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useContext, useMemo, useRef, useState } from 'react';
|
import { useContext, 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';
|
||||||
@@ -21,6 +20,7 @@ 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,18 +158,16 @@ const Sensors = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const intervalCallback = useCallback(() => {
|
useInterval(() => {
|
||||||
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 = useCallback((state: State, sortKey: unknown) => {
|
const getSortIcon = (state: State, sortKey: unknown) => {
|
||||||
if (state.sortKey === sortKey && state.reverse) {
|
if (state.sortKey === sortKey && state.reverse) {
|
||||||
return <KeyboardArrowDownOutlinedIcon />;
|
return <KeyboardArrowDownOutlinedIcon />;
|
||||||
}
|
}
|
||||||
@@ -177,7 +175,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 },
|
||||||
@@ -234,119 +232,104 @@ const Sensors = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.SENSORS());
|
useLayoutTitle(LL.SENSORS());
|
||||||
|
|
||||||
const formatDurationMin = useCallback(
|
const formatDurationMin = (duration_min: number) => {
|
||||||
(duration_min: number) => {
|
const totalMs = duration_min * MS_PER_MINUTE;
|
||||||
const totalMs = duration_min * MS_PER_MINUTE;
|
const days = Math.trunc(totalMs / MS_PER_DAY);
|
||||||
const days = Math.trunc(totalMs / MS_PER_DAY);
|
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
||||||
const hours = Math.trunc(totalMs / MS_PER_HOUR) % 24;
|
const minutes = Math.trunc(totalMs / MS_PER_MINUTE) % 60;
|
||||||
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 = useCallback(
|
const formatValue = (value: unknown, uom: DeviceValueUOM) => {
|
||||||
(value: unknown, uom: DeviceValueUOM) => {
|
if (value === undefined) {
|
||||||
if (value === undefined) {
|
return '';
|
||||||
return '';
|
}
|
||||||
}
|
if (typeof value !== 'number') {
|
||||||
if (typeof value !== 'number') {
|
return value as string;
|
||||||
return value as string;
|
}
|
||||||
}
|
switch (uom) {
|
||||||
switch (uom) {
|
case DeviceValueUOM.HOURS:
|
||||||
case DeviceValueUOM.HOURS:
|
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
||||||
return value ? formatDurationMin(value * 60) : LL.NUM_HOURS({ num: 0 });
|
case DeviceValueUOM.MINUTES:
|
||||||
case DeviceValueUOM.MINUTES:
|
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
||||||
return value ? formatDurationMin(value) : LL.NUM_MINUTES({ num: 0 });
|
case DeviceValueUOM.SECONDS:
|
||||||
case DeviceValueUOM.SECONDS:
|
return LL.NUM_SECONDS({ num: value });
|
||||||
return LL.NUM_SECONDS({ num: value });
|
case DeviceValueUOM.NONE:
|
||||||
case DeviceValueUOM.NONE:
|
return new Intl.NumberFormat().format(value);
|
||||||
return new Intl.NumberFormat().format(value);
|
case DeviceValueUOM.DEGREES:
|
||||||
case DeviceValueUOM.DEGREES:
|
case DeviceValueUOM.DEGREES_R:
|
||||||
case DeviceValueUOM.DEGREES_R:
|
case DeviceValueUOM.FAHRENHEIT:
|
||||||
case DeviceValueUOM.FAHRENHEIT:
|
return (
|
||||||
return (
|
new Intl.NumberFormat(undefined, {
|
||||||
new Intl.NumberFormat(undefined, {
|
minimumFractionDigits: 1
|
||||||
minimumFractionDigits: 1
|
}).format(value) +
|
||||||
}).format(value) +
|
' ' +
|
||||||
' ' +
|
DeviceValueUOM_s[uom]
|
||||||
DeviceValueUOM_s[uom]
|
);
|
||||||
);
|
default:
|
||||||
default:
|
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
||||||
return new Intl.NumberFormat().format(value) + ' ' + DeviceValueUOM_s[uom];
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[formatDurationMin, LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTemperatureSensor = useCallback(
|
const updateTemperatureSensor = (ts: TemperatureSensor) => {
|
||||||
(ts: TemperatureSensor) => {
|
if (me.admin) {
|
||||||
if (me.admin) {
|
ts.o_n = ts.n;
|
||||||
ts.o_n = ts.n;
|
setSelectedTemperatureSensor(ts);
|
||||||
setSelectedTemperatureSensor(ts);
|
setTemperatureDialogOpen(true);
|
||||||
setTemperatureDialogOpen(true);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onTemperatureDialogClose = useCallback(() => {
|
const onTemperatureDialogClose = () => {
|
||||||
setTemperatureDialogOpen(false);
|
setTemperatureDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}, [fetchSensorData]);
|
};
|
||||||
|
|
||||||
const onTemperatureDialogSave = useCallback(
|
const onTemperatureDialogSave = async (ts: TemperatureSensor) => {
|
||||||
async (ts: TemperatureSensor) => {
|
await sendTemperatureSensor({
|
||||||
await sendTemperatureSensor({
|
id: ts.id,
|
||||||
id: ts.id,
|
name: ts.n,
|
||||||
name: ts.n,
|
offset: ts.o,
|
||||||
offset: ts.o,
|
is_system: ts.s
|
||||||
is_system: ts.s
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.SENSOR(1)));
|
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.finally(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.SENSOR(2)) + ' ' + LL.FAILED(1));
|
setTemperatureDialogOpen(false);
|
||||||
})
|
setSelectedTemperatureSensor(undefined);
|
||||||
.finally(() => {
|
void fetchSensorData();
|
||||||
setTemperatureDialogOpen(false);
|
});
|
||||||
setSelectedTemperatureSensor(undefined);
|
};
|
||||||
void fetchSensorData();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sendTemperatureSensor, LL, fetchSensorData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateAnalogSensor = useCallback(
|
const updateAnalogSensor = (as: AnalogSensor) => {
|
||||||
(as: AnalogSensor) => {
|
if (me.admin) {
|
||||||
if (me.admin) {
|
setCreating(false);
|
||||||
setCreating(false);
|
as.o_n = as.n;
|
||||||
as.o_n = as.n;
|
setSelectedAnalogSensor(as);
|
||||||
setSelectedAnalogSensor(as);
|
setAnalogDialogOpen(true);
|
||||||
setAnalogDialogOpen(true);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[me.admin]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onAnalogDialogClose = useCallback(() => {
|
const onAnalogDialogClose = () => {
|
||||||
setAnalogDialogOpen(false);
|
setAnalogDialogOpen(false);
|
||||||
void fetchSensorData();
|
void fetchSensorData();
|
||||||
}, [fetchSensorData]);
|
};
|
||||||
|
|
||||||
const addAnalogSensor = useCallback(() => {
|
const addAnalogSensor = () => {
|
||||||
if (firstAvailableGPIO.current === undefined) {
|
if (firstAvailableGPIO.current === undefined) {
|
||||||
toast.error(LL.NO_GPIO());
|
toast.error(LL.NO_GPIO());
|
||||||
return;
|
return;
|
||||||
@@ -366,194 +349,167 @@ const Sensors = () => {
|
|||||||
o_n: ''
|
o_n: ''
|
||||||
});
|
});
|
||||||
setAnalogDialogOpen(true);
|
setAnalogDialogOpen(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onAnalogDialogSave = useCallback(
|
const onAnalogDialogSave = async (as: AnalogSensor) => {
|
||||||
async (as: AnalogSensor) => {
|
await sendAnalogSensor({
|
||||||
await sendAnalogSensor({
|
id: as.id,
|
||||||
id: as.id,
|
gpio: as.g,
|
||||||
gpio: as.g,
|
name: as.n,
|
||||||
name: as.n,
|
offset: as.o,
|
||||||
offset: as.o,
|
factor: as.f,
|
||||||
factor: as.f,
|
uom: as.u,
|
||||||
uom: as.u,
|
type: as.t,
|
||||||
type: as.t,
|
deleted: as.d,
|
||||||
deleted: as.d,
|
is_system: as.s
|
||||||
is_system: as.s
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
||||||
})
|
})
|
||||||
.then(() => {
|
.catch(() => {
|
||||||
toast.success(LL.UPDATED_OF(LL.ANALOG_SENSOR(2)));
|
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.finally(() => {
|
||||||
toast.error(LL.UPDATE_OF(LL.ANALOG_SENSOR(5)) + ' ' + LL.FAILED(1));
|
setAnalogDialogOpen(false);
|
||||||
})
|
setSelectedAnalogSensor(undefined);
|
||||||
.finally(() => {
|
void fetchSensorData();
|
||||||
setAnalogDialogOpen(false);
|
});
|
||||||
setSelectedAnalogSensor(undefined);
|
};
|
||||||
void fetchSensorData();
|
|
||||||
});
|
const RenderAnalogSensors = (
|
||||||
},
|
<Table
|
||||||
[sendAnalogSensor, LL, fetchSensorData]
|
data={{ nodes: sensorData.as }}
|
||||||
|
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 RenderAnalogSensors = useMemo(
|
const RenderTemperatureSensors = (
|
||||||
() => (
|
<Table
|
||||||
<Table
|
data={{ nodes: sensorData.ts }}
|
||||||
data={{ nodes: sensorData.as }}
|
theme={temperature_theme}
|
||||||
theme={analog_theme}
|
sort={temperature_sort}
|
||||||
sort={analog_sort}
|
layout={{ custom: true }}
|
||||||
layout={{ custom: true }}
|
>
|
||||||
>
|
{(tableList: TemperatureSensor[]) => (
|
||||||
{(tableList: AnalogSensor[]) => (
|
<>
|
||||||
<>
|
<Header>
|
||||||
<Header>
|
<HeaderRow>
|
||||||
<HeaderRow>
|
<HeaderCell resize>
|
||||||
<HeaderCell stiff>
|
<Button
|
||||||
<Button
|
fullWidth
|
||||||
fullWidth
|
style={HEADER_BUTTON_STYLE}
|
||||||
style={HEADER_BUTTON_STYLE}
|
endIcon={getSortIcon(temperature_sort.state, 'NAME')}
|
||||||
endIcon={getSortIcon(analog_sort.state, 'GPIO')}
|
onClick={() =>
|
||||||
onClick={() => analog_sort.fns.onToggleSort({ sortKey: 'GPIO' })}
|
temperature_sort.fns.onToggleSort({ sortKey: 'NAME' })
|
||||||
>
|
}
|
||||||
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>
|
{LL.NAME(0)}
|
||||||
<Cell>{as.n}</Cell>
|
</Button>
|
||||||
<Cell stiff>{AnalogTypeNames[as.t - 1]} </Cell>
|
</HeaderCell>
|
||||||
{(as.t === AnalogType.DIGITAL_OUT &&
|
<HeaderCell stiff>
|
||||||
as.g !== GPIO_25 &&
|
<Button
|
||||||
as.g !== GPIO_26) ||
|
fullWidth
|
||||||
as.t === AnalogType.DIGITAL_IN ||
|
style={HEADER_BUTTON_STYLE_END}
|
||||||
as.t === AnalogType.PULSE ? (
|
endIcon={getSortIcon(temperature_sort.state, 'VALUE')}
|
||||||
<Cell stiff>{as.v ? LL.ON() : LL.OFF()}</Cell>
|
onClick={() =>
|
||||||
) : (
|
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)}
|
|
||||||
>
|
>
|
||||||
<Cell>{ts.n}</Cell>
|
{LL.VALUE(0)}
|
||||||
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
</Button>
|
||||||
</Row>
|
</HeaderCell>
|
||||||
))}
|
</HeaderRow>
|
||||||
</Body>
|
</Header>
|
||||||
</>
|
<Body>
|
||||||
)}
|
{tableList.map((ts: TemperatureSensor) => (
|
||||||
</Table>
|
<Row
|
||||||
),
|
style={{ color: ts.s ? 'grey' : 'inherit' }}
|
||||||
[
|
key={ts.id}
|
||||||
temperature_sort,
|
item={ts}
|
||||||
temperature_theme,
|
onClick={() => updateTemperatureSensor(ts)}
|
||||||
getSortIcon,
|
>
|
||||||
sensorData.ts,
|
<Cell>{ts.n}</Cell>
|
||||||
LL,
|
<Cell>{formatValue(ts.t, ts.u)}</Cell>
|
||||||
updateTemperatureSensor,
|
</Row>
|
||||||
formatValue
|
))}
|
||||||
]
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Table>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,84 +53,54 @@ 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 = useMemo(
|
const updateFormValue = updateValue((updater) =>
|
||||||
() =>
|
setEditItem(
|
||||||
updateValue((updater) =>
|
(prev) =>
|
||||||
setEditItem(
|
updater(
|
||||||
(prev) =>
|
prev as unknown as Record<string, unknown>
|
||||||
updater(
|
) as unknown as AnalogSensor
|
||||||
prev as unknown as Record<string, unknown>
|
)
|
||||||
) as unknown as AnalogSensor
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[setEditItem]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize helper functions to check sensor type conditions
|
const isCounterOrRate =
|
||||||
const isCounterOrRate = useMemo(
|
editItem.t === AnalogType.COUNTER ||
|
||||||
() =>
|
editItem.t === AnalogType.RATE ||
|
||||||
editItem.t === AnalogType.COUNTER ||
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||||
editItem.t === AnalogType.RATE ||
|
const isCounter =
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
editItem.t === AnalogType.COUNTER ||
|
||||||
[editItem.t]
|
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2);
|
||||||
);
|
const isFreqType =
|
||||||
const isCounter = useMemo(
|
editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2;
|
||||||
() =>
|
const isPWM =
|
||||||
editItem.t === AnalogType.COUNTER ||
|
editItem.t === AnalogType.PWM_0 ||
|
||||||
(editItem.t >= AnalogType.CNT_0 && editItem.t <= AnalogType.CNT_2),
|
editItem.t === AnalogType.PWM_1 ||
|
||||||
[editItem.t]
|
editItem.t === AnalogType.PWM_2;
|
||||||
);
|
const isDACOutGPIO =
|
||||||
const isFreqType = useMemo(
|
editItem.t === AnalogType.DIGITAL_OUT &&
|
||||||
() => editItem.t >= AnalogType.FREQ_0 && editItem.t <= AnalogType.FREQ_2,
|
(editItem.g === 25 || editItem.g === 26);
|
||||||
[editItem.t]
|
const isDigitalOutGPIO =
|
||||||
);
|
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]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize menu items to avoid recreation on each render
|
const analogTypeMenuItems = AnalogTypeNames.map((val, i) => ({
|
||||||
const analogTypeMenuItems = useMemo(
|
name: val,
|
||||||
() =>
|
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 = useMemo(
|
const uomMenuItems = DeviceValueUOM_s.map((val, i) => (
|
||||||
() =>
|
<MenuItem key={val} value={i}>
|
||||||
DeviceValueUOM_s.map((val, i) => (
|
{val}
|
||||||
<MenuItem key={val} value={i}>
|
</MenuItem>
|
||||||
{val}
|
));
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const analogGPIOMenuItems = () =>
|
const analogGPIOMenuItems = () =>
|
||||||
// add selectedItem.g to the list
|
// add selectedItem.g to the list
|
||||||
@@ -157,16 +127,16 @@ const SensorsAnalogDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (
|
||||||
(_event: React.SyntheticEvent, reason: 'backdropClick' | 'escapeKeyDown') => {
|
_event: React.SyntheticEvent,
|
||||||
if (reason !== 'backdropClick') {
|
reason: 'backdropClick' | 'escapeKeyDown'
|
||||||
onClose();
|
) => {
|
||||||
}
|
if (reason !== 'backdropClick') {
|
||||||
},
|
onClose();
|
||||||
[onClose]
|
}
|
||||||
);
|
};
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -174,17 +144,13 @@ const SensorsAnalogDialog = ({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}, [validator, editItem, onSave]);
|
};
|
||||||
|
|
||||||
const remove = useCallback(() => {
|
const remove = () => {
|
||||||
onSave({ ...editItem, d: true });
|
onSave({ ...editItem, d: true });
|
||||||
}, [editItem, onSave]);
|
};
|
||||||
|
|
||||||
const dialogTitle = useMemo(
|
const dialogTitle = `${creating ? LL.ADD(1) + ' ' + LL.NEW(0) : LL.EDIT()} ${LL.ANALOG_SENSOR(0)}`;
|
||||||
() =>
|
|
||||||
`${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 { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, 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,16 +50,12 @@ 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 = useMemo(
|
const updateFormValue = updateValue(
|
||||||
() =>
|
setEditItem as unknown as (
|
||||||
updateValue(
|
updater: (
|
||||||
setEditItem as unknown as (
|
prevState: Readonly<Record<string, unknown>>
|
||||||
updater: (
|
) => Record<string, unknown>
|
||||||
prevState: Readonly<Record<string, unknown>>
|
) => void
|
||||||
) => Record<string, unknown>
|
|
||||||
) => void
|
|
||||||
),
|
|
||||||
[setEditItem]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -69,16 +65,13 @@ const SensorsTemperatureDialog = ({
|
|||||||
}
|
}
|
||||||
}, [open, selectedItem]);
|
}, [open, selectedItem]);
|
||||||
|
|
||||||
const handleClose = useCallback(
|
const handleClose = (_event: React.SyntheticEvent, reason?: string) => {
|
||||||
(_event: React.SyntheticEvent, reason?: string) => {
|
if (reason !== 'backdropClick') {
|
||||||
if (reason !== 'backdropClick') {
|
onClose();
|
||||||
onClose();
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[onClose]
|
|
||||||
);
|
|
||||||
|
|
||||||
const save = useCallback(async () => {
|
const save = async () => {
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
await validate(validator, editItem);
|
await validate(validator, editItem);
|
||||||
@@ -86,29 +79,11 @@ 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>{dialogTitle}</DialogTitle>
|
<DialogTitle>{`${LL.EDIT()} ${LL.TEMP_SENSOR()}`}</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}
|
||||||
@@ -128,12 +103,23 @@ const SensorsTemperatureDialog = ({
|
|||||||
<TextField
|
<TextField
|
||||||
name="o"
|
name="o"
|
||||||
label={LL.OFFSET()}
|
label={LL.OFFSET()}
|
||||||
value={offsetValue}
|
value={numberValue(editItem.o)}
|
||||||
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, useCallback, useContext } from 'react';
|
import { memo, 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 = useCallback(() => {
|
const handleSignOut = () => {
|
||||||
signOut(true);
|
signOut(true);
|
||||||
}, [signOut]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionContent>
|
<SectionContent>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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;
|
||||||
@@ -43,7 +44,17 @@ 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 {
|
||||||
@@ -339,21 +350,19 @@ export enum DeviceEntityMask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleItem {
|
export interface ScheduleItem {
|
||||||
id: number; // unique index
|
id: number; // unique index for table
|
||||||
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: string;
|
cmd_name: string; // references a named Command
|
||||||
value: string;
|
name: 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?: string;
|
o_cmd_name?: string;
|
||||||
o_value?: string;
|
|
||||||
o_name?: string;
|
o_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +370,23 @@ 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;
|
||||||
@@ -391,8 +417,7 @@ 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 {
|
||||||
@@ -435,6 +460,7 @@ 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,9 +1,10 @@
|
|||||||
import Schema from 'async-validator';
|
import Schema from 'validators/schema';
|
||||||
import type { InternalRuleItem } from 'async-validator';
|
import type { InternalRuleItem } from 'validators/schema';
|
||||||
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,
|
||||||
@@ -232,7 +233,29 @@ export const schedulerItemValidation = (
|
|||||||
scheduleItem: ScheduleItem
|
scheduleItem: ScheduleItem
|
||||||
) =>
|
) =>
|
||||||
new Schema({
|
new Schema({
|
||||||
name: [NAME_PATTERN, uniqueNameValidator(schedule, scheduleItem.o_name)],
|
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 { useCallback, useMemo, useState } from 'react';
|
import { 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,7 +6,6 @@ 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,
|
||||||
@@ -22,9 +21,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
|
||||||
@@ -63,22 +62,16 @@ const APSettings = () => {
|
|||||||
|
|
||||||
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
const updateFormValue = useMemo(
|
const updateFormValue = updateValueDirty(
|
||||||
() =>
|
origData as unknown as Record<string, unknown>,
|
||||||
updateValueDirty(
|
dirtyFlags,
|
||||||
origData as unknown as Record<string, unknown>,
|
setDirtyFlags,
|
||||||
dirtyFlags,
|
updateDataValue as (value: unknown) => void
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize AP enabled state
|
const apEnabled = data ? isAPEnabled(data) : false;
|
||||||
const apEnabled = useMemo(() => (data ? isAPEnabled(data) : false), [data]);
|
|
||||||
|
|
||||||
// Memoize validation and submit handler
|
const validateAndSubmit = async () => {
|
||||||
const validateAndSubmit = useCallback(async () => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -88,7 +81,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) {
|
||||||
@@ -108,9 +101,6 @@ 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,5 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -20,7 +19,6 @@ 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,
|
||||||
@@ -28,12 +26,15 @@ import {
|
|||||||
FormLoader,
|
FormLoader,
|
||||||
MessageBox,
|
MessageBox,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
|
ValidatedPasswordField,
|
||||||
ValidatedTextField,
|
ValidatedTextField,
|
||||||
useLayoutTitle
|
useLayoutTitle
|
||||||
} from 'components';
|
} from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import { numberValue, updateValueDirty, useRest } from 'utils';
|
import { numberValue, updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
|
import type { ValidateFieldsError } from 'validators/schema';
|
||||||
|
|
||||||
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
import { API, getBoardProfile, readSettings, writeSettings } from '../../api/app';
|
||||||
import { BOARD_PROFILES } from '../main/types';
|
import { BOARD_PROFILES } from '../main/types';
|
||||||
@@ -106,82 +107,79 @@ const ApplicationSettings = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memoized input props to prevent recreation on every render
|
const SecondsInputProps = {
|
||||||
const SecondsInputProps = useMemo(
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
() => ({
|
};
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const MinutesInputProps = useMemo(
|
const MinutesInputProps = {
|
||||||
() => ({
|
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
||||||
endAdornment: <InputAdornment position="end">{LL.MINUTES()}</InputAdornment>
|
};
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const HoursInputProps = useMemo(
|
const HoursInputProps = {
|
||||||
() => ({
|
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
||||||
endAdornment: <InputAdornment position="end">{LL.HOURS()}</InputAdornment>
|
};
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
const doRestart = 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 = useCallback(
|
const updateBoardProfile = async (board_profile: string) => {
|
||||||
async (board_profile: string) => {
|
await readBoardProfile(board_profile).catch((error: Error) => {
|
||||||
await readBoardProfile(board_profile).catch((error: Error) => {
|
toast.error(error.message);
|
||||||
toast.error(error.message);
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[readBoardProfile]
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutTitle(LL.APPLICATION());
|
useLayoutTitle(LL.APPLICATION());
|
||||||
|
|
||||||
const validateAndSubmit = useCallback(async () => {
|
const validateAndSubmit = 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 = useCallback(
|
const changeBoardProfile = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
const boardProfile = event.target.value;
|
||||||
const boardProfile = event.target.value;
|
updateFormValue(event);
|
||||||
updateFormValue(event);
|
if (boardProfile === 'CUSTOM') {
|
||||||
if (boardProfile === 'CUSTOM') {
|
updateDataValue({
|
||||||
updateDataValue({
|
...data,
|
||||||
...data,
|
board_profile: boardProfile
|
||||||
board_profile: boardProfile
|
});
|
||||||
});
|
} else {
|
||||||
} else {
|
void updateBoardProfile(boardProfile);
|
||||||
void updateBoardProfile(boardProfile);
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[data, updateBoardProfile, updateFormValue, updateDataValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const restart = useCallback(async () => {
|
const restart = async () => {
|
||||||
await validateAndSubmit();
|
await validateAndSubmit();
|
||||||
await doRestart();
|
await doRestart();
|
||||||
}, [validateAndSubmit, doRestart]);
|
};
|
||||||
|
|
||||||
// Memoize board profile select items to prevent recreation
|
const sendmail = async () => {
|
||||||
const boardProfileItems = useMemo(() => boardProfileSelectItems(), []);
|
await sendAPI({
|
||||||
|
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) {
|
||||||
@@ -190,7 +188,19 @@ const ApplicationSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography sx={{ pb: 1 }} variant="h6" color="primary">
|
<Typography 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>
|
||||||
@@ -351,6 +361,148 @@ 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>
|
||||||
@@ -751,6 +903,16 @@ 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,5 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -27,6 +26,7 @@ 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 = useCallback(async () => {
|
const doRestart = async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
try {
|
try {
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 });
|
||||||
@@ -65,16 +65,33 @@ 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 = useCallback(() => {
|
const handleCloseBackupDialog = () => {
|
||||||
setConfirmBackup(false);
|
setConfirmBackup(false);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const renderBackupDialog = useMemo(
|
const handleDownload = (type: string) => () => {
|
||||||
() => (
|
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}
|
||||||
@@ -98,40 +115,13 @@ 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,5 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -17,7 +16,6 @@ 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,
|
||||||
@@ -28,10 +26,12 @@ 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 = useCallback(() => {
|
const sendResetMQTT = () => {
|
||||||
void callAction({ action: 'resetMQTT' })
|
void callAction({ action: 'resetMQTT' })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
toast.success('MQTT ' + LL.REFRESH() + ' successful');
|
||||||
@@ -65,29 +65,20 @@ 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 = useMemo(
|
const updateFormValue = updateValueDirty(
|
||||||
() =>
|
origData as unknown as Record<string, unknown>,
|
||||||
updateValueDirty(
|
dirtyFlags,
|
||||||
origData as unknown as Record<string, unknown>,
|
setDirtyFlags,
|
||||||
dirtyFlags,
|
updateDataValue as (value: unknown) => void
|
||||||
setDirtyFlags,
|
|
||||||
updateDataValue as (value: unknown) => void
|
|
||||||
),
|
|
||||||
[origData, dirtyFlags, setDirtyFlags, updateDataValue]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const SecondsInputProps = useMemo(
|
const SecondsInputProps = {
|
||||||
() => ({
|
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
||||||
endAdornment: <InputAdornment position="end">{LL.SECONDS()}</InputAdornment>
|
};
|
||||||
}),
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const emptyFieldErrors = useMemo(() => ({}), []);
|
const validateAndSubmit = async () => {
|
||||||
|
|
||||||
const validateAndSubmit = useCallback(async () => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -96,25 +87,22 @@ const MqttSettings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}, [data, saveData]);
|
};
|
||||||
|
|
||||||
const publishIntervalFields = useMemo(
|
const publishIntervalFields = [
|
||||||
() => [
|
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
||||||
{ name: 'publish_time_heartbeat', label: 'Heartbeat', validated: true },
|
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
||||||
{ name: 'publish_time_boiler', label: LL.MQTT_INT_BOILER(), validated: false },
|
{
|
||||||
{
|
name: 'publish_time_thermostat',
|
||||||
name: 'publish_time_thermostat',
|
label: LL.MQTT_INT_THERMOSTATS(),
|
||||||
label: LL.MQTT_INT_THERMOSTATS(),
|
validated: false
|
||||||
validated: false
|
},
|
||||||
},
|
{ name: 'publish_time_solar', label: LL.MQTT_INT_SOLAR(), 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_mixer', label: LL.MQTT_INT_MIXER(), validated: false },
|
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
||||||
{ name: 'publish_time_water', label: LL.MQTT_INT_WATER(), validated: false },
|
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
||||||
{ name: 'publish_time_sensor', label: LL.SENSORS(), validated: false },
|
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
||||||
{ name: 'publish_time_other', label: LL.DEFAULT(0), validated: false }
|
];
|
||||||
],
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
@@ -154,7 +142,7 @@ const MqttSettings = () => {
|
|||||||
<Grid container spacing={2} rowSpacing={0}>
|
<Grid container spacing={2} rowSpacing={0}>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="host"
|
name="host"
|
||||||
label={LL.ADDRESS_OF(LL.BROKER())}
|
label={LL.ADDRESS_OF(LL.BROKER())}
|
||||||
multiline
|
multiline
|
||||||
@@ -166,7 +154,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="port"
|
name="port"
|
||||||
label="Port"
|
label="Port"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -178,7 +166,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="base"
|
name="base"
|
||||||
label={LL.BASE_TOPIC()}
|
label={LL.BASE_TOPIC()}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -219,7 +207,7 @@ const MqttSettings = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name="keep_alive"
|
name="keep_alive"
|
||||||
label="Keep Alive"
|
label="Keep Alive"
|
||||||
slotProps={{
|
slotProps={{
|
||||||
@@ -438,7 +426,7 @@ const MqttSettings = () => {
|
|||||||
<Grid key={field.name}>
|
<Grid key={field.name}>
|
||||||
{field.validated ? (
|
{field.validated ? (
|
||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors ?? emptyFieldErrors}
|
fieldErrors={fieldErrors ?? {}}
|
||||||
name={field.name}
|
name={field.name}
|
||||||
label={field.label}
|
label={field.label}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { 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';
|
||||||
@@ -24,7 +23,6 @@ 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,
|
||||||
@@ -34,11 +32,13 @@ 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,19 +67,16 @@ 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();
|
||||||
|
|
||||||
// Memoized selected timezone value
|
const selectedTzValue = data
|
||||||
const selectedTzValue = useMemo(
|
? selectedTimeZone(data.tz_label, data.tz_format)
|
||||||
() => (data ? selectedTimeZone(data.tz_label, data.tz_format) : undefined),
|
: undefined;
|
||||||
[data?.tz_label, data?.tz_format]
|
|
||||||
);
|
const selectedTzValueT = data
|
||||||
const selectedTzValueT = useMemo(
|
? selectedTimeZone(data.tz_label_t, data.tz_format_t)
|
||||||
() => (data ? selectedTimeZone(data.tz_label_t, data.tz_format_t) : undefined),
|
: 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);
|
||||||
@@ -92,32 +89,22 @@ const NTPSettings = () => {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize updateFormValue to prevent recreation on every render
|
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]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Memoize updateLocalTime handler
|
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
const updateLocalTime = useCallback(
|
setLocalTime(event.target.value);
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize openSetTime handler
|
const openSetTime = () => {
|
||||||
const openSetTime = useCallback(() => {
|
|
||||||
setLocalTime(formatLocalDateTime(new Date()));
|
setLocalTime(formatLocalDateTime(new Date()));
|
||||||
setSettingTime(true);
|
setSettingTime(true);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
// Memoize configureTime handler
|
const configureTime = async () => {
|
||||||
const configureTime = useCallback(async () => {
|
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -130,13 +117,11 @@ const NTPSettings = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}, [localTime, updateTime, LL, loadData]);
|
};
|
||||||
|
|
||||||
// Memoize close dialog handler
|
const handleCloseSetTime = () => setSettingTime(false);
|
||||||
const handleCloseSetTime = useCallback(() => setSettingTime(false), []);
|
|
||||||
|
|
||||||
// Memoize validate and submit handler
|
const validateAndSubmit = async () => {
|
||||||
const validateAndSubmit = useCallback(async () => {
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
try {
|
try {
|
||||||
setFieldErrors(undefined);
|
setFieldErrors(undefined);
|
||||||
@@ -145,35 +130,27 @@ const NTPSettings = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setFieldErrors((error as ValidationError).fieldErrors);
|
setFieldErrors((error as ValidationError).fieldErrors);
|
||||||
}
|
}
|
||||||
}, [data, saveData]);
|
};
|
||||||
|
|
||||||
// Memoize timezone change handler
|
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const changeTimeZone = useCallback(
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
...settings,
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
tz_label: event.target.value,
|
||||||
...settings,
|
tz_format: TIME_ZONES[event.target.value]
|
||||||
tz_label: event.target.value,
|
}));
|
||||||
tz_format: TIME_ZONES[event.target.value]
|
updateFormValue(event);
|
||||||
}));
|
};
|
||||||
updateFormValue(event);
|
|
||||||
},
|
|
||||||
[updateFormValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeTimeZoneT = useCallback(
|
const changeTimeZoneT = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
||||||
void updateState(readNTPSettings(), (settings: NTPSettingsType) => ({
|
...settings,
|
||||||
...settings,
|
tz_label_t: event.target.value,
|
||||||
tz_label_t: event.target.value,
|
tz_format_t: TIME_ZONES[event.target.value]
|
||||||
tz_format_t: TIME_ZONES[event.target.value]
|
}));
|
||||||
}));
|
updateFormValue(event);
|
||||||
updateFormValue(event);
|
};
|
||||||
},
|
|
||||||
[updateFormValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize render content to prevent unnecessary re-renders
|
const renderContent = () => {
|
||||||
const renderContent = useMemo(() => {
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
return <FormLoader onRetry={loadData} errorMessage={errorMessage || ''} />;
|
||||||
}
|
}
|
||||||
@@ -299,27 +276,12 @@ 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,190 +1,108 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
import CancelIcon from '@mui/icons-material/Cancel';
|
import BuildIcon from '@mui/icons-material/Build';
|
||||||
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 {
|
import { List } from '@mui/material';
|
||||||
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 [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
|
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||||
const [restarting, setRestarting] = useState<boolean>();
|
const firmwareText = versions?.current?.version
|
||||||
|
? `v${versions.current.version}${upgradeAvailable ? ` (${LL.UPDATE_AVAILABLE()})` : ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
return (
|
||||||
immediate: false
|
<SectionContent>
|
||||||
});
|
<List>
|
||||||
|
<ListMenuItem
|
||||||
|
icon={BuildIcon}
|
||||||
|
bgcolor="#72caf9"
|
||||||
|
label="EMS-ESP Firmware"
|
||||||
|
text={firmwareText}
|
||||||
|
to="/settings/version"
|
||||||
|
badge={upgradeAvailable}
|
||||||
|
/>
|
||||||
|
|
||||||
const doFormat = useCallback(async () => {
|
<ListMenuItem
|
||||||
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
icon={TuneIcon}
|
||||||
setRestarting(true);
|
bgcolor="#134ba2"
|
||||||
setConfirmFactoryReset(false);
|
label={LL.APPLICATION()}
|
||||||
});
|
text={LL.APPLICATION_SETTINGS_1()}
|
||||||
}, [sendAPI]);
|
to="application"
|
||||||
|
/>
|
||||||
|
|
||||||
const handleFactoryResetClose = useCallback(() => {
|
<ListMenuItem
|
||||||
setConfirmFactoryReset(false);
|
icon={SettingsEthernetIcon}
|
||||||
}, []);
|
bgcolor="#40828f"
|
||||||
|
label={LL.NETWORK(0)}
|
||||||
|
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
||||||
|
to="network"
|
||||||
|
/>
|
||||||
|
|
||||||
const handleFactoryResetClick = useCallback(() => {
|
<ListMenuItem
|
||||||
setConfirmFactoryReset(true);
|
icon={SettingsInputAntennaIcon}
|
||||||
}, []);
|
bgcolor="#5f9a5f"
|
||||||
|
label={LL.ACCESS_POINT(0)}
|
||||||
|
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
||||||
|
to="ap"
|
||||||
|
/>
|
||||||
|
|
||||||
const content = useMemo(() => {
|
<ListMenuItem
|
||||||
return (
|
icon={AccessTimeIcon}
|
||||||
<>
|
bgcolor="#c5572c"
|
||||||
<List>
|
label="NTP"
|
||||||
<ListMenuItem
|
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
||||||
icon={TuneIcon}
|
to="ntp"
|
||||||
bgcolor="#134ba2"
|
/>
|
||||||
label={LL.APPLICATION()}
|
|
||||||
text={LL.APPLICATION_SETTINGS_1()}
|
|
||||||
to="application"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={SettingsEthernetIcon}
|
icon={DeviceHubIcon}
|
||||||
bgcolor="#40828f"
|
bgcolor="#68374d"
|
||||||
label={LL.NETWORK(0)}
|
label="MQTT"
|
||||||
text={LL.CONFIGURE(LL.SETTINGS_OF(LL.NETWORK(1)))}
|
text={LL.CONFIGURE('MQTT')}
|
||||||
to="network"
|
to="mqtt"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={SettingsInputAntennaIcon}
|
icon={LockIcon}
|
||||||
bgcolor="#5f9a5f"
|
label={LL.SECURITY(0)}
|
||||||
label={LL.ACCESS_POINT(0)}
|
text={LL.SECURITY_1()}
|
||||||
text={LL.CONFIGURE(LL.ACCESS_POINT(1))}
|
to="security"
|
||||||
to="ap"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={AccessTimeIcon}
|
icon={ViewModuleIcon}
|
||||||
bgcolor="#c5572c"
|
bgcolor="#efc34b"
|
||||||
label="NTP"
|
label={LL.MODULES()}
|
||||||
text={LL.CONFIGURE(LL.LOCAL_TIME(1))}
|
text={LL.MODULES_1()}
|
||||||
to="ntp"
|
to="modules"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ListMenuItem
|
<ListMenuItem
|
||||||
icon={DeviceHubIcon}
|
icon={ImportExportIcon}
|
||||||
bgcolor="#68374d"
|
bgcolor="#5d89f7"
|
||||||
label="MQTT"
|
label={LL.DOWNLOAD_UPLOAD()}
|
||||||
text={LL.CONFIGURE('MQTT')}
|
text={LL.DOWNLOAD_UPLOAD_1()}
|
||||||
to="mqtt"
|
to="downloadUpload"
|
||||||
/>
|
/>
|
||||||
|
</List>
|
||||||
<ListMenuItem
|
</SectionContent>
|
||||||
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,5 +1,3 @@
|
|||||||
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> = {
|
||||||
@@ -472,26 +470,16 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,944 @@
|
|||||||
|
import { memo, useContext, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import CheckIcon from '@mui/icons-material/Done';
|
||||||
|
import DownloadIcon from '@mui/icons-material/GetApp';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
|
||||||
|
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
import * as SystemApi from 'api/system';
|
||||||
|
import { API, callAction } from 'api/app';
|
||||||
|
|
||||||
|
import { dialogStyle } from 'CustomTheme';
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
|
import type { APIcall } from 'app/main/types';
|
||||||
|
import SystemMonitor from 'app/status/SystemMonitor';
|
||||||
|
import {
|
||||||
|
FormLoader,
|
||||||
|
SectionContent,
|
||||||
|
SingleUpload,
|
||||||
|
useLayoutTitle
|
||||||
|
} from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
import type { TranslationFunctions } from 'i18n/i18n-types';
|
||||||
|
import type { VersionInfo } from 'types';
|
||||||
|
import { prettyDateTime } from 'utils/time';
|
||||||
|
|
||||||
|
// Constants moved outside component to avoid recreation
|
||||||
|
const STABLE_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/';
|
||||||
|
const STABLE_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md';
|
||||||
|
const DEV_URL = 'https://github.com/emsesp/EMS-ESP32/releases/download/latest/';
|
||||||
|
const DEV_RELNOTES_URL =
|
||||||
|
'https://github.com/emsesp/EMS-ESP32/blob/dev/CHANGELOG_LATEST.md';
|
||||||
|
|
||||||
|
// Types for better type safety
|
||||||
|
interface PartitionData {
|
||||||
|
partition: string;
|
||||||
|
version: string;
|
||||||
|
install_date?: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionData {
|
||||||
|
emsesp_version: string;
|
||||||
|
arduino_version: string;
|
||||||
|
esp_platform: string;
|
||||||
|
flash_chip_size: number;
|
||||||
|
psram: boolean;
|
||||||
|
build_flags?: string;
|
||||||
|
partition: string;
|
||||||
|
partitions: PartitionData[];
|
||||||
|
developer_mode: boolean;
|
||||||
|
disable_reset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized components for better performance
|
||||||
|
const VersionInfoDialog = memo(
|
||||||
|
({
|
||||||
|
showVersionInfo,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
partitionVersion,
|
||||||
|
partition,
|
||||||
|
currentPartition,
|
||||||
|
size,
|
||||||
|
locale,
|
||||||
|
LL,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
showVersionInfo: number;
|
||||||
|
latestVersion: VersionInfo | undefined;
|
||||||
|
latestDevVersion: VersionInfo | undefined;
|
||||||
|
partitionVersion: VersionInfo | undefined;
|
||||||
|
partition: string;
|
||||||
|
currentPartition: string;
|
||||||
|
size: number;
|
||||||
|
locale: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) => {
|
||||||
|
if (showVersionInfo === 0) return null;
|
||||||
|
|
||||||
|
const isStable = showVersionInfo === 1;
|
||||||
|
const isDev = showVersionInfo === 2;
|
||||||
|
const isPartition = showVersionInfo === 3;
|
||||||
|
|
||||||
|
const version = isStable
|
||||||
|
? latestVersion
|
||||||
|
: isDev
|
||||||
|
? latestDevVersion
|
||||||
|
: partitionVersion;
|
||||||
|
const relNotesUrl = isStable
|
||||||
|
? STABLE_RELNOTES_URL
|
||||||
|
: isDev
|
||||||
|
? DEV_RELNOTES_URL
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={showVersionInfo !== 0} onClose={onClose}>
|
||||||
|
<DialogTitle>{LL.FIRMWARE_VERSION_INFO()}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Table size="small" sx={{ borderCollapse: 'collapse', minWidth: 0 }}>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{isPartition
|
||||||
|
? typeof version === 'string'
|
||||||
|
? version
|
||||||
|
: version?.version
|
||||||
|
: version?.version}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13,
|
||||||
|
width: 140
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? LL.TYPE(0) : LL.RELEASE_TYPE()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition === currentPartition && LL.ACTIVE() + ' '}
|
||||||
|
{isStable
|
||||||
|
? LL.STABLE()
|
||||||
|
: isDev
|
||||||
|
? LL.DEVELOPMENT()
|
||||||
|
: 'Partition ' + LL.VERSION()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Partition
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{partition}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{isPartition && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Size
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{size} KB
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{version && version.date && (
|
||||||
|
<TableRow sx={{ height: 24, borderBottom: 'none' }}>
|
||||||
|
<TableCell
|
||||||
|
component="th"
|
||||||
|
scope="row"
|
||||||
|
sx={{
|
||||||
|
color: 'lightblue',
|
||||||
|
borderBottom: 'none',
|
||||||
|
pr: 1,
|
||||||
|
py: 0.5,
|
||||||
|
fontSize: 13
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPartition ? 'Install Date' : 'Build Date'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ borderBottom: 'none', py: 0.5, fontSize: 13 }}>
|
||||||
|
{prettyDateTime(locale, new Date(version.date))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{!isPartition && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={relNotesUrl}
|
||||||
|
target="_blank"
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
Changelog
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="outlined" onClick={onClose} color="secondary">
|
||||||
|
{LL.CLOSE()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallDialog,
|
||||||
|
fetchDevVersion,
|
||||||
|
latestVersion,
|
||||||
|
latestDevVersion,
|
||||||
|
upgradeImportantMessageType,
|
||||||
|
platform,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallDialog: boolean;
|
||||||
|
fetchDevVersion: boolean;
|
||||||
|
latestVersion: VersionInfo | undefined;
|
||||||
|
latestDevVersion: VersionInfo | undefined;
|
||||||
|
upgradeImportantMessageType: number;
|
||||||
|
platform: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (url: string) => void;
|
||||||
|
}) => {
|
||||||
|
const binURL = (() => {
|
||||||
|
if (!latestVersion || !latestDevVersion) return '';
|
||||||
|
const version = fetchDevVersion ? latestDevVersion : latestVersion;
|
||||||
|
const filename = `EMS-ESP-${version.version.replaceAll('.', '_')}-${platform}.bin`;
|
||||||
|
return fetchDevVersion
|
||||||
|
? `${DEV_URL}${filename}`
|
||||||
|
: `${STABLE_URL}v${version.version}/${filename}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{`${LL.INSTALL()} ${fetchDevVersion ? LL.DEVELOPMENT() : LL.STABLE()} Firmware`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(
|
||||||
|
LL.INSTALL(),
|
||||||
|
fetchDevVersion ? latestDevVersion?.version : latestVersion?.version
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{upgradeImportantMessageType === 2 && LL.UPGRADE_IMPORTANT_MESSAGES_2()}
|
||||||
|
{upgradeImportantMessageType === 1 && (
|
||||||
|
<>
|
||||||
|
{LL.UPGRADE_IMPORTANT_MESSAGES_1()}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link to="/settings/downloadUpload" style={{ color: 'lightblue' }}>
|
||||||
|
{LL.DOWNLOAD_SYSTEM_BACKUP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Typography sx={{ mt: 2 }}>
|
||||||
|
<Link
|
||||||
|
to="https://docs.emsesp.org/FAQ#upgrading-the-firmware"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue' }}
|
||||||
|
>
|
||||||
|
{LL.ONLINE_HELP()}
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<DownloadIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={binURL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: 'lightblue', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{LL.DOWNLOAD(0)}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(binURL)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const InstallPartitionDialog = memo(
|
||||||
|
({
|
||||||
|
openInstallPartitionDialog,
|
||||||
|
version,
|
||||||
|
partition,
|
||||||
|
LL,
|
||||||
|
onClose,
|
||||||
|
onInstall
|
||||||
|
}: {
|
||||||
|
openInstallPartitionDialog: boolean;
|
||||||
|
version: string;
|
||||||
|
partition: string;
|
||||||
|
LL: TranslationFunctions;
|
||||||
|
onClose: () => void;
|
||||||
|
onInstall: (partition: string) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Dialog sx={dialogStyle} open={openInstallPartitionDialog} onClose={onClose}>
|
||||||
|
<DialogTitle>
|
||||||
|
{LL.INSTALL()} {LL.STORED_VERSIONS()}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
<Typography sx={{ mb: 2 }}>
|
||||||
|
{LL.INSTALL_VERSION(LL.INSTALL(), version)}
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={onClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<WarningIcon color="warning" />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onInstall(partition)}
|
||||||
|
color="primary"
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function moved outside component
|
||||||
|
const getPlatform = (data: VersionData): string => {
|
||||||
|
return `${data.esp_platform}-${data.flash_chip_size >= 16384 ? '16MB' : '4MB'}${data.psram ? '+' : ''}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Version = () => {
|
||||||
|
const { LL, locale } = useI18nContext();
|
||||||
|
const { me, versions } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
|
const [restarting, setRestarting] = useState<boolean>(false);
|
||||||
|
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
|
||||||
|
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
||||||
|
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [partitionVersion, setPartitionVersion] = useState<VersionInfo | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
const [partition, setPartition] = useState<string>('');
|
||||||
|
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
|
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
|
||||||
|
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
|
||||||
|
const [firmwareSize, setFirmwareSize] = useState<number>(0);
|
||||||
|
|
||||||
|
const latestVersion = useMemo<VersionInfo | undefined>(
|
||||||
|
() =>
|
||||||
|
versions?.stable
|
||||||
|
? { version: versions.stable.version, date: versions.stable.date }
|
||||||
|
: undefined,
|
||||||
|
[versions?.stable]
|
||||||
|
);
|
||||||
|
const latestDevVersion = useMemo<VersionInfo | undefined>(
|
||||||
|
() =>
|
||||||
|
versions?.dev
|
||||||
|
? { version: versions.dev.version, date: versions.dev.date }
|
||||||
|
: undefined,
|
||||||
|
[versions?.dev]
|
||||||
|
);
|
||||||
|
const usingDevVersion = versions?.current?.type === 'dev';
|
||||||
|
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
|
||||||
|
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
|
||||||
|
const internetLive = Boolean(versions?.stable || versions?.dev);
|
||||||
|
|
||||||
|
const { send: sendSetPartition } = useRequest(
|
||||||
|
(partition: string) => callAction({ action: 'setPartition', param: partition }),
|
||||||
|
{ immediate: false }
|
||||||
|
).onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, send: loadData, error } = useRequest(SystemApi.readSystemStatus);
|
||||||
|
|
||||||
|
const { send: sendUploadURL } = useRequest(
|
||||||
|
(url: string) => callAction({ action: 'uploadURL', param: url }),
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
|
||||||
|
immediate: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [upgradeImportantMessageType, setUpgradeImportantMessageType] =
|
||||||
|
useState<number>(0);
|
||||||
|
|
||||||
|
const { send: checkUpgradeImportantMessages } = useRequest(
|
||||||
|
(version: string) =>
|
||||||
|
callAction({ action: 'upgradeImportantMessages', param: version }),
|
||||||
|
{
|
||||||
|
immediate: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onSuccess((event) => {
|
||||||
|
const upgradeImportantMessageType_n = (
|
||||||
|
event.data as { upgradeImportantMessageType: number }
|
||||||
|
).upgradeImportantMessageType;
|
||||||
|
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||||
|
})
|
||||||
|
.onError((error) => {
|
||||||
|
toast.error(String(error.error?.message || 'An error occurred'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const platform = data ? getPlatform(data) : '';
|
||||||
|
|
||||||
|
const otherPartitions =
|
||||||
|
data?.partitions.filter((p) => p.partition !== data.partition) ?? [];
|
||||||
|
|
||||||
|
const setPartitionVersionInfo = (partition: string) => {
|
||||||
|
setShowVersionInfo(3);
|
||||||
|
const partitionData = data?.partitions.find((p) => p.partition === partition);
|
||||||
|
if (partitionData) {
|
||||||
|
setPartitionVersion({
|
||||||
|
version: partitionData.version,
|
||||||
|
date: partitionData.install_date ?? ''
|
||||||
|
});
|
||||||
|
setPartition(partitionData.partition);
|
||||||
|
setFirmwareSize(partitionData.size);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRestart = async () => {
|
||||||
|
setConfirmRestart(false);
|
||||||
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
|
(error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setRestarting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doFormat = async () => {
|
||||||
|
await sendAPI({ device: 'system', cmd: 'format', id: 0 }).then(() => {
|
||||||
|
setRestarting(true);
|
||||||
|
setConfirmFactoryReset(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFactoryResetClose = () => setConfirmFactoryReset(false);
|
||||||
|
const handleFactoryResetClick = () => setConfirmFactoryReset(true);
|
||||||
|
const handleRestartClose = () => setConfirmRestart(false);
|
||||||
|
const handleRestartClick = () => setConfirmRestart(true);
|
||||||
|
|
||||||
|
const installFirmwareURL = async (url: string) => {
|
||||||
|
await sendUploadURL(url).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
await doRestart();
|
||||||
|
};
|
||||||
|
|
||||||
|
const installPartitionFirmware = async (partition: string) => {
|
||||||
|
await sendSetPartition(partition).catch((error: Error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
setRestarting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showPartitionDialog = (
|
||||||
|
version: string,
|
||||||
|
partition: string,
|
||||||
|
install_date: string
|
||||||
|
) => {
|
||||||
|
setOpenInstallPartitionDialog(true);
|
||||||
|
setPartitionVersion({ version: version, date: install_date });
|
||||||
|
setPartition(partition);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showFirmwareDialog = (useDevVersion: boolean) => {
|
||||||
|
setFetchDevVersion(useDevVersion);
|
||||||
|
const targetVersion = useDevVersion
|
||||||
|
? latestDevVersion?.version
|
||||||
|
: latestVersion?.version;
|
||||||
|
if (targetVersion) {
|
||||||
|
void checkUpgradeImportantMessages(targetVersion);
|
||||||
|
}
|
||||||
|
setOpenInstallDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInstallDialog = () => setOpenInstallDialog(false);
|
||||||
|
const closeInstallPartitionDialog = () => setOpenInstallPartitionDialog(false);
|
||||||
|
|
||||||
|
const handleVersionInfoClose = () => {
|
||||||
|
setShowVersionInfo(0);
|
||||||
|
setPartitionVersion(undefined);
|
||||||
|
setPartition('');
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutTitle('EMS-ESP Firmware');
|
||||||
|
|
||||||
|
const showButtons = (showingDev: boolean) => {
|
||||||
|
const choice = showingDev
|
||||||
|
? !usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.DEVELOPMENT())
|
||||||
|
: devUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined
|
||||||
|
: usingDevVersion
|
||||||
|
? LL.SWITCH_RELEASE_TYPE(LL.STABLE())
|
||||||
|
: stableUpgradeAvailable
|
||||||
|
? LL.UPDATE_AVAILABLE()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!choice) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{ verticalAlign: 'middle', ml: 0.5, mr: 0.5 }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#66bb6a', fontSize: '0.8em' }}>
|
||||||
|
{LL.LATEST_VERSION(usingDevVersion ? LL.DEVELOPMENT() : LL.STABLE())}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{LL.REINSTALL()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!me.admin) return null;
|
||||||
|
|
||||||
|
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 1 }}
|
||||||
|
variant="outlined"
|
||||||
|
color={isUpdateAvailable ? 'success' : 'warning'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => showFirmwareDialog(showingDev)}
|
||||||
|
>
|
||||||
|
{choice}
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-label="update available"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
ml: 1,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: '#ffeb3b',
|
||||||
|
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (restarting) {
|
||||||
|
return <SystemMonitor />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<Box sx={{ p: 2, border: '1px solid #565656', borderRadius: 2 }}>
|
||||||
|
<Typography sx={{ mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.THIS_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.VERSION()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{data.emsesp_version}
|
||||||
|
{data.build_flags && (
|
||||||
|
<Typography variant="caption">
|
||||||
|
({data.build_flags})
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setPartitionVersionInfo(data.partition)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.PLATFORM()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{platform}
|
||||||
|
<Typography variant="caption">
|
||||||
|
(
|
||||||
|
{data.psram ? (
|
||||||
|
<CheckIcon
|
||||||
|
color="success"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloseIcon
|
||||||
|
color="error"
|
||||||
|
sx={{
|
||||||
|
fontSize: '1.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
PSRAM)
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{internetLive ? (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ mt: 4, mb: 1 }} variant="h6" color="primary">
|
||||||
|
{LL.AVAILABLE_VERSION()}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
direction="row"
|
||||||
|
rowSpacing={1}
|
||||||
|
sx={{
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
alignItems: 'baseline'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{otherPartitions.length > 0 && data.developer_mode && (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.STORED_VERSIONS()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
{otherPartitions.map((partition) => (
|
||||||
|
<Typography key={partition.partition} sx={{ mb: 1 }}>
|
||||||
|
{partition.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() =>
|
||||||
|
setPartitionVersionInfo(partition.partition)
|
||||||
|
}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 0 }}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() =>
|
||||||
|
showPartitionDialog(
|
||||||
|
partition.version,
|
||||||
|
partition.partition,
|
||||||
|
partition.install_date ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{LL.INSTALL()}
|
||||||
|
</Button>
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.STABLE()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestVersion?.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(1)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(false)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs: 4, md: 2 }}>
|
||||||
|
<Typography color="secondary">{LL.DEVELOPMENT()}</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 8, md: 10 }}>
|
||||||
|
<Typography>
|
||||||
|
{latestDevVersion?.version}
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setShowVersionInfo(2)}
|
||||||
|
aria-label={LL.FIRMWARE_VERSION_INFO()}
|
||||||
|
>
|
||||||
|
<InfoOutlinedIcon color="primary" sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
{showButtons(true)}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ mt: 2 }} color="warning">
|
||||||
|
<WarningIcon color="warning" sx={{ verticalAlign: 'middle', mr: 2 }} />
|
||||||
|
{LL.INTERNET_CONNECTION_REQUIRED()}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<VersionInfoDialog
|
||||||
|
showVersionInfo={showVersionInfo}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
partitionVersion={partitionVersion}
|
||||||
|
locale={locale}
|
||||||
|
partition={partition}
|
||||||
|
currentPartition={data?.partition ?? ''}
|
||||||
|
size={firmwareSize}
|
||||||
|
LL={LL}
|
||||||
|
onClose={handleVersionInfoClose}
|
||||||
|
/>
|
||||||
|
<InstallDialog
|
||||||
|
openInstallDialog={openInstallDialog}
|
||||||
|
fetchDevVersion={fetchDevVersion}
|
||||||
|
latestVersion={latestVersion}
|
||||||
|
latestDevVersion={latestDevVersion}
|
||||||
|
upgradeImportantMessageType={upgradeImportantMessageType}
|
||||||
|
platform={platform}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallDialog}
|
||||||
|
onInstall={installFirmwareURL}
|
||||||
|
/>
|
||||||
|
<InstallPartitionDialog
|
||||||
|
openInstallPartitionDialog={openInstallPartitionDialog}
|
||||||
|
version={partitionVersion?.version || ''}
|
||||||
|
partition={partition}
|
||||||
|
LL={LL}
|
||||||
|
onClose={closeInstallPartitionDialog}
|
||||||
|
onInstall={installPartitionFirmware}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ pt: 2, pb: 2 }} variant="h6" color="primary">
|
||||||
|
{LL.UPLOAD()}
|
||||||
|
</Typography>
|
||||||
|
<SingleUpload doRestart={doRestart} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{me.admin && (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmFactoryReset}
|
||||||
|
onClose={handleFactoryResetClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.FACTORY_RESET()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.SYSTEM_FACTORY_TEXT_DIALOG()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doFormat}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
sx={dialogStyle}
|
||||||
|
open={confirmRestart}
|
||||||
|
onClose={handleRestartClose}
|
||||||
|
>
|
||||||
|
<DialogTitle>{LL.RESTART()}</DialogTitle>
|
||||||
|
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleRestartClose}
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={doRestart}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
gap: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
startIcon={<PowerSettingsNewIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleRestartClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.RESTART()}
|
||||||
|
</Button>
|
||||||
|
{!data.disable_reset && (
|
||||||
|
<Button
|
||||||
|
startIcon={<SettingsBackupRestoreIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
onClick={handleFactoryResetClick}
|
||||||
|
color="error"
|
||||||
|
>
|
||||||
|
{LL.FACTORY_RESET()}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default memo(Version);
|
||||||
@@ -1,12 +1,5 @@
|
|||||||
import { memo, useCallback, useMemo, useState } from 'react';
|
import { memo, useState } from 'react';
|
||||||
import {
|
import { Outlet, useMatch, useNavigate } from 'react-router';
|
||||||
Navigate,
|
|
||||||
Route,
|
|
||||||
Routes,
|
|
||||||
matchRoutes,
|
|
||||||
useLocation,
|
|
||||||
useNavigate
|
|
||||||
} from 'react-router';
|
|
||||||
|
|
||||||
import { Tab } from '@mui/material';
|
import { Tab } from '@mui/material';
|
||||||
|
|
||||||
@@ -14,52 +7,32 @@ 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));
|
||||||
|
|
||||||
// this also works!
|
const routerTab = useMatch('/settings/network/:tab')?.pathname || false;
|
||||||
// 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 = useCallback(
|
const selectNetwork = (network: WiFiNetwork) => {
|
||||||
(network: WiFiNetwork) => {
|
setSelectedNetwork(network);
|
||||||
setSelectedNetwork(network);
|
void navigate('/settings/network/settings');
|
||||||
void navigate('/settings/network/settings');
|
};
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deselectNetwork = useCallback(() => {
|
const deselectNetwork = () => {
|
||||||
setSelectedNetwork(undefined);
|
setSelectedNetwork(undefined);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = {
|
||||||
() => ({
|
...(selectedNetwork && { selectedNetwork }),
|
||||||
...(selectedNetwork && { selectedNetwork }),
|
selectNetwork,
|
||||||
selectNetwork,
|
deselectNetwork
|
||||||
deselectNetwork
|
};
|
||||||
}),
|
|
||||||
[selectedNetwork, selectNetwork, deselectNetwork]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WiFiConnectionContext.Provider value={contextValue}>
|
<WiFiConnectionContext.Provider value={contextValue}>
|
||||||
@@ -70,14 +43,7 @@ const Network = () => {
|
|||||||
/>
|
/>
|
||||||
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
<Tab value="/settings/network/scan" label={LL.NETWORK_SCAN()} />
|
||||||
</RouterTabs>
|
</RouterTabs>
|
||||||
<Routes>
|
<Outlet />
|
||||||
<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,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -26,7 +25,6 @@ import { API } from 'api/app';
|
|||||||
|
|
||||||
import { updateState, useRequest } from 'alova/client';
|
import { updateState, useRequest } from 'alova/client';
|
||||||
import type { APIcall } from 'app/main/types';
|
import type { APIcall } from 'app/main/types';
|
||||||
import type { ValidateFieldsError } from 'async-validator';
|
|
||||||
import {
|
import {
|
||||||
BlockFormControlLabel,
|
BlockFormControlLabel,
|
||||||
BlockNavigation,
|
BlockNavigation,
|
||||||
@@ -37,11 +35,13 @@ import {
|
|||||||
ValidatedPasswordField,
|
ValidatedPasswordField,
|
||||||
ValidatedTextField
|
ValidatedTextField
|
||||||
} from 'components';
|
} from 'components';
|
||||||
|
import { toast } from 'components/toast';
|
||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
import type { NetworkSettingsType } from 'types';
|
import type { NetworkSettingsType } from 'types';
|
||||||
import { updateValueDirty, useRest } from 'utils';
|
import { updateValueDirty, useRest } from 'utils';
|
||||||
import { ValidationError, validate } from 'validators';
|
import { ValidationError, validate } from 'validators';
|
||||||
import { createNetworkSettingsValidator } from 'validators/network';
|
import { createNetworkSettingsValidator } from 'validators/network';
|
||||||
|
import type { ValidateFieldsError } from 'validators/schema';
|
||||||
|
|
||||||
import SystemMonitor from '../../status/SystemMonitor';
|
import SystemMonitor from '../../status/SystemMonitor';
|
||||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||||
@@ -89,7 +89,7 @@ const NetworkSettings = () => {
|
|||||||
static_ip_config: false,
|
static_ip_config: false,
|
||||||
bandwidth20: false,
|
bandwidth20: false,
|
||||||
tx_power: 0,
|
tx_power: 0,
|
||||||
nosleep: false,
|
nosleep: true,
|
||||||
enableMDNS: true,
|
enableMDNS: true,
|
||||||
enableCORS: false,
|
enableCORS: false,
|
||||||
CORSOrigin: '*'
|
CORSOrigin: '*'
|
||||||
@@ -121,19 +121,19 @@ const NetworkSettings = () => {
|
|||||||
deselectNetwork();
|
deselectNetwork();
|
||||||
}, [data, saveData, deselectNetwork]);
|
}, [data, saveData, deselectNetwork]);
|
||||||
|
|
||||||
const setCancel = useCallback(async () => {
|
const setCancel = async () => {
|
||||||
deselectNetwork();
|
deselectNetwork();
|
||||||
await loadData();
|
await loadData();
|
||||||
}, [deselectNetwork, loadData]);
|
};
|
||||||
|
|
||||||
const doRestart = useCallback(async () => {
|
const doRestart = async () => {
|
||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
|
||||||
(error: Error) => {
|
(error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}, [sendAPI]);
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -173,7 +173,7 @@ const NetworkSettings = () => {
|
|||||||
<ValidatedTextField
|
<ValidatedTextField
|
||||||
fieldErrors={fieldErrors || {}}
|
fieldErrors={fieldErrors || {}}
|
||||||
name="ssid"
|
name="ssid"
|
||||||
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
|
label="SSID"
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={data.ssid}
|
value={data.ssid}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useRef, useState } from 'react';
|
import { memo, 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 = useCallback(() => {
|
const renderNetworkScanner = () => {
|
||||||
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, useCallback, useContext } from 'react';
|
import { memo, 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,34 +63,31 @@ const WiFiNetworkSelector = ({ networkList }: { networkList: WiFiNetworkList })
|
|||||||
|
|
||||||
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
const wifiConnectionContext = useContext(WiFiConnectionContext);
|
||||||
|
|
||||||
const renderNetwork = useCallback(
|
const renderNetwork = (network: WiFiNetwork) => (
|
||||||
(network: WiFiNetwork) => (
|
<ListItem
|
||||||
<ListItem
|
key={network.bssid}
|
||||||
key={network.bssid}
|
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
||||||
onClick={() => wifiConnectionContext.selectNetwork(network)}
|
>
|
||||||
>
|
<ListItemAvatar>
|
||||||
<ListItemAvatar>
|
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
||||||
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
|
</ListItemAvatar>
|
||||||
</ListItemAvatar>
|
<ListItemText
|
||||||
<ListItemText
|
primary={network.ssid}
|
||||||
primary={network.ssid}
|
secondary={
|
||||||
secondary={
|
'Security: ' +
|
||||||
'Security: ' +
|
networkSecurityMode(network) +
|
||||||
networkSecurityMode(network) +
|
', Ch: ' +
|
||||||
', Ch: ' +
|
network.channel +
|
||||||
network.channel +
|
', bssid: ' +
|
||||||
', bssid: ' +
|
network.bssid
|
||||||
network.bssid
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<ListItemIcon>
|
||||||
<ListItemIcon>
|
<Badge badgeContent={network.rssi + 'dBm'}>
|
||||||
<Badge badgeContent={network.rssi + 'dBm'}>
|
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
||||||
<WifiIcon sx={{ color: networkQualityHighlight(network, theme) }} />
|
</Badge>
|
||||||
</Badge>
|
</ListItemIcon>
|
||||||
</ListItemIcon>
|
</ListItem>
|
||||||
</ListItem>
|
|
||||||
),
|
|
||||||
[wifiConnectionContext, theme]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (networkList.networks.length === 0) {
|
if (networkList.networks.length === 0) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useMemo, useState } from 'react';
|
import { memo, useCallback, useContext, 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,16 +55,14 @@ const ManageUsers = () => {
|
|||||||
const blocker = useBlocker(changed !== 0);
|
const blocker = useBlocker(changed !== 0);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
|
||||||
const table_theme = useMemo(
|
const table_theme = useTheme({
|
||||||
() =>
|
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;
|
||||||
@@ -74,7 +72,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;
|
||||||
@@ -87,7 +85,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;
|
||||||
}
|
}
|
||||||
@@ -95,44 +93,36 @@ const ManageUsers = () => {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
}),
|
});
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const noAdminConfigured = useCallback(
|
const noAdminConfigured = () => !data?.users.find((u) => u.admin);
|
||||||
() => !data?.users.find((u) => u.admin),
|
|
||||||
[data]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeUser = useCallback(
|
const removeUser = (toRemove: UserType) => {
|
||||||
(toRemove: UserType) => {
|
if (!data) return;
|
||||||
if (!data) return;
|
const users = data.users.filter((u) => u.username !== toRemove.username);
|
||||||
const users = data.users.filter((u) => u.username !== toRemove.username);
|
updateDataValue({ ...data, users });
|
||||||
updateDataValue({ ...data, users });
|
setChanged(changed + 1);
|
||||||
setChanged(changed + 1);
|
};
|
||||||
},
|
|
||||||
[data, updateDataValue, changed]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createUser = useCallback(() => {
|
const createUser = () => {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setUser({
|
setUser({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
admin: true
|
admin: true
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const editUser = useCallback((toEdit: UserType) => {
|
const editUser = (toEdit: UserType) => {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
setUser({ ...toEdit });
|
setUser({ ...toEdit });
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const cancelEditingUser = useCallback(() => {
|
const cancelEditingUser = () => {
|
||||||
setUser(undefined);
|
setUser(undefined);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const doneEditingUser = useCallback(() => {
|
const doneEditingUser = () => {
|
||||||
if (user && data) {
|
if (user && data) {
|
||||||
const users = [
|
const users = [
|
||||||
...data.users.filter(
|
...data.users.filter(
|
||||||
@@ -144,26 +134,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 = useCallback((username: string) => {
|
const generateTokenForUser = (username: string) => {
|
||||||
setGeneratingToken(username);
|
setGeneratingToken(username);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const onSubmit = useCallback(async () => {
|
const onSubmit = async () => {
|
||||||
await saveData();
|
await saveData();
|
||||||
await authenticatedContext.refresh();
|
await authenticatedContext.refresh();
|
||||||
setChanged(0);
|
setChanged(0);
|
||||||
}, [saveData, authenticatedContext]);
|
};
|
||||||
|
|
||||||
const onCancelSubmit = useCallback(async () => {
|
const onCancelSubmit = async () => {
|
||||||
await loadData();
|
await loadData();
|
||||||
setChanged(0);
|
setChanged(0);
|
||||||
}, [loadData]);
|
};
|
||||||
|
|
||||||
const content = () => {
|
const content = () => {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -177,15 +167,10 @@ const ManageUsers = () => {
|
|||||||
admin: boolean;
|
admin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// add id to the type, needed for the table
|
const user_table = data.users.map((u) => ({
|
||||||
const user_table = useMemo(
|
...u,
|
||||||
() =>
|
id: u.username
|
||||||
data.users.map((u) => ({
|
})) as UserType2[];
|
||||||
...u,
|
|
||||||
id: u.username
|
|
||||||
})) as UserType2[],
|
|
||||||
[data.users]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Navigate, Route, Routes, matchRoutes, useLocation } from 'react-router';
|
import { Outlet, useMatch } 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 location = useLocation();
|
const routerTab = useMatch('/settings/security/:tab')?.pathname || false;
|
||||||
|
|
||||||
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 (
|
||||||
<>
|
<>
|
||||||
@@ -40,14 +21,7 @@ const Security = () => {
|
|||||||
/>
|
/>
|
||||||
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
<Tab value="/settings/security/users" label={LL.MANAGE_USERS()} />
|
||||||
</RouterTabs>
|
</RouterTabs>
|
||||||
<Routes>
|
<Outlet />
|
||||||
<Route path="users" element={<ManageUsers />} />
|
|
||||||
<Route path="settings" element={<SecuritySettings />} />
|
|
||||||
<Route
|
|
||||||
path="*"
|
|
||||||
element={<Navigate replace to="/settings/security/settings" />}
|
|
||||||
/>
|
|
||||||
</Routes>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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,
|
||||||
@@ -20,6 +19,7 @@ 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()} mt={1} />
|
<MessageBox level="info" message={LL.SU_TEXT()} sx={{ mt: 1 }} />
|
||||||
{dirtyFlags && dirtyFlags.length !== 0 && (
|
{dirtyFlags && dirtyFlags.length !== 0 && (
|
||||||
<ButtonRow>
|
<ButtonRow>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, 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,8 +14,6 @@ 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,
|
||||||
@@ -25,6 +23,8 @@ 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 = useCallback(async () => {
|
const validateAndDone = 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,5 +1,3 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Cell,
|
Cell,
|
||||||
@@ -36,16 +34,14 @@ const SystemActivity = () => {
|
|||||||
|
|
||||||
useLayoutTitle(LL.DATA_TRAFFIC());
|
useLayoutTitle(LL.DATA_TRAFFIC());
|
||||||
|
|
||||||
const stats_theme = tableTheme(
|
const stats_theme = tableTheme({
|
||||||
useMemo(
|
Table: `
|
||||||
() => ({
|
|
||||||
Table: `
|
|
||||||
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
|
||||||
`,
|
`,
|
||||||
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;
|
||||||
@@ -55,7 +51,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;
|
||||||
@@ -69,26 +65,20 @@ 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 = useCallback(
|
const showName = (id: number) => {
|
||||||
(id: number) => {
|
const name: keyof Translation['STATUS_NAMES'] =
|
||||||
const name: keyof Translation['STATUS_NAMES'] =
|
id.toString() as keyof Translation['STATUS_NAMES'];
|
||||||
id.toString() as keyof Translation['STATUS_NAMES'];
|
return LL.STATUS_NAMES[name]();
|
||||||
return LL.STATUS_NAMES[name]();
|
};
|
||||||
},
|
|
||||||
[LL]
|
|
||||||
);
|
|
||||||
|
|
||||||
const showQuality = useCallback((stat: Stat) => {
|
const showQuality = (stat: Stat) => {
|
||||||
if (stat.q === 0 || stat.s + stat.f === 0) {
|
if (stat.q === 0 || stat.s + stat.f === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,14 +90,18 @@ 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}
|
||||||
@@ -136,10 +130,8 @@ 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, useMemo } from 'react';
|
import { type FC, memo } 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,16 +127,15 @@ 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 = useMemo(() => {
|
const mqttStatusText = !data
|
||||||
if (!data) return '';
|
? ''
|
||||||
if (!data.enabled) return LL.NOT_ENABLED();
|
: !data.enabled
|
||||||
return data.connected
|
? LL.NOT_ENABLED()
|
||||||
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
: data.connected
|
||||||
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
? `${LL.CONNECTED(0)} (${data.connect_count})`
|
||||||
}, [data, LL]);
|
: `${LL.DISCONNECTED()} (${data.connect_count})`;
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
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';
|
||||||
@@ -67,12 +65,16 @@ const NTPStatus = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const content = useMemo(() => {
|
if (!data) {
|
||||||
if (!data) {
|
|
||||||
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SectionContent>
|
||||||
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
|
</SectionContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionContent>
|
||||||
<List>
|
<List>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
@@ -121,10 +123,8 @@ 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,5 +1,3 @@
|
|||||||
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';
|
||||||
@@ -124,16 +122,20 @@ const NetworkStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const content = useMemo(() => {
|
if (!data) {
|
||||||
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>
|
||||||
@@ -227,10 +229,8 @@ const NetworkStatus = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
);
|
</SectionContent>
|
||||||
}, [data, error, loadData, LL, theme]);
|
);
|
||||||
|
|
||||||
return <SectionContent>{content}</SectionContent>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default NetworkStatus;
|
export default NetworkStatus;
|
||||||
|
|||||||
+108
-273
@@ -1,25 +1,16 @@
|
|||||||
import { useCallback, useContext, useMemo, useState } from 'react';
|
import { useContext } 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,
|
||||||
@@ -27,12 +18,10 @@ 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 { type APIcall, busConnectionStatus } from 'app/main/types';
|
import { 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';
|
||||||
@@ -41,9 +30,6 @@ 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 = (
|
||||||
@@ -72,24 +58,7 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
|
const { data, send: loadData, error } = useRequest(readSystemStatus);
|
||||||
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();
|
||||||
@@ -97,10 +66,8 @@ const SystemStatus = () => {
|
|||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
// Memoize derived status values to avoid recalculation on every render
|
const busStatus = (() => {
|
||||||
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)})`;
|
||||||
@@ -111,12 +78,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return 'EMS state unknown';
|
return 'EMS state unknown';
|
||||||
}
|
}
|
||||||
}, [data?.bus_status, data?.bus_uptime, LL]);
|
})();
|
||||||
|
|
||||||
// Memoize derived status values to avoid recalculation on every render
|
const systemStatus = (() => {
|
||||||
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:
|
||||||
@@ -129,14 +94,12 @@ 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 = useMemo(() => {
|
const busStatusHighlight = (() => {
|
||||||
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;
|
||||||
@@ -147,11 +110,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
}, [data?.bus_status, theme.palette]);
|
})();
|
||||||
|
|
||||||
const ntpStatus = useMemo(() => {
|
const ntpStatus = (() => {
|
||||||
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();
|
||||||
@@ -164,11 +126,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
}, [data?.ntp_status, data?.ntp_time, LL]);
|
})();
|
||||||
|
|
||||||
const ntpStatusHighlight = useMemo(() => {
|
const ntpStatusHighlight = (() => {
|
||||||
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;
|
||||||
@@ -179,11 +140,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.error.main;
|
return theme.palette.error.main;
|
||||||
}
|
}
|
||||||
}, [data?.ntp_status, theme.palette]);
|
})();
|
||||||
|
|
||||||
const networkStatusHighlight = useMemo(() => {
|
const networkStatusHighlight = (() => {
|
||||||
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:
|
||||||
@@ -198,11 +158,10 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return theme.palette.warning.main;
|
return theme.palette.warning.main;
|
||||||
}
|
}
|
||||||
}, [data?.network_status, theme.palette]);
|
})();
|
||||||
|
|
||||||
const networkStatus = useMemo(() => {
|
const networkStatus = (() => {
|
||||||
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);
|
||||||
@@ -223,227 +182,103 @@ const SystemStatus = () => {
|
|||||||
default:
|
default:
|
||||||
return LL.UNKNOWN();
|
return LL.UNKNOWN();
|
||||||
}
|
}
|
||||||
}, [data?.network_status, data?.wifi_rssi, LL]);
|
})();
|
||||||
|
|
||||||
const activeHighlight = useCallback(
|
const activeHighlight = (value: boolean) =>
|
||||||
(value: boolean) =>
|
value ? theme.palette.success.main : theme.palette.info.main;
|
||||||
value ? theme.palette.success.main : theme.palette.info.main,
|
|
||||||
[theme.palette]
|
|
||||||
);
|
|
||||||
|
|
||||||
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 || ''} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (!data || !LL) {
|
||||||
return (
|
return (
|
||||||
<>
|
<SectionContent>
|
||||||
<List>
|
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
|
||||||
<ListMenuItem
|
</SectionContent>
|
||||||
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>;
|
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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SystemStatus;
|
export default SystemStatus;
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
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';
|
||||||
@@ -33,6 +25,7 @@ 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';
|
||||||
@@ -185,8 +178,7 @@ const SystemLog = () => {
|
|||||||
};
|
};
|
||||||
}, [data]); // Recalculate when data changes (in case layout shifts)
|
}, [data]); // Recalculate when data changes (in case layout shifts)
|
||||||
|
|
||||||
// Memoize message handler to avoid recreating on every render
|
const handleLogMessage = (message: { data: string }) => {
|
||||||
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) => {
|
||||||
@@ -200,7 +192,7 @@ const SystemLog = () => {
|
|||||||
const newLog = [...log, logentry];
|
const newLog = [...log, logentry];
|
||||||
return newLog;
|
return newLog;
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useSSE(fetchLogES, {
|
useSSE(fetchLogES, {
|
||||||
immediate: true,
|
immediate: true,
|
||||||
@@ -211,7 +203,7 @@ const SystemLog = () => {
|
|||||||
toast.error('No connection to Log service');
|
toast.error('No connection to Log service');
|
||||||
});
|
});
|
||||||
|
|
||||||
const onDownload = useCallback(() => {
|
const onDownload = () => {
|
||||||
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');
|
||||||
@@ -225,11 +217,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 = useCallback(async () => {
|
const saveSettings = 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);
|
||||||
@@ -246,7 +238,7 @@ const SystemLog = () => {
|
|||||||
}
|
}
|
||||||
}, [logEntries.length, autoscroll]);
|
}, [logEntries.length, autoscroll]);
|
||||||
|
|
||||||
const sendReadCommand = useCallback(() => {
|
const sendReadCommand = () => {
|
||||||
if (readValue === '') {
|
if (readValue === '') {
|
||||||
setReadOpen(!readOpen);
|
setReadOpen(!readOpen);
|
||||||
return;
|
return;
|
||||||
@@ -257,7 +249,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 { useCallback, useMemo, useRef, useState } from 'react';
|
import { 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,39 +57,31 @@ const SystemMonitor = () => {
|
|||||||
void send();
|
void send();
|
||||||
}, 1000); // check every 1 second
|
}, 1000); // check every 1 second
|
||||||
|
|
||||||
const { statusMessage, isUploading, progressValue } = useMemo(() => {
|
const status = data?.status;
|
||||||
const status = data?.status;
|
|
||||||
|
|
||||||
const message =
|
const statusMessage =
|
||||||
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 uploading =
|
const isUploading =
|
||||||
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
|
||||||
const progress =
|
const progressValue =
|
||||||
uploading && status
|
isUploading && status
|
||||||
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return {
|
const onCancel = async () => {
|
||||||
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
|
||||||
@@ -153,9 +145,21 @@ const SystemMonitor = () => {
|
|||||||
{LL.PLEASE_WAIT()}…
|
{LL.PLEASE_WAIT()}…
|
||||||
</Typography>
|
</Typography>
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
<>
|
||||||
<LinearProgressWithLabel value={progressValue} />
|
<Box sx={{ width: '100%', pl: 2, pr: 2, py: 2 }}>
|
||||||
</Box>
|
<LinearProgressWithLabel value={progressValue} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
sx={{ ml: 2, mt: 2 }}
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{LL.CANCEL()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,931 +0,0 @@
|
|||||||
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, useMemo } from 'react';
|
import { type FC, type PropsWithChildren, memo } from 'react';
|
||||||
|
|
||||||
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
import ErrorIcon from '@mui/icons-material/Error';
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
@@ -38,18 +38,17 @@ const MessageBox: FC<PropsWithChildren<MessageBoxProps>> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const { Icon, backgroundColor } = useMemo(() => {
|
const Icon = LEVEL_ICONS[level];
|
||||||
const Icon = LEVEL_ICONS[level];
|
const palettePath = LEVEL_PALETTE_PATHS[level];
|
||||||
const palettePath = LEVEL_PALETTE_PATHS[level];
|
const [paletteKeyName, shade] = palettePath.split('.') as [
|
||||||
const [key, shade] = palettePath.split('.') as [
|
keyof typeof theme.palette,
|
||||||
keyof typeof theme.palette,
|
string
|
||||||
string
|
];
|
||||||
];
|
const paletteKey = theme.palette[paletteKeyName] as unknown as Record<
|
||||||
const paletteKey = theme.palette[key] as unknown as Record<string, string>;
|
string,
|
||||||
const backgroundColor = paletteKey[shade];
|
string
|
||||||
|
>;
|
||||||
return { Icon, backgroundColor };
|
const backgroundColor = paletteKey[shade];
|
||||||
}, [level, theme]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { memo, useCallback, useContext, useMemo } from 'react';
|
import { memo, useContext } 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/CZ.svg';
|
import CZflag from 'i18n/CS.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,27 +44,14 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [
|
|||||||
const LanguageSelector = () => {
|
const LanguageSelector = () => {
|
||||||
const { setLocale, locale, LL } = useContext(I18nContext);
|
const { setLocale, locale, LL } = useContext(I18nContext);
|
||||||
|
|
||||||
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = useCallback(
|
const onLocaleSelected: ChangeEventHandler<HTMLInputElement> = async ({
|
||||||
async ({ target }) => {
|
target
|
||||||
const loc = target.value as Locales;
|
}) => {
|
||||||
localStorage.setItem('lang', loc);
|
const loc = target.value as Locales;
|
||||||
await loadLocaleAsync(loc);
|
localStorage.setItem('lang', loc);
|
||||||
setLocale(loc);
|
await loadLocaleAsync(loc);
|
||||||
},
|
setLocale(loc);
|
||||||
[setLocale]
|
};
|
||||||
);
|
|
||||||
|
|
||||||
// Memoize menu items to prevent recreation on every render
|
|
||||||
const menuItems = useMemo(
|
|
||||||
() =>
|
|
||||||
LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
|
||||||
<MenuItem key={key} value={key}>
|
|
||||||
<img src={flag} style={flagStyle} alt={label} />
|
|
||||||
{label}
|
|
||||||
</MenuItem>
|
|
||||||
)),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
@@ -76,7 +63,12 @@ const LanguageSelector = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
select
|
select
|
||||||
>
|
>
|
||||||
{menuItems}
|
{LANGUAGE_OPTIONS.map(({ key, flag, label }) => (
|
||||||
|
<MenuItem key={key} value={key}>
|
||||||
|
<img src={flag} style={flagStyle} alt={label} />
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useState } from 'react';
|
import { memo, 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 = useCallback(() => {
|
const togglePasswordVisibility = () => {
|
||||||
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 'async-validator';
|
import type { ValidateFieldsError } from 'validators/schema';
|
||||||
|
|
||||||
interface ValidatedFieldProps {
|
interface ValidatedFieldProps {
|
||||||
fieldErrors?: ValidateFieldsError;
|
fieldErrors?: ValidateFieldsError;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ 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);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -28,7 +27,6 @@ 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, useCallback, useMemo } from 'react';
|
import { memo } 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,14 +39,11 @@ const LayoutAppBarComponent = ({ title, onToggleDrawer }: LayoutAppBarProps) =>
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const pathnames = useMemo(
|
const pathnames = location.pathname.split('/').filter((x) => x);
|
||||||
() => location.pathname.split('/').filter((x) => x),
|
|
||||||
[location.pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleBackClick = useCallback(() => {
|
const handleBackClick = () => {
|
||||||
void navigate('/' + pathnames[0]);
|
void navigate('/' + pathnames[0]);
|
||||||
}, [navigate, pathnames]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppBar position="fixed" sx={appBarStyles}>
|
<AppBar position="fixed" sx={appBarStyles}>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { memo, useMemo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||||
|
|
||||||
|
import { readSettings } from 'api/app';
|
||||||
|
|
||||||
|
import { useRequest } from 'alova/client';
|
||||||
import { PROJECT_NAME } from 'env';
|
import { PROJECT_NAME } from 'env';
|
||||||
|
|
||||||
import { DRAWER_WIDTH } from './Layout';
|
import { DRAWER_WIDTH } from './Layout';
|
||||||
@@ -24,22 +27,34 @@ interface LayoutDrawerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
const LayoutDrawerComponent = ({ mobileOpen, onClose }: LayoutDrawerProps) => {
|
||||||
// Memoize drawer content to prevent unnecessary re-renders
|
const { data: settings } = useRequest(readSettings);
|
||||||
const drawer = useMemo(
|
const system_name = settings?.system_name;
|
||||||
() => (
|
|
||||||
<>
|
const drawer = (
|
||||||
<Toolbar disableGutters>
|
<>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
<Toolbar disableGutters>
|
||||||
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
<Box sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
|
||||||
<Typography variant="h6">{PROJECT_NAME}</Typography>
|
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography>{PROJECT_NAME}</Typography>
|
||||||
|
{system_name && (
|
||||||
|
<Typography color="secondary" variant="body2">
|
||||||
|
{system_name}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Divider absolute />
|
</Box>
|
||||||
</Toolbar>
|
<Divider absolute />
|
||||||
<Divider />
|
</Toolbar>
|
||||||
<LayoutMenu />
|
<Divider />
|
||||||
</>
|
<LayoutMenu />
|
||||||
),
|
</>
|
||||||
[]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useContext, useState } from 'react';
|
import { memo, 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,6 +8,7 @@ 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';
|
||||||
@@ -18,13 +19,15 @@ import { AuthenticatedContext } from 'contexts/authentication';
|
|||||||
import { useI18nContext } from 'i18n/i18n-react';
|
import { useI18nContext } from 'i18n/i18n-react';
|
||||||
|
|
||||||
const LayoutMenuComponent = () => {
|
const LayoutMenuComponent = () => {
|
||||||
const { me } = useContext(AuthenticatedContext);
|
const { me, versions } = useContext(AuthenticatedContext);
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
const [menuOpen, setMenuOpen] = useState(true);
|
const [menuOpen, setMenuOpen] = useState(true);
|
||||||
|
|
||||||
const handleMenuToggle = useCallback(() => {
|
const upgradeAvailable = versions?.current?.upgradeable ?? false;
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
setMenuOpen((prev) => !prev);
|
setMenuOpen((prev) => !prev);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -78,6 +81,12 @@ 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()}
|
||||||
@@ -105,6 +114,7 @@ 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, useMemo } from 'react';
|
import { memo } from 'react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
|
|
||||||
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
|
import { Box, 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,60 +11,52 @@ 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 = useMemo(() => routeMatches(to, pathname), [to, pathname]);
|
const selected = routeMatches(to, pathname);
|
||||||
|
|
||||||
// Memoize dynamic styles based on selected state
|
const buttonStyles: SxProps<Theme> = {
|
||||||
const buttonStyles: SxProps<Theme> = useMemo(
|
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
() => ({
|
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
||||||
transition: 'all 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
borderRadius: '8px',
|
||||||
backgroundColor: selected ? 'rgba(144, 202, 249, 0.1)' : 'transparent',
|
margin: '2px 8px',
|
||||||
borderRadius: '8px',
|
'&:hover': {
|
||||||
margin: '2px 8px',
|
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
||||||
'&:hover': {
|
},
|
||||||
backgroundColor: 'rgba(68, 82, 211, 0.39)'
|
'&::before': {
|
||||||
},
|
content: '""',
|
||||||
'&::before': {
|
position: 'absolute',
|
||||||
content: '""',
|
left: 0,
|
||||||
position: 'absolute',
|
top: 0,
|
||||||
left: 0,
|
bottom: 0,
|
||||||
top: 0,
|
width: selected ? '3px' : '0px',
|
||||||
bottom: 0,
|
backgroundColor: '#90caf9',
|
||||||
width: selected ? '3px' : '0px',
|
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
||||||
backgroundColor: '#90caf9',
|
}
|
||||||
transition: 'width 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)'
|
};
|
||||||
}
|
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const iconStyles: SxProps<Theme> = useMemo(
|
const iconStyles: SxProps<Theme> = {
|
||||||
() => ({
|
color: selected ? '#90caf9' : '#9e9e9e',
|
||||||
color: selected ? '#90caf9' : '#9e9e9e',
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
||||||
transform: selected ? 'scale(1.1)' : 'scale(1)',
|
transitionProperty: 'color, transform'
|
||||||
transitionProperty: 'color, transform'
|
};
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
const textStyles: SxProps<Theme> = useMemo(
|
const textStyles: SxProps<Theme> = {
|
||||||
() => ({
|
color: selected ? '#90caf9' : '#f5f5f5',
|
||||||
color: selected ? '#90caf9' : '#f5f5f5',
|
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
||||||
transition: 'color 0.05s cubic-bezier(0.55, 0.085, 0.68, 0.53)',
|
transitionProperty: 'color, font-weight'
|
||||||
transitionProperty: 'color, font-weight'
|
};
|
||||||
}),
|
|
||||||
[selected]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItemButton
|
<ListItemButton
|
||||||
@@ -78,6 +70,20 @@ 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,6 +5,7 @@ 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,
|
||||||
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
|
|||||||
text: string;
|
text: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
badge?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconStyles: CSSProperties = {
|
const iconStyles: CSSProperties = {
|
||||||
@@ -28,15 +30,40 @@ 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 }: ListMenuItemProps) => (
|
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
|
||||||
<>
|
<>
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar sx={{ bgcolor, color: 'white' }}>
|
<Avatar sx={{ bgcolor, color: 'white' }}>
|
||||||
<Icon />
|
<Icon />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText primary={label} secondary={text} />
|
<ListItemText
|
||||||
|
primary={
|
||||||
|
<>
|
||||||
|
{label}
|
||||||
|
{badge && <Badge />}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
secondary={text}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
|
|||||||
label,
|
label,
|
||||||
text,
|
text,
|
||||||
to,
|
to,
|
||||||
disabled
|
disabled,
|
||||||
|
badge
|
||||||
}: ListMenuItemProps) => (
|
}: ListMenuItemProps) => (
|
||||||
<>
|
<>
|
||||||
{to && !disabled ? (
|
{to && !disabled ? (
|
||||||
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
|
|||||||
{...(bgcolor && { bgcolor })}
|
{...(bgcolor && { bgcolor })}
|
||||||
label={label}
|
label={label}
|
||||||
text={text}
|
text={text}
|
||||||
|
{...(badge && { badge })}
|
||||||
/>
|
/>
|
||||||
</ListItemButton>
|
</ListItemButton>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
|
|||||||
{...(bgcolor && { bgcolor })}
|
{...(bgcolor && { bgcolor })}
|
||||||
label={label}
|
label={label}
|
||||||
text={text}
|
text={text}
|
||||||
|
{...(badge && { badge })}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback } from 'react';
|
import { memo } 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 = useCallback(() => {
|
const handleReset = () => {
|
||||||
blocker.reset?.();
|
blocker.reset?.();
|
||||||
}, [blocker]);
|
};
|
||||||
|
|
||||||
const handleProceed = useCallback(() => {
|
const handleProceed = () => {
|
||||||
blocker.proceed?.();
|
blocker.proceed?.();
|
||||||
}, [blocker]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
<Dialog sx={dialogStyle} open={blocker.state === 'blocked'}>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { memo, useContext } from 'react';
|
import { memo, useContext } from 'react';
|
||||||
import type { FC } from 'react';
|
import { Navigate, Outlet } from 'react-router';
|
||||||
import { Navigate } from 'react-router';
|
|
||||||
|
|
||||||
import { AuthenticatedContext } from 'contexts/authentication';
|
import { AuthenticatedContext } from 'contexts/authentication';
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
|
||||||
|
|
||||||
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
// Layout-route guard: renders nested admin routes only for admins, otherwise
|
||||||
const authenticatedContext = useContext(AuthenticatedContext);
|
// redirects home. Must be used inside the authenticated route subtree so that
|
||||||
return authenticatedContext.me.admin ? (
|
// AuthenticatedContext (and `me`) is available.
|
||||||
<>{children}</>
|
const RequireAdmin = () => {
|
||||||
) : (
|
const { me } = useContext(AuthenticatedContext);
|
||||||
<Navigate replace to="/" />
|
return me.admin ? <Outlet /> : <Navigate replace to="/" />;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(RequireAdmin);
|
export default memo(RequireAdmin);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
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, useCallback } from 'react';
|
import { memo } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
@@ -16,12 +16,9 @@ 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 = useCallback(
|
const handleTabChange = (_event: unknown, path: string) => {
|
||||||
(_event: unknown, path: string) => {
|
void navigate(path);
|
||||||
void navigate(path);
|
};
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ 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';
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as Toaster } from './Toaster';
|
||||||
|
export { toast } from './toastStore';
|
||||||
|
export type { ToastSeverity } from './toastStore';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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,7 +7,6 @@ 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';
|
||||||
@@ -28,6 +27,7 @@ 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,7 +91,9 @@ const DragNdrop = ({ text, onFileSelected }: DragNdropProps) => {
|
|||||||
).upgradeImportantMessageType;
|
).upgradeImportantMessageType;
|
||||||
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
setUpgradeImportantMessageType(upgradeImportantMessageType_n);
|
||||||
if (upgradeImportantMessageType_n === 0) {
|
if (upgradeImportantMessageType_n === 0) {
|
||||||
onFileSelected(file);
|
if (file) {
|
||||||
|
onFileSelected(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onError((error) => {
|
.onError((error) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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';
|
||||||
@@ -7,6 +6,7 @@ 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,36 +1,55 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { redirect } from 'react-router';
|
import { useNavigate } 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 } from 'types';
|
import type { Me, VersionsResponse } from 'types';
|
||||||
import type { RequiredChildrenProps } from 'utils';
|
import type { RequiredChildrenProps } from 'utils';
|
||||||
|
|
||||||
import { AuthenticationContext } from './context';
|
import { AuthenticationContext } from './context';
|
||||||
|
|
||||||
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
const { LL } = useI18nContext();
|
const { LL } = useI18nContext();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [initialized, setInitialized] = useState<boolean>(false);
|
const [initialized, setInitialized] = useState<boolean>(false);
|
||||||
const [me, setMe] = useState<Me>();
|
const [me, setMe] = useState<Me>();
|
||||||
|
const [versions, setVersions] = useState<VersionsResponse>();
|
||||||
|
|
||||||
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
|
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
|
||||||
immediate: false
|
immediate: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { send: sendGetVersions } = useRequest(
|
||||||
|
() => callAction({ action: 'getVersions' }),
|
||||||
|
{ immediate: false }
|
||||||
|
)
|
||||||
|
.onSuccess((event) => {
|
||||||
|
setVersions(event.data as VersionsResponse);
|
||||||
|
})
|
||||||
|
.onError(() => {
|
||||||
|
setVersions(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshVersions = useCallback(async () => {
|
||||||
|
await sendGetVersions().catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const signIn = (accessToken: string) => {
|
const signIn = (accessToken: string) => {
|
||||||
try {
|
try {
|
||||||
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
|
||||||
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
|
||||||
setMe(decodedMe);
|
setMe(decodedMe);
|
||||||
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
|
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
|
||||||
|
void refreshVersions();
|
||||||
} catch {
|
} catch {
|
||||||
setMe(undefined);
|
setMe(undefined);
|
||||||
throw new Error('Failed to parse JWT');
|
throw new Error('Failed to parse JWT');
|
||||||
@@ -40,8 +59,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
const signOut = (doRedirect: boolean) => {
|
const signOut = (doRedirect: boolean) => {
|
||||||
AuthenticationApi.clearAccessToken();
|
AuthenticationApi.clearAccessToken();
|
||||||
setMe(undefined);
|
setMe(undefined);
|
||||||
|
setVersions(undefined);
|
||||||
if (doRedirect) {
|
if (doRedirect) {
|
||||||
redirect('/');
|
void navigate('/', { replace: true });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,8 +69,9 @@ 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(() => {
|
.then(async () => {
|
||||||
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
setMe(AuthenticationApi.decodeMeJWT(accessToken));
|
||||||
|
await refreshVersions();
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -67,15 +88,16 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// cache object to prevent re-renders
|
|
||||||
const obj = useMemo(
|
const obj = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
refresh,
|
refresh,
|
||||||
...(me && { me })
|
refreshVersions,
|
||||||
|
...(me && { me }),
|
||||||
|
...(versions && { versions })
|
||||||
}),
|
}),
|
||||||
[signIn, signOut, me, refresh]
|
[signIn, signOut, me, refresh, refreshVersions, versions]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
import type { Me } from 'types';
|
import type { Me, VersionsResponse } from 'types';
|
||||||
|
|
||||||
export interface AuthenticationContextValue {
|
export interface AuthenticationContextValue {
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
signIn: (accessToken: string) => void;
|
signIn: (accessToken: string) => void;
|
||||||
signOut: (redirect: boolean) => void;
|
signOut: (redirect: boolean) => void;
|
||||||
me?: Me;
|
me?: Me;
|
||||||
|
versions?: VersionsResponse;
|
||||||
|
refreshVersions: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 B After Width: | Height: | Size: 207 B |
@@ -31,8 +31,8 @@ const cs: Translation = {
|
|||||||
ID_OF: '{0} ID',
|
ID_OF: '{0} ID',
|
||||||
DEVICE: 'Zařízení',
|
DEVICE: 'Zařízení',
|
||||||
PRODUCT: 'Produkt',
|
PRODUCT: 'Produkt',
|
||||||
VERSION: 'Verze',
|
|
||||||
BRAND: 'Značka',
|
BRAND: 'Značka',
|
||||||
|
VERSION: 'Verze',
|
||||||
ENTITY_NAME: 'Název entity',
|
ENTITY_NAME: 'Název entity',
|
||||||
VALUE: '{{value|Value}}',
|
VALUE: '{{value|Value}}',
|
||||||
DEVICES: 'Zařízení',
|
DEVICES: 'Zařízení',
|
||||||
@@ -102,8 +102,8 @@ const cs: Translation = {
|
|||||||
PHY_TYPE: 'Typ Eth PHY',
|
PHY_TYPE: 'Typ Eth PHY',
|
||||||
DISABLED: 'zakázáno',
|
DISABLED: 'zakázáno',
|
||||||
TX_MODE: 'EMS Tx režim',
|
TX_MODE: 'EMS Tx režim',
|
||||||
|
EMS_BUS: '{{Bus|EMS Bus}}',
|
||||||
HARDWARE: 'Hardware',
|
HARDWARE: 'Hardware',
|
||||||
EMS_BUS: '{{BUS|EMS BUS}}',
|
|
||||||
GENERAL_OPTIONS: 'Obecné možnosti',
|
GENERAL_OPTIONS: 'Obecné možnosti',
|
||||||
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
|
LANGUAGE_ENTITIES: 'Jazyk (pro entity zařízení)',
|
||||||
HIDE_LED: 'Skrýt LED',
|
HIDE_LED: 'Skrýt LED',
|
||||||
@@ -247,8 +247,7 @@ 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_1: 'Vždy',
|
AP_PROVIDE_TEXT_2: 'Když je síťové připojení stratené',
|
||||||
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',
|
||||||
@@ -262,7 +261,6 @@ const cs: Translation = {
|
|||||||
SCAN_AGAIN: 'Skenovat znovu',
|
SCAN_AGAIN: 'Skenovat znovu',
|
||||||
NETWORK_SCANNER: 'Síťový skener',
|
NETWORK_SCANNER: 'Síťový skener',
|
||||||
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
|
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
|
||||||
NETWORK_BLANK_SSID: 'ponechte prázdné pro deaktivaci WiFi a povolení ETH',
|
|
||||||
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
|
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
|
||||||
TX_POWER: 'Vysílací výkon',
|
TX_POWER: 'Vysílací výkon',
|
||||||
HOSTNAME: 'Název hostitele',
|
HOSTNAME: 'Název hostitele',
|
||||||
@@ -288,14 +286,13 @@ 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. Nastavte jedinečný název pro povolení/zakázání aktivace přes API/MQTT',
|
SCHEDULER_HELP_1: 'Automatizujte příkazy přidáním naplánovaných událostí níže',
|
||||||
SCHEDULER_HELP_2: 'Použijte 00:00 pro spuštění při startu',
|
SCHEDULER_HELP_2: 'při startu',
|
||||||
SCHEDULE: 'Harmonogram',
|
SCHEDULE: 'Harmonogram',
|
||||||
TIME: 'Čas',
|
TIME: 'Čas',
|
||||||
TIMER: 'Časovač',
|
TIMER: 'Časovač',
|
||||||
ONCHANGE: 'Při změně',
|
ONCHANGE: 'Při změně',
|
||||||
CONDITION: 'Podmínka',
|
CONDITION: 'Podmínka',
|
||||||
IMMEDIATE: 'Ihned',
|
|
||||||
SCHEDULE_UPDATED: 'Harmonogram aktualizován',
|
SCHEDULE_UPDATED: 'Harmonogram aktualizován',
|
||||||
SCHEDULE_TIMER_1: 'při startu',
|
SCHEDULE_TIMER_1: 'při startu',
|
||||||
SCHEDULE_TIMER_2: 'každou minutu',
|
SCHEDULE_TIMER_2: 'každou minutu',
|
||||||
@@ -343,6 +340,7 @@ 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',
|
||||||
@@ -364,8 +362,13 @@ const cs: Translation = {
|
|||||||
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
|
UPGRADE_IMPORTANT_MESSAGES: 'Aktualizovat důležité zprávy',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
|
UPGRADE_IMPORTANT_MESSAGES_1: 'Tato aktualizace vyžaduje obnovení továrního nastavení. Ujistěte se, že nejprve stáhnete systémovou zálohu před pokračováním a poté nahrajte tento soubor po instalaci nové verze.',
|
||||||
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
|
UPGRADE_IMPORTANT_MESSAGES_2: 'Aktualizujete se na novou hlavní verzi. Ujistěte se, že jste přečetli ChangeLog pro jakékoliv závažné změny.',
|
||||||
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?'
|
WARNING_SYSTEM_BACKUP: 'Toto vytvoří zálohu vašich celých systémových konfigurací a nastavení. Všechna hesla budou v zálohovém souboru čitelná. Buďte opatrní při sdílení! Opravdu chcete pokračovat?',
|
||||||
|
TEST_EMAIL_SUCCESSFUL: 'Test email byl úspěšně odeslán',
|
||||||
|
SYSTEM_NAME: 'Název systému',
|
||||||
|
COMMANDS: 'Příkazy',
|
||||||
|
COMMANDS_UPDATED: 'Příkazy byly aktualizovány',
|
||||||
|
COMMANDS_HELP_1: 'Definujte vlastní příkazy pro magistrali EMS',
|
||||||
|
EXECUTE_COMMAND_SENT: 'Příkaz odeslán',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default cs;
|
export default cs;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user