mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-06 15:59:52 +03:00
Compare commits
688 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc057d18c9 | ||
|
|
326f05d63f | ||
|
|
eda203e350 | ||
|
|
70efa4f4e6 | ||
|
|
a020a48e63 | ||
|
|
a56c847790 | ||
|
|
763337db3f | ||
|
|
f6c5a4d064 | ||
|
|
a20b6b59c9 | ||
|
|
9a708a263c | ||
|
|
c78a42e42f | ||
|
|
df555acc0e | ||
|
|
bf1ce5150a | ||
|
|
b499d908ab | ||
|
|
029ef56edd | ||
|
|
70f52ce484 | ||
|
|
a087974acb | ||
|
|
f96b13c3cd | ||
|
|
9cf89ee6f6 | ||
|
|
a5be7f4e38 | ||
|
|
d367c20a13 | ||
|
|
97132a4758 | ||
|
|
308b8fb779 | ||
|
|
79e0b80e51 | ||
|
|
f79258f645 | ||
|
|
8d3bcde8d6 | ||
|
|
8ee70a1263 | ||
|
|
fb940269de | ||
|
|
ec02e55635 | ||
|
|
f87f18ca8e | ||
|
|
94afd8a3a6 | ||
|
|
0d69a0a3db | ||
|
|
beae2a2587 | ||
|
|
a3a29132ab | ||
|
|
cdc04f987c | ||
|
|
ba2ded1a5a | ||
|
|
d0a779b185 | ||
|
|
d12879b07b | ||
|
|
d0bcbb8d1b | ||
|
|
97257ac821 | ||
|
|
d5e19fdf5b | ||
|
|
9c1d08c057 | ||
|
|
c4f4a440ac | ||
|
|
8ebefebb0a | ||
|
|
3b86d1b5aa | ||
|
|
505e339406 | ||
|
|
297134dd81 | ||
|
|
02989ec4b3 | ||
|
|
9573869c7c | ||
|
|
295bbed4ae | ||
|
|
c81e3e832a | ||
|
|
a018432b81 | ||
|
|
6bc8a4b6c5 | ||
|
|
32dcd7313b | ||
|
|
387c9c63f1 | ||
|
|
ca30b8233b | ||
|
|
c3757f95e5 | ||
|
|
66fbd2b359 | ||
|
|
4420ae33b8 | ||
|
|
d707e92d59 | ||
|
|
273526dc16 | ||
|
|
ebc5cfa2d8 | ||
|
|
13430de3ba | ||
|
|
78dee6c7fe | ||
|
|
43db536878 | ||
|
|
c765a274b2 | ||
|
|
8101fcf95d | ||
|
|
69568abb8a | ||
|
|
a14b43dbe2 | ||
|
|
2fedd6da15 | ||
|
|
fc3224c9ea | ||
|
|
39feb25600 | ||
|
|
eddbacf5a7 | ||
|
|
4e5b460447 | ||
|
|
0e172ff06d | ||
|
|
40fa1d47bc | ||
|
|
70fe08a3c8 | ||
|
|
b840e2a6b5 | ||
|
|
422657fbf7 | ||
|
|
da596a01b9 | ||
|
|
96d2fd2393 | ||
|
|
42c9aeeed6 | ||
|
|
a9b32d8ba7 | ||
|
|
30da267db0 | ||
|
|
84f643e461 | ||
|
|
6298fab94f | ||
|
|
9639f9cee6 | ||
|
|
4af1de4093 | ||
|
|
2c90a2c3aa | ||
|
|
254d4dd02d | ||
|
|
4eceeea4d1 | ||
|
|
efa5eb273c | ||
|
|
8a05e11297 | ||
|
|
1238e89084 | ||
|
|
1c48d12167 | ||
|
|
1fab47acd5 | ||
|
|
0e563e91b1 | ||
|
|
43b1a4572a | ||
|
|
bf18718b9a | ||
|
|
8790d81e03 | ||
|
|
0e5cb2df8e | ||
|
|
46c5913000 | ||
|
|
c35433856f | ||
|
|
67f898bbec | ||
|
|
449c01d345 | ||
|
|
cec1fce745 | ||
|
|
a02d4ab259 | ||
|
|
a22783a3e4 | ||
|
|
9524ec1317 | ||
|
|
64a7108e6d | ||
|
|
cf4818f09c | ||
|
|
98fb970dac | ||
|
|
d162206f75 | ||
|
|
1609b76f0a | ||
|
|
794b3c0471 | ||
|
|
e7bcc380e3 | ||
|
|
fe12f1903d | ||
|
|
41ec4b9bcf | ||
|
|
f64dd00cce | ||
|
|
5ef2f8292c | ||
|
|
ae3ead6b10 | ||
|
|
874f686882 | ||
|
|
383c30f649 | ||
|
|
0b2c95fc4e | ||
|
|
eacdd62cc8 | ||
|
|
a692eb557d | ||
|
|
99b6acfc07 | ||
|
|
fa486c8b60 | ||
|
|
b19b3e5869 | ||
|
|
e6bfd90235 | ||
|
|
072fe526ea | ||
|
|
18e9b99413 | ||
|
|
e65f5072fe | ||
|
|
435218888a | ||
|
|
3e7b743dfa | ||
|
|
c693ef6307 | ||
|
|
e4447ee1b9 | ||
|
|
356390c92b | ||
|
|
ab6893adeb | ||
|
|
55133b028a | ||
|
|
1fbd69b718 | ||
|
|
97239268ac | ||
|
|
10bf065a2a | ||
|
|
107106d759 | ||
|
|
622a5db8d1 | ||
|
|
1e082f941a | ||
|
|
8824f4f3da | ||
|
|
f8bf6b5cc8 | ||
|
|
be844a5184 | ||
|
|
6ec67f7417 | ||
|
|
2c468c7225 | ||
|
|
af7cd7b009 | ||
|
|
cba081379e | ||
|
|
a9064baefc | ||
|
|
31627bb704 | ||
|
|
c4cfabfbaf | ||
|
|
9d25f8049c | ||
|
|
2d50f18dcf | ||
|
|
89b0711464 | ||
|
|
34cb3ad375 | ||
|
|
d2609e4291 | ||
|
|
77a8857e2f | ||
|
|
53d3bda326 | ||
|
|
570588f498 | ||
|
|
60e1a93966 | ||
|
|
3115fae807 | ||
|
|
c2767a866f | ||
|
|
60714b0b0b | ||
|
|
c32cdf5d98 | ||
|
|
c1598f3d4e | ||
|
|
94c45891b4 | ||
|
|
9d3426877d | ||
|
|
d3d9a9300b | ||
|
|
472b97e89e | ||
|
|
a47e0e8266 | ||
|
|
f412ddc716 | ||
|
|
859c5950f4 | ||
|
|
b9f63bcfb1 | ||
|
|
86b79a2685 | ||
|
|
c502aa7d1c | ||
|
|
7f587070e5 | ||
|
|
e121b480ef | ||
|
|
22668d76ee | ||
|
|
2c0c5ba425 | ||
|
|
6670dfdf07 | ||
|
|
b426f4f904 | ||
|
|
e8387b363f | ||
|
|
a6e0280ac1 | ||
|
|
5ab22af9c3 | ||
|
|
4263860760 | ||
|
|
9a3668c16b | ||
|
|
3a538a97d6 | ||
|
|
c637dcbbd8 | ||
|
|
6c68f2950a | ||
|
|
9310f606ad | ||
|
|
7ea6c542cf | ||
|
|
5b4c5b063d | ||
|
|
8afb5b3a7c | ||
|
|
8fbe2199f4 | ||
|
|
62a5254add | ||
|
|
ab5ba6fc80 | ||
|
|
f9d46904f6 | ||
|
|
6545dbd483 | ||
|
|
353853af14 | ||
|
|
6fef3990a7 | ||
|
|
571f5577d1 | ||
|
|
e2de89c763 | ||
|
|
c481e1fe0f | ||
|
|
9034c16e27 | ||
|
|
95927fc5d8 | ||
|
|
4023d7856b | ||
|
|
8307753d53 | ||
|
|
c7fb339ee9 | ||
|
|
ca50c5178a | ||
|
|
33e58ec45a | ||
|
|
e175b5a5a2 | ||
|
|
6072606918 | ||
|
|
3a04e891b4 | ||
|
|
862cc8d982 | ||
|
|
fc29a3ad95 | ||
|
|
ae9af3bf0b | ||
|
|
1bdaaf0d0e | ||
|
|
f3e99f9092 | ||
|
|
ba315f2159 | ||
|
|
1863e57f5b | ||
|
|
a0454943a7 | ||
|
|
a3130d498e | ||
|
|
3168438d6f | ||
|
|
9202d799c6 | ||
|
|
c9f10fb5cc | ||
|
|
b3fde5a2da | ||
|
|
8339fc735c | ||
|
|
3e2f7a5976 | ||
|
|
9c732ae19f | ||
|
|
7e26c0633a | ||
|
|
226a557d5c | ||
|
|
08021138ae | ||
|
|
b16fa6d5c0 | ||
|
|
6203eedbe5 | ||
|
|
db8c30ddbb | ||
|
|
0e9202ae4d | ||
|
|
85f6628718 | ||
|
|
8dab64b9bb | ||
|
|
8b521aa572 | ||
|
|
663fd22e03 | ||
|
|
1f933fb26a | ||
|
|
0faa56f5ff | ||
|
|
9c2aac31b6 | ||
|
|
fdab56936f | ||
|
|
c0aa263f2b | ||
|
|
6c151654cc | ||
|
|
87c9d4d823 | ||
|
|
fd53999777 | ||
|
|
ce60bd10b6 | ||
|
|
ee0492771d | ||
|
|
964e75e191 | ||
|
|
cb16be6b1d | ||
|
|
84a2a7340c | ||
|
|
a383925661 | ||
|
|
c49928bf3b | ||
|
|
02fc57961b | ||
|
|
9923b60d64 | ||
|
|
f243162724 | ||
|
|
b35ab94509 | ||
|
|
4f399b51f5 | ||
|
|
9e14b34e1f | ||
|
|
bf54ee2e9d | ||
|
|
9d2ed1cdbd | ||
|
|
db9237451f | ||
|
|
6277fbbea1 | ||
|
|
1b21e02b7a | ||
|
|
1a5b012545 | ||
|
|
f4aa4ed912 | ||
|
|
d075ee3111 | ||
|
|
5917238a62 | ||
|
|
b2429e3cff | ||
|
|
90aa385326 | ||
|
|
39d9eec890 | ||
|
|
cfff3a4b4a | ||
|
|
5abbec9dea | ||
|
|
7b6ca7cd73 | ||
|
|
b3f0ba348a | ||
|
|
5d2f648d03 | ||
|
|
e00da5a721 | ||
|
|
ca7bea3f24 | ||
|
|
d344924a3c | ||
|
|
7f405e5212 | ||
|
|
c2f38396eb | ||
|
|
501726c6ad | ||
|
|
d07f18bd41 | ||
|
|
931782df1c | ||
|
|
8e65e31ed6 | ||
|
|
221e28c996 | ||
|
|
579c869688 | ||
|
|
8a4decd48b | ||
|
|
66b3572dfe | ||
|
|
7172ed303e | ||
|
|
883c81320b | ||
|
|
7ad1251c89 | ||
|
|
75317d278d | ||
|
|
5438d7bbd9 | ||
|
|
8dc5f0a5ac | ||
|
|
9c38987dd4 | ||
|
|
0c6c7f999f | ||
|
|
2ae5d4dcf5 | ||
|
|
473b870035 | ||
|
|
5e9e995e4b | ||
|
|
270b81fafd | ||
|
|
191e47eb14 | ||
|
|
dc9c21fc65 | ||
|
|
1afa21c199 | ||
|
|
7dc65a8c98 | ||
|
|
20ff1725e7 | ||
|
|
a0759f68b2 | ||
|
|
08135baae6 | ||
|
|
bf40d8feaf | ||
|
|
2c54cafdb6 | ||
|
|
3ff75db00a | ||
|
|
469677aba5 | ||
|
|
da2c81818e | ||
|
|
c1a71afd77 | ||
|
|
7fe2b843e6 | ||
|
|
2c490e4148 | ||
|
|
6241999d2f | ||
|
|
6b315d52a8 | ||
|
|
0143e89e27 | ||
|
|
7130513593 | ||
|
|
a721826821 | ||
|
|
53e3600af7 | ||
|
|
43d838548b | ||
|
|
5b6c0317f6 | ||
|
|
9fe0ee119b | ||
|
|
c70d0352ea | ||
|
|
9a8449e4fe | ||
|
|
fa166483bb | ||
|
|
986b4df997 | ||
|
|
fbec88f6c8 | ||
|
|
adc88d53d3 | ||
|
|
ab17dd5812 | ||
|
|
cc42ac3584 | ||
|
|
4bba52a09e | ||
|
|
00abae10ac | ||
|
|
eafec3045d | ||
|
|
343d0a7baa | ||
|
|
b688f6f7d0 | ||
|
|
9cc03948fa | ||
|
|
c80d5c6bbf | ||
|
|
271b36c602 | ||
|
|
b2a885bf3f | ||
|
|
c0c33d80c7 | ||
|
|
9ff497914e | ||
|
|
726b4fcc0b | ||
|
|
56860da4af | ||
|
|
929a97622c | ||
|
|
09d25c04bc | ||
|
|
15f5f55833 | ||
|
|
a243b0a853 | ||
|
|
d76a838165 | ||
|
|
22595b0f24 | ||
|
|
9f6bbac8ce | ||
|
|
101f94bea6 | ||
|
|
52847b06f4 | ||
|
|
3c9cad2717 | ||
|
|
3acb9c456e | ||
|
|
615e86d177 | ||
|
|
9d7820d155 | ||
|
|
275044bd78 | ||
|
|
2f21c896a6 | ||
|
|
04a374c380 | ||
|
|
4b3b9524ef | ||
|
|
9e293136b9 | ||
|
|
45cda9b0cc | ||
|
|
cd5fef6891 | ||
|
|
a9a11f464b | ||
|
|
2a4288e11d | ||
|
|
47410ce6cc | ||
|
|
f9207ecd0f | ||
|
|
fdefd02812 | ||
|
|
4cdeef212d | ||
|
|
258db5c7e8 | ||
|
|
b59ef15cae | ||
|
|
94f5d4d503 | ||
|
|
9ed2a99f50 | ||
|
|
9f86444e0a | ||
|
|
7bad5d10d8 | ||
|
|
f23bf270d0 | ||
|
|
8839909425 | ||
|
|
ec1b0b3641 | ||
|
|
5882218cff | ||
|
|
cffc2695dd | ||
|
|
49eb049533 | ||
|
|
4009a1a25c | ||
|
|
eec4d7863d | ||
|
|
0d79138e0b | ||
|
|
75914da36a | ||
|
|
cf876acc5d | ||
|
|
6d6cb755e2 | ||
|
|
555b1ad996 | ||
|
|
15a8d6dbf1 | ||
|
|
c3256824b2 | ||
|
|
9c68142a0d | ||
|
|
b496f7731a | ||
|
|
d203092ba7 | ||
|
|
f771bb8043 | ||
|
|
f9b2a71b86 | ||
|
|
8479be5bd4 | ||
|
|
4eddad2cf1 | ||
|
|
8b31e8a9c4 | ||
|
|
d5245f7e7c | ||
|
|
56bb5433a0 | ||
|
|
3d8396a35a | ||
|
|
1a58f76ab7 | ||
|
|
4279962e59 | ||
|
|
503df0842b | ||
|
|
4a93adb1f8 | ||
|
|
2674b3a20e | ||
|
|
3d072f73b7 | ||
|
|
4fc5932899 | ||
|
|
077c20fd34 | ||
|
|
d9716ceb42 | ||
|
|
51af4b32d2 | ||
|
|
08cae822f1 | ||
|
|
30d6fc0fc1 | ||
|
|
462bf81be1 | ||
|
|
26758b965d | ||
|
|
917e4f5cbf | ||
|
|
f02621d1d8 | ||
|
|
45e0998172 | ||
|
|
755368440b | ||
|
|
728ccaefa7 | ||
|
|
ecaa0ec4bb | ||
|
|
d94eb26fa8 | ||
|
|
9774661a40 | ||
|
|
38128f7864 | ||
|
|
1f8213e315 | ||
|
|
fce9a25280 | ||
|
|
b2de5d47dd | ||
|
|
77f583f91f | ||
|
|
4a56726f61 | ||
|
|
4f3dacf81a | ||
|
|
14593f6a51 | ||
|
|
ae16d01734 | ||
|
|
6069af3e90 | ||
|
|
4c2408dfda | ||
|
|
0bd122b643 | ||
|
|
d747ba0b14 | ||
|
|
91484fd09f | ||
|
|
d427a4bd6c | ||
|
|
b455827c17 | ||
|
|
b6363957a8 | ||
|
|
ad27f76c4a | ||
|
|
eeec7142c7 | ||
|
|
efd758d627 | ||
|
|
608d5e332d | ||
|
|
cf4fa9e76d | ||
|
|
2ebb77ff37 | ||
|
|
744154ccf4 | ||
|
|
90c38d0403 | ||
|
|
55d7ef1036 | ||
|
|
7c37c9a4c8 | ||
|
|
54d09ceb51 | ||
|
|
84e21ab992 | ||
|
|
cb9ddb299a | ||
|
|
9e5fdb7cb1 | ||
|
|
97c1d6d73a | ||
|
|
0f0fc2a8f7 | ||
|
|
c41344e2a5 | ||
|
|
07b438f6f0 | ||
|
|
2bf7bf3071 | ||
|
|
5096964825 | ||
|
|
b96c2a731c | ||
|
|
4f1b768b6c | ||
|
|
d3002ce415 | ||
|
|
a5ba70a6d7 | ||
|
|
4118a576de | ||
|
|
4bcbcf4e3a | ||
|
|
9775cc796a | ||
|
|
d269029bb1 | ||
|
|
786a94b448 | ||
|
|
80c87c8c43 | ||
|
|
39d78fb444 | ||
|
|
ac1de4c995 | ||
|
|
6f347bd49e | ||
|
|
cc31143a6c | ||
|
|
86430c6408 | ||
|
|
1cc031b27b | ||
|
|
592c5ca778 | ||
|
|
bad6346e7a | ||
|
|
cf10791c95 | ||
|
|
3f68c0001e | ||
|
|
07ec5c3b12 | ||
|
|
2d8f97ff35 | ||
|
|
28ae1ae3d4 | ||
|
|
b9e1c0ba56 | ||
|
|
317cc8eeee | ||
|
|
3ddee386b2 | ||
|
|
9f946f3514 | ||
|
|
904198013e | ||
|
|
ac7c7cee84 | ||
|
|
6c3d33d4a1 | ||
|
|
3853c6ca18 | ||
|
|
f3b7d33372 | ||
|
|
7a67577aa1 | ||
|
|
fe3e02cb2e | ||
|
|
821d7845f0 | ||
|
|
f7709e19fd | ||
|
|
fdfc5e0e68 | ||
|
|
b331a07b69 | ||
|
|
f5bf566e66 | ||
|
|
f80d796333 | ||
|
|
afd55c52e5 | ||
|
|
b4cc300190 | ||
|
|
03af305761 | ||
|
|
079f4e5ac0 | ||
|
|
ff075a4f56 | ||
|
|
b031e1a232 | ||
|
|
175412774c | ||
|
|
d0efe8eb47 | ||
|
|
be0d5557a6 | ||
|
|
24f6fcd2d4 | ||
|
|
9bef53c16c | ||
|
|
9b70985d32 | ||
|
|
a0a3d8ef3a | ||
|
|
4ae406b3e1 | ||
|
|
51f2009a2c | ||
|
|
8d172e0b57 | ||
|
|
4a132e769c | ||
|
|
786110359a | ||
|
|
3b5560b741 | ||
|
|
0351f4fbb3 | ||
|
|
a695f85359 | ||
|
|
d0aa601301 | ||
|
|
508e98e3b6 | ||
|
|
1227772696 | ||
|
|
0aa5c73635 | ||
|
|
afda97870a | ||
|
|
d56cdeec77 | ||
|
|
657914db26 | ||
|
|
0495aecf4c | ||
|
|
f539a236f1 | ||
|
|
52a9e500df | ||
|
|
df0da84b9f | ||
|
|
daaad1ce42 | ||
|
|
26b7075696 | ||
|
|
b526734e4b | ||
|
|
eaca7df527 | ||
|
|
1413bb7fbf | ||
|
|
5a09de002a | ||
|
|
5634f46bd5 | ||
|
|
a2f8435204 | ||
|
|
f076829b9b | ||
|
|
47aab98964 | ||
|
|
4f696a4947 | ||
|
|
828e769b3b | ||
|
|
69dc26005a | ||
|
|
6bbe2687ef | ||
|
|
f19bf17d21 | ||
|
|
d09e2237ee | ||
|
|
10830dee36 | ||
|
|
5f7f670517 | ||
|
|
c1edbbf047 | ||
|
|
7168f6c75e | ||
|
|
57582aa90d | ||
|
|
732dced999 | ||
|
|
eb9df59f15 | ||
|
|
e247b9e5f9 | ||
|
|
c1fd964344 | ||
|
|
f206ce7114 | ||
|
|
7438385729 | ||
|
|
142ca1e32c | ||
|
|
cde761fadd | ||
|
|
16eb3f021d | ||
|
|
9c15ddf952 | ||
|
|
24216d7b4f | ||
|
|
833ddf15d5 | ||
|
|
63a94dcef1 | ||
|
|
807bf4c061 | ||
|
|
a4ba130e5f | ||
|
|
d06145bb3a | ||
|
|
935e04b266 | ||
|
|
35fec3150b | ||
|
|
493238e696 | ||
|
|
cffc44b471 | ||
|
|
fcab3b1d46 | ||
|
|
1badaa725e | ||
|
|
76579c426e | ||
|
|
5f69395522 | ||
|
|
9ceec6e306 | ||
|
|
a67913d660 | ||
|
|
7da6b1925d | ||
|
|
fb05558ab9 | ||
|
|
33835fef7b | ||
|
|
f1f089baa0 | ||
|
|
d8f32d6ade | ||
|
|
ca04ebccd2 | ||
|
|
d11a67527f | ||
|
|
625fb41352 | ||
|
|
e0e90e3cab | ||
|
|
4c4a6f668d | ||
|
|
b7d8447f73 | ||
|
|
18651bdaf4 | ||
|
|
9046a6578a | ||
|
|
4219842088 | ||
|
|
3b41d6fff6 | ||
|
|
b2eaca27de | ||
|
|
9ccb04489b | ||
|
|
6b164b5487 | ||
|
|
073493cba2 | ||
|
|
7f5e0f7244 | ||
|
|
7bb6f55153 | ||
|
|
7f21bea8a6 | ||
|
|
802e7a080f | ||
|
|
a7c1a75996 | ||
|
|
70803b1a6d | ||
|
|
005b9a88c3 | ||
|
|
7214b5beea | ||
|
|
e2163546fe | ||
|
|
17bc9c231a | ||
|
|
0b4e9da5d5 | ||
|
|
002fa82afb | ||
|
|
482baef360 | ||
|
|
20b876bdd6 | ||
|
|
d00ac1fa86 | ||
|
|
02e5b6e975 | ||
|
|
1f17eda56f | ||
|
|
718f9a4f11 | ||
|
|
668276e7d0 | ||
|
|
3de769c7e7 | ||
|
|
984bbd493d | ||
|
|
74eabba641 | ||
|
|
81d54ca69f | ||
|
|
ddee63b718 | ||
|
|
99743e8e0f | ||
|
|
7d11539827 | ||
|
|
7079098810 | ||
|
|
e12ac26406 | ||
|
|
64e755bc15 | ||
|
|
a65b6bf19d | ||
|
|
5143040ba9 | ||
|
|
01c75ad5c0 | ||
|
|
43d0197a60 | ||
|
|
35013a71d4 | ||
|
|
381acbf00f | ||
|
|
47fb13aa4a | ||
|
|
f5aca6aa93 | ||
|
|
95f4670b47 | ||
|
|
acc2412742 | ||
|
|
77df8cc69b | ||
|
|
eaf651a4e2 | ||
|
|
3d1a050e22 | ||
|
|
2db053ab04 | ||
|
|
915418eca8 | ||
|
|
508a707c6e | ||
|
|
233d82805b | ||
|
|
8c4e5e5185 | ||
|
|
d3ca556914 | ||
|
|
325a92f40d | ||
|
|
a89481cf14 | ||
|
|
65be8ba6f7 | ||
|
|
a2b9b9e0c8 | ||
|
|
20bd5327f8 | ||
|
|
4ac045afcf | ||
|
|
4569ee50a5 | ||
|
|
cd2ea1d5fc | ||
|
|
ab34f7c056 | ||
|
|
a27a5ebf4c | ||
|
|
be20fcf021 | ||
|
|
f62317a338 | ||
|
|
d27243eb34 | ||
|
|
8f5e26acd1 | ||
|
|
e02f20d74e | ||
|
|
b0111d6653 | ||
|
|
a38d8c14fa | ||
|
|
77e1898512 | ||
|
|
29110e96e5 | ||
|
|
02e2b51814 | ||
|
|
e9588cc7a1 | ||
|
|
3d8e1b8f86 | ||
|
|
312f969364 | ||
|
|
0f41079803 | ||
|
|
7f30e8dadc | ||
|
|
9cd20cfc05 | ||
|
|
1343bbf6ea | ||
|
|
90c39d1f85 | ||
|
|
92da61376b | ||
|
|
69976c2caf | ||
|
|
3c6fd0c83a | ||
|
|
b0a09747d4 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -29,7 +29,7 @@ assignees: ''
|
|||||||
*If applicable, add screenshots to help explain your problem.*
|
*If applicable, add screenshots to help explain your problem.*
|
||||||
|
|
||||||
**Device information**
|
**Device information**
|
||||||
*Copy-paste here the information as it is outputted by the device. You can get this information by from http://ems-esp.local/api?device=system&cmd=info*
|
*Copy-paste here the information as it is outputted by the device. You can get this information by from http://ems-esp.local/api/system*
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
*Add any other context about the problem here.*
|
*Add any other context about the problem here.*
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ assignees: ''
|
|||||||
*If applicable, add screenshots to help explain your problem.*
|
*If applicable, add screenshots to help explain your problem.*
|
||||||
|
|
||||||
**Device information**
|
**Device information**
|
||||||
*Copy-paste here the information as it is outputted by the device. You can get this information from http://ems-esp.local/api?device=system&cmd=info*
|
*Copy-paste here the information as it is outputted by the device. You can get this information from http://ems-esp.local/api/system*
|
||||||
|
|
||||||
**Additional context**
|
**Additional context**
|
||||||
*Add any other context about the problem here.*
|
*Add any other context about the problem here.*
|
||||||
|
|||||||
57
.github/workflows/sonar_check.yml
vendored
Normal file
57
.github/workflows/sonar_check.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Sonar Check
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository_owner == 'emsesp'
|
||||||
|
# if: github.repository == 'emsesp/EMS-ESP32'
|
||||||
|
env:
|
||||||
|
# https://binaries.sonarsource.com/?prefix=Distribution/sonar-scanner-cli/
|
||||||
|
SONAR_SCANNER_VERSION: 4.7.0.2747
|
||||||
|
SONAR_SERVER_URL: "https://sonarcloud.io"
|
||||||
|
BUILD_WRAPPER_OUT_DIR: build_wrapper_output_directory
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
- name: Set up JDK 11
|
||||||
|
uses: actions/setup-java@v1
|
||||||
|
with:
|
||||||
|
java-version: 11
|
||||||
|
- name: Cache SonarCloud packages
|
||||||
|
uses: actions/cache@v1
|
||||||
|
with:
|
||||||
|
path: ~/.sonar/cache
|
||||||
|
key: ${{ runner.os }}-sonar
|
||||||
|
restore-keys: ${{ runner.os }}-sonar
|
||||||
|
- name: Download and set up sonar-scanner
|
||||||
|
env:
|
||||||
|
SONAR_SCANNER_DOWNLOAD_URL: https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${{ env.SONAR_SCANNER_VERSION }}-linux.zip
|
||||||
|
run: |
|
||||||
|
mkdir -p $HOME/.sonar
|
||||||
|
curl -sSLo $HOME/.sonar/sonar-scanner.zip ${{ env.SONAR_SCANNER_DOWNLOAD_URL }}
|
||||||
|
unzip -o $HOME/.sonar/sonar-scanner.zip -d $HOME/.sonar/
|
||||||
|
echo "$HOME/.sonar/sonar-scanner-${{ env.SONAR_SCANNER_VERSION }}-linux/bin" >> $GITHUB_PATH
|
||||||
|
- name: Download and set up build-wrapper
|
||||||
|
env:
|
||||||
|
BUILD_WRAPPER_DOWNLOAD_URL: ${{ env.SONAR_SERVER_URL }}/static/cpp/build-wrapper-linux-x86.zip
|
||||||
|
run: |
|
||||||
|
curl -sSLo $HOME/.sonar/build-wrapper-linux-x86.zip ${{ env.BUILD_WRAPPER_DOWNLOAD_URL }}
|
||||||
|
unzip -o $HOME/.sonar/build-wrapper-linux-x86.zip -d $HOME/.sonar/
|
||||||
|
echo "$HOME/.sonar/build-wrapper-linux-x86" >> $GITHUB_PATH
|
||||||
|
- name: Run build-wrapper
|
||||||
|
run: |
|
||||||
|
make clean
|
||||||
|
build-wrapper-linux-x86-64 --out-dir ${{ env.BUILD_WRAPPER_OUT_DIR }} make clean all
|
||||||
|
- name: Run sonar-scanner
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
run: |
|
||||||
|
sonar-scanner
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -30,3 +30,8 @@ node_modules
|
|||||||
test.sh
|
test.sh
|
||||||
scripts/__pycache__
|
scripts/__pycache__
|
||||||
.temp
|
.temp
|
||||||
|
|
||||||
|
# sonar
|
||||||
|
.scannerwork/
|
||||||
|
sonar/
|
||||||
|
build_wrapper_output_directory/
|
||||||
|
|||||||
171
CHANGELOG.md
171
CHANGELOG.md
@@ -5,7 +5,140 @@ 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).
|
||||||
|
|
||||||
# Changelog
|
# [3.4.2]
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- RC310 additions [#520](https://github.com/emsesp/EMS-ESP32/pull/520)
|
||||||
|
- damping
|
||||||
|
- wwprio for RC310 heating circuits
|
||||||
|
- switchonoptimization for RC310 heating circuits
|
||||||
|
- enum_controlmode for RC310 (new enum list)
|
||||||
|
- nofrostmode, reducemode, reducetemp & noreducetemp for RC310
|
||||||
|
- emergencyops and emergencytemp, wwmaxtemp, wwflowtempoffset and wwcomfort1 for RC310
|
||||||
|
- HM200 hybrid module [#500](https://github.com/emsesp/EMS-ESP32/issues/500)
|
||||||
|
- AM200 alternative heatsource module [#573](https://github.com/emsesp/EMS-ESP32/issues/573)
|
||||||
|
- EM10 error module as gateway [#575](https://github.com/emsesp/EMS-ESP32/issues/575)
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- fix Table resizing in WebUI [#519](https://github.com/emsesp/EMS-ESP32/issues/519)
|
||||||
|
- allow larger customization files [#570](https://github.com/emsesp/EMS-ESP32/issues/570)
|
||||||
|
- losing entitiy wwcomfort [#581](https://github.com/emsesp/EMS-ESP32/issues/581)
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Shorten "friendly names" in Home Assistant [#555](https://github.com/emsesp/EMS-ESP32/issues/555)
|
||||||
|
|
||||||
|
- platformio 2.3.0 (IDF 4, Arduino 2)
|
||||||
|
- remove master-thermostat, support multiple thermostats
|
||||||
|
- merge up- and download in webui [#577](https://github.com/emsesp/EMS-ESP32/issues/577)
|
||||||
|
|
||||||
|
# [3.4.1] May 29 2022
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- Fix memory leak in api [#524](https://github.com/emsesp/EMS-ESP32/issues/524)
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
# [3.4.0] May 23 2022
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
- WebUI optimizations, updated look&feel and better performance [#124](https://github.com/emsesp/EMS-ESP32/issues/124)
|
||||||
|
- Auto refresh of WebUI after successful firmware upload [#178](https://github.com/emsesp/EMS-ESP32/issues/178)
|
||||||
|
- New Customization Service in WebUI. First feature is the ability to enable/disabled Enitites (device values) from EMS devices [#206](https://github.com/emsesp/EMS-ESP32/issues/206)
|
||||||
|
- Option to disable Telnet Console [#209](https://github.com/emsesp/EMS-ESP32/issues/209)
|
||||||
|
- Added Hide SSID, Max Clients and Preferred Channel to Access Point
|
||||||
|
- Merged in MichaelDvP's changes like Fahrenheit conversion, publish single (for IOBroker) and a few other critical optimizations
|
||||||
|
- Enabled bi-directional read/write with Home Assistant, so values can be changed automatically from the UI without scripting [#265](https://github.com/emsesp/EMS-ESP32/issues/265)
|
||||||
|
- Added GC7000F Boiler [#270](https://github.com/emsesp/EMS-ESP32/issues/270)
|
||||||
|
- Revised LED flash sequence on boot up to show system health (1 flash=no ems, 2 flashes=no wifi) [#224](https://github.com/emsesp/EMS-ESP32/issues/224)
|
||||||
|
- Analog Sensor support [#271](https://github.com/emsesp/EMS-ESP32/issues/271)
|
||||||
|
- Solar cylinder priority [#247](https://github.com/emsesp/EMS-ESP32/issues/247)
|
||||||
|
- Read only mode in Settings, where EMS Tx/Write commands are blocked [#286](https://github.com/emsesp/EMS-ESP32/issues/286)
|
||||||
|
- Added 8700i Boiler device
|
||||||
|
- Added Cascade CM10 Controller device
|
||||||
|
- Add Olimex ESP32-POE-ISO to board profiles plus settings to customize Ethernet modules [#301](https://github.com/emsesp/EMS-ESP32/issues/301)
|
||||||
|
- Help text for string commands in WebUI [#320](https://github.com/emsesp/EMS-ESP32/issues/320)
|
||||||
|
- Germany translations (at compile time)
|
||||||
|
- #entities added to system/info` endpoint [#322](https://github.com/emsesp/EMS-ESP32/issues/322)
|
||||||
|
- analog outputs digital/pwm/dac
|
||||||
|
- remove MQTT retained configs if discovery is disabled
|
||||||
|
- timeout 10 min for MQTT-QoS wait
|
||||||
|
- Moduline 300 auto-temperatures T1-T4, RC300 romminfluencefactor
|
||||||
|
- RC35 parameters [#392](https://github.com/emsesp/EMS-ESP32/issues/392), [#398](https://github.com/emsesp/EMS-ESP32/issues/398)
|
||||||
|
- sync time with thermostat [#386](https://github.com/emsesp/EMS-ESP32/issues/386), [#408](https://github.com/emsesp/EMS-ESP32/issues/408)
|
||||||
|
- set mode has immediate effect [#395](https://github.com/emsesp/EMS-ESP32/issues/395)
|
||||||
|
- min/max in web value setting
|
||||||
|
- Extend customization to select if an entity is to be shown in the WebUI or forced as read-only [#317](https://github.com/emsesp/EMS-ESP32/issues/317)
|
||||||
|
- Added Moduline 400 installation parameters [PR #449 by @kwertie01](https://github.com/emsesp/EMS-ESP32/pull/449)
|
||||||
|
- Read time from IVT-controller [#439](https://github.com/emsesp/EMS-ESP32/issues/439)
|
||||||
|
- Hybrid Heatpump product-id 168 [#459](https://github.com/emsesp/EMS-ESP32/issues/459), thermostat settings
|
||||||
|
- Junkers ISM2 and IPM in warm water mode [#437](https://github.com/emsesp/EMS-ESP32/issues/437)
|
||||||
|
- Added Shower Alert trigger time and cold shot time [#436](https://github.com/emsesp/EMS-ESP32/issues/436)
|
||||||
|
- Improved Table layout in Web UI (searching, filtering, sorting, exporting to CSV)
|
||||||
|
- API fetch individual attributes from an entity [#462](https://github.com/emsesp/EMS-ESP32/issues/462)
|
||||||
|
- Option to disable mDNS
|
||||||
|
- Option for rendering booleans on dashboard [#456](https://github.com/emsesp/EMS-ESP32/issues/456)
|
||||||
|
- Upload customization settings from a file [#256](https://github.com/emsesp/EMS-ESP32/issues/256)
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- lastcode broke MQTT JSON structure [#228](https://github.com/emsesp/EMS-ESP32/issues/228)
|
||||||
|
- fixed issue with overlapping while reading sequence of EMS1.0 telegrams
|
||||||
|
- fixed redundant telegram readings (because of offset overflow)
|
||||||
|
- added missing RC30/Moduline 400 [#243](https://github.com/emsesp/EMS-ESP32/issues/243)
|
||||||
|
- Correct modes for RC25 [#106](https://github.com/emsesp/EMS-ESP32/issues/106)
|
||||||
|
- Clean up old HA config's in MQTT before publishing data. This will prevent HA giving the 'dict' warnings [#229](https://github.com/emsesp/EMS-ESP32/issues/229)
|
||||||
|
- RC25 temperature setting [#272](https://github.com/emsesp/EMS-ESP32/issues/272)
|
||||||
|
- Buderus RC25 - "hc1 mode type" incorrect value [#273](https://github.com/emsesp/EMS-ESP32/issues/273)
|
||||||
|
- Increased number of Mixers and Heating Circuits [#294](https://github.com/emsesp/EMS-ESP32/issues/294)
|
||||||
|
- Check receive status before removing a telegram fetch [#268](https://github.com/emsesp/EMS-ESP32/issues/268), [#282](https://github.com/emsesp/EMS-ESP32/issues/282)
|
||||||
|
- Fix uploading firmware on OSX [#345](https://github.com/emsesp/EMS-ESP32/issues/345)
|
||||||
|
- Non-nested MQTT would corrupt the json [#354](https://github.com/emsesp/EMS-ESP32/issues/354)
|
||||||
|
- Burner selected max power can have a value higher than 100% [#314](https://github.com/emsesp/EMS-ESP32/issues/314)
|
||||||
|
- some missing fahrenheit calculations
|
||||||
|
- limited number of exclusions [#339](https://github.com/emsesp/EMS-ESP32/issues/339)
|
||||||
|
- MQTT sometimes would not reconnect after a WiFi outage
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
- Use flash system to show system health (1 flash=no ems, 2 flashes=no wifi) [#224](https://github.com/emsesp/EMS-ESP32/issues/224)
|
||||||
|
- Renamed Dallas Sensor to Temperature Sensor in UI
|
||||||
|
- Dallas Format removed. Use the name to give each sensor an alias
|
||||||
|
- No longer MQTT subscribes to topic `/thermostat_hc<n>` as it supports a path similar to the API endpoint construct
|
||||||
|
- Show Sensors quality in WebUI
|
||||||
|
- Controller not shown in WebUI dashboard
|
||||||
|
- renamed "Home Assistant Integration" to "MQTT Discovery" in MQTT Settings [#290](https://github.com/emsesp/EMS-ESP32/issues/290)
|
||||||
|
- Show ems tx reads and writes separately
|
||||||
|
- Show ems device handlers separated for received, fetched and pending handlers.
|
||||||
|
- Wired renamed to Ethernet
|
||||||
|
- removed system/pin command, new commands in analogsensors
|
||||||
|
- system/info device-info split to name/version/brand
|
||||||
|
- exclude list uses short-names, possible flags for web/api/mqtt excludes, readonly and favorite (selection not yet implemented)
|
||||||
|
- thermostat clock formate date-time: dd.mm.yyyy hh:mm
|
||||||
|
- RC300 summermode as other thermostats `winter/summer` instead of `off/on`
|
||||||
|
|
||||||
|
## **BREAKING CHANGES:**
|
||||||
|
|
||||||
|
- Settings:
|
||||||
|
- order of Boolean Format has changed in Application Settings - check your settings
|
||||||
|
- Dallas Format setting removed. Now customize name of each Dallas sensor via the UI
|
||||||
|
- MQTT/API
|
||||||
|
- Boiler `wwheat` renamed to `ww3wayon` [#211](https://github.com/emsesp/EMS-ESP32/issues/211)
|
||||||
|
- Boiler `ww` tag renamed to `dhw`. Any custom Home Assistant lovelace dashboards will need updating.
|
||||||
|
- Renamed description of `wwtapactivated` to "turn on/off DHW". Otherwise would have looked like "boiler_dhw_turn_on_off_dhw" in HA.
|
||||||
|
- `/api/system/info` endpoint has updated keys. Now lowercase, no underscores and not capitalized. Replace "handlers" with "handlers received", "handlers fetched" and "handlers pending".
|
||||||
|
|
||||||
|
# [3.3.1] January 20 2022
|
||||||
|
|
||||||
|
- lastcode broke MQTT JSON structure [#228](https://github.com/emsesp/EMS-ESP32/issues/228)
|
||||||
|
- overlapping while reading sequence of EMS1.0 telegrams
|
||||||
|
- redundant telegram readings (because of offset overflow)
|
||||||
|
- added missing RC30/Moduline400 [#243](https://github.com/emsesp/EMS-ESP32/issues/243)
|
||||||
|
- check received status before toggling fetch on empty telegram [#268][#282]
|
||||||
|
|
||||||
# [3.3.0] November 28 2021
|
# [3.3.0] November 28 2021
|
||||||
|
|
||||||
@@ -174,51 +307,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
|
||||||
- power settings, disabling BLE and turning off Wifi sleep
|
|
||||||
- Rx and Tx counts to Heartbeat MQTT payload
|
|
||||||
- ethernet support
|
|
||||||
- id to info command to show only a heatingcircuit
|
|
||||||
- add sending devices that are not listed to 0x07
|
|
||||||
- extra MQTT boolean option for "ON" and "OFF"
|
|
||||||
- support for chunked MQTT payloads to allow large data sets > 2kb
|
|
||||||
- external Button support (#708) for resetting to factory defaults and other actions
|
|
||||||
- new console set command in `system`, `set board_profile <profile>` for quickly enabling cabled ethernet connections without using the captive wifi portal
|
|
||||||
- added in MQTT nested mode, for thermostat and mixer, like we had back in v2
|
|
||||||
- cascade MC400 (product-id 210) (3.0.0b6), power values for heating sources (3.0.1b1)
|
|
||||||
- values for wwMaxPower, wwFlowtempOffset
|
|
||||||
- RC300 `thermostat temp -1` to clear temporary setpoint in auto mode
|
|
||||||
- syslog port selectable (#744)
|
|
||||||
- individual mqtt commands (#31)
|
- individual mqtt commands (#31)
|
||||||
- board Profiles (#11)
|
- board Profiles (#11)
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- telegrams matched to masterthermostat 0x18
|
|
||||||
- multiple roomcontrollers
|
|
||||||
- readback after write with delay (give ems-devices time to set the value)
|
|
||||||
- thermostat ES72/RC20 device 66 to command-set RC20_2
|
|
||||||
- MQTT payloads not adding to queue when MQTT is re-connecting (fixes #369)
|
|
||||||
- fix for HA topics with invalid command formats (#728)
|
|
||||||
- wrong position of values #723, #732
|
|
||||||
- OTA Upload via Web on OSX
|
|
||||||
- Rx and Tx quality % would sometimes show > 100
|
- Rx and Tx quality % would sometimes show > 100
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
- changed how telegram parameters are rendered for mqtt, console and web (#632)
|
|
||||||
- split `show values` in smaller packages (edited)
|
|
||||||
- extended length of IP/hostname from 32 to 48 chars (#676)
|
|
||||||
- check flowsensor for `tap_water_active`
|
|
||||||
- mqtt prefixed with `Base`
|
|
||||||
- count Dallas sensor fails
|
|
||||||
- switch from SPIFFS to LITTLEFS
|
|
||||||
- added ID to MQTT payloads which is the Device's product ID and used in HA to identify a unique HA device
|
|
||||||
- increased MQTT buffer and reduced wait time between publishes
|
|
||||||
- updated to the latest ArduinoJson library
|
|
||||||
- some names of mqtt-tags like in v2.2.1
|
|
||||||
- new ESP32 partition side to allow for smoother OTA and fallback
|
|
||||||
- network Gateway IP is optional (#682)emsesp/EMS-ESP
|
|
||||||
- moved to a new GitHub repo https://github.com/emsesp/EMS-ESP32
|
|
||||||
- invert LED changed to Hide LED. Default is off.
|
- invert LED changed to Hide LED. Default is off.
|
||||||
- renamed Scan Network to Scan WiFi Network
|
- renamed Scan Network to Scan WiFi Network
|
||||||
- added version to cmd=settings
|
- added version to cmd=settings
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<img src="/media/EMS-ESP_logo_dark.png" alt="Logo" align="right" height="76"/>
|
|
||||||
|
|
||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
**Any contribution helps EMS-ESP get better for the entire community!**
|
**Any contribution helps EMS-ESP get better for the entire community!**
|
||||||
@@ -94,7 +92,7 @@ References:
|
|||||||
|
|
||||||
- <https://www.conventionalcommits.org/>
|
- <https://www.conventionalcommits.org/>
|
||||||
|
|
||||||
--------------------------------------
|
---
|
||||||
|
|
||||||
## Contributor License Agreement (CLA)
|
## Contributor License Agreement (CLA)
|
||||||
|
|
||||||
@@ -133,9 +131,9 @@ A CLA is a legal document in which you state _you are entitled to contribute the
|
|||||||
|
|
||||||
CLA is a safety because it also ensures that once you have provided a contribution, you cannot try to withdraw permission for its use at a later date. People can therefore use that software, confident that they will not be asked to stop using pieces of the code at a later date.
|
CLA is a safety because it also ensures that once you have provided a contribution, you cannot try to withdraw permission for its use at a later date. People can therefore use that software, confident that they will not be asked to stop using pieces of the code at a later date.
|
||||||
|
|
||||||
A __license__ grants "outbound" rights to the user of project.
|
A **license** grants "outbound" rights to the user of project.
|
||||||
|
|
||||||
A __CLA__ enables a contributor to grant "inbound" rights to a project.
|
A **CLA** enables a contributor to grant "inbound" rights to a project.
|
||||||
|
|
||||||
<Other>
|
<Other>
|
||||||
<A table should be maintained for relating maintainers and components. When triaging, this is essential to figure out if someone in particular should be consulted about specific changes.>
|
<A table should be maintained for relating maintainers and components. When triaging, this is essential to figure out if someone in particular should be consulted about specific changes.>
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -33,7 +33,7 @@ CXX_STANDARD := -std=c++11
|
|||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Defined Symbols
|
# Defined Symbols
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
DEFINES += -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
|
DEFINES += -DFACTORY_WIFI_HOSTNAME=\"ems-esp\" -DARDUINOJSON_ENABLE_STD_STRING=1 -DARDUINOJSON_ENABLE_PROGMEM=1 -DARDUINOJSON_ENABLE_ARDUINO_STRING -DARDUINOJSON_USE_DOUBLE=0 -DEMSESP_DEBUG -DEMSESP_STANDALONE -DEMSESP_USE_SERIAL -DEMSESP_DEFAULT_BOARD_PROFILE=\"LOLIN\"
|
||||||
|
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Sources & Files
|
# Sources & Files
|
||||||
@@ -73,8 +73,8 @@ CPPFLAGS += -Os
|
|||||||
|
|
||||||
CFLAGS += $(CPPFLAGS)
|
CFLAGS += $(CPPFLAGS)
|
||||||
CFLAGS += -Wall
|
CFLAGS += -Wall
|
||||||
CFLAGS += -Wno-unused -Wno-restrict
|
|
||||||
CFLAGS += -Wextra
|
CFLAGS += -Wextra
|
||||||
|
CFLAGS += -Wno-unused-parameter
|
||||||
|
|
||||||
CXXFLAGS += $(CFLAGS) -MMD
|
CXXFLAGS += $(CFLAGS) -MMD
|
||||||
|
|
||||||
@@ -113,6 +113,7 @@ COMPILE.cpp = $(CXX) $(CXX_STANDARD) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
|
|||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
# Targets
|
# Targets
|
||||||
#----------------------------------------------------------------------
|
#----------------------------------------------------------------------
|
||||||
|
.PHONY: all
|
||||||
all: $(OUTPUT)
|
all: $(OUTPUT)
|
||||||
|
|
||||||
$(OUTPUT): $(OBJS)
|
$(OUTPUT): $(OBJS)
|
||||||
@@ -138,8 +139,9 @@ cppcheck: $(SOURCES)
|
|||||||
run: $(OUTPUT)
|
run: $(OUTPUT)
|
||||||
@$<
|
@$<
|
||||||
|
|
||||||
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
@$(RM) -r $(BUILD) $(OUTPUT)
|
@$(RM) -rf $(BUILD) $(OUTPUT)
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo available targets: all run clean
|
@echo available targets: all run clean
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -15,6 +15,7 @@ This project is the specifically for the ESP32. Compared with the previous ESP82
|
|||||||
[](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
|
[](https://github.com/emsesp/EMS-ESP32/blob/main/CHANGELOG.md)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
[](https://github.com/emsesp/EMS-ESP32/commits/main)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://sonarcloud.io/summary/new_code?id=emsesp_EMS-ESP32)
|
||||||
[](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade)
|
[](https://www.codacy.com/gh/emsesp/EMS-ESP32/dashboard?utm_source=github.com&utm_medium=referral&utm_content=emsesp/EMS-ESP32&utm_campaign=Badge_Grade)
|
||||||
[](https://github.com/emsesp/EMS-ESP32/releases)
|
[](https://github.com/emsesp/EMS-ESP32/releases)
|
||||||
[](https://discord.gg/3J3GgnzpyT)
|
[](https://discord.gg/3J3GgnzpyT)
|
||||||
@@ -33,16 +34,16 @@ Note, EMS-ESP requires a small hardware circuit that can convert the EMS bus dat
|
|||||||
|
|
||||||
# **Features**
|
# **Features**
|
||||||
|
|
||||||
- A multi-user secure web interface to change settings and monitor the data
|
- A multi-user secure web interface to change settings and monitor incoming data
|
||||||
- A console, accessible via Serial and Telnet for more monitoring
|
- A console, accessible via Serial and Telnet for more advanced monitoring
|
||||||
- Native support for Home Assistant via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
|
- Native support for Home Assistant and Domoticz via [MQTT Discovery](https://www.home-assistant.io/docs/mqtt/discovery/)
|
||||||
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network
|
- Can run standalone as an independent WiFi Access Point or join an existing WiFi network
|
||||||
- Easy first-time configuration via a web Captive Portal
|
- Easy first-time configuration via a web Captive Portal
|
||||||
- Support for more than [80 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
|
- Support for more than [100 EMS devices](https://emsesp.github.io/docs/#/Supported-EMS-Devices) (boilers, thermostats, solar modules, mixer modules, heat pumps, gateways)
|
||||||
|
|
||||||
## **Demo**
|
## **Demo**
|
||||||
|
|
||||||
See a live demo [here](https://ems-esp.derbyshire.nl) using fake data. Log in with any username/password.
|
See a demo [here](https://ems-esp.derbyshire.nl). Log in with any username/password.
|
||||||
|
|
||||||
# **Screenshots**
|
# **Screenshots**
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@
|
|||||||
# Firmware Installation
|
# Firmware Installation
|
||||||
|
|
||||||
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ This is a snapshot of the current "beta" development code and firmware binaries
|
|||||||
# Firmware Installation
|
# Firmware Installation
|
||||||
|
|
||||||
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
Follow the instructions in the [documentation](https://emsesp.github.io/docs) on how to install the firmware binaries in the Assets below.
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ build_flags =
|
|||||||
-D FACTORY_MQTT_PASSWORD=\"\"
|
-D FACTORY_MQTT_PASSWORD=\"\"
|
||||||
-D FACTORY_MQTT_CLIENT_ID=\"ems-esp\"
|
-D FACTORY_MQTT_CLIENT_ID=\"ems-esp\"
|
||||||
-D FACTORY_MQTT_KEEP_ALIVE=60
|
-D FACTORY_MQTT_KEEP_ALIVE=60
|
||||||
-D FACTORY_MQTT_CLEAN_SESSION=true
|
-D FACTORY_MQTT_CLEAN_SESSION=false
|
||||||
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
|
-D FACTORY_MQTT_MAX_TOPIC_LENGTH=128
|
||||||
|
|
||||||
; JWT Secret
|
; JWT Secret
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# This enables lint extensions
|
||||||
|
EXTEND_ESLINT=true
|
||||||
|
|
||||||
# This is the name of your project. It appears on the sign-in page and in the menu bar.
|
# This is the name of your project. It appears on the sign-in page and in the menu bar.
|
||||||
REACT_APP_PROJECT_NAME=EMS-ESP
|
REACT_APP_PROJECT_NAME=EMS-ESP
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Change the IP address to that of your ESP device to enable local development of the UI
|
|
||||||
|
|
||||||
# REACT_APP_HTTP_ROOT=http://localhost:3000
|
|
||||||
# REACT_APP_WEB_SOCKET_ROOT=ws://localhost:3000
|
|
||||||
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# don't ever lint node_modules
|
|
||||||
node_modules
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"root": true,
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"prettier"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
// 0 = ignore, 1 = warning, 2 = error
|
|
||||||
"no-console": 0,
|
|
||||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
|
||||||
"explicit-function-return-type": 0,
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": 0,
|
|
||||||
"@typescript-eslint/no-var-requires": 0,
|
|
||||||
"@typescript-eslint/ban-types": 0,
|
|
||||||
"@typescript-eslint/no-non-null-asserted-optional-chain": 0,
|
|
||||||
"@typescript-eslint/no-non-null-assertion": 0,
|
|
||||||
"@typescript-eslint/no-explicit-any": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -2,5 +2,5 @@
|
|||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"printWidth": 80
|
"printWidth": 120
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,30 @@
|
|||||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
|
||||||
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
|
|
||||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
const CompressionPlugin = require('compression-webpack-plugin');
|
|
||||||
const ProgmemGenerator = require('./progmem-generator.js');
|
const ProgmemGenerator = require('./progmem-generator.js');
|
||||||
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
|
|
||||||
module.exports = function override(config, env) {
|
module.exports = function override(config, env) {
|
||||||
const hosted = process.env.REACT_APP_HOSTED;
|
const hosted = process.env.REACT_APP_HOSTED;
|
||||||
|
|
||||||
if (env === 'production' && !hosted) {
|
if (env === 'production' && !hosted) {
|
||||||
console.log('Custom webpack...');
|
// rename the ouput file, we need it's path to be short, for embedded FS
|
||||||
|
|
||||||
// rename the output file, we need it's path to be short for LittleFS
|
|
||||||
config.output.filename = 'js/[id].[chunkhash:4].js';
|
config.output.filename = 'js/[id].[chunkhash:4].js';
|
||||||
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
|
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
|
||||||
|
|
||||||
// take out the manifest and service worker plugins
|
// take out the manifest plugin
|
||||||
config.plugins = config.plugins.filter(
|
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
|
||||||
(plugin) => !(plugin instanceof ManifestPlugin)
|
|
||||||
);
|
|
||||||
config.plugins = config.plugins.filter(
|
|
||||||
(plugin) => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW)
|
|
||||||
);
|
|
||||||
|
|
||||||
// shorten css filenames
|
// shorten css filenames
|
||||||
const miniCssExtractPlugin = config.plugins.find(
|
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
|
||||||
(plugin) => plugin instanceof MiniCssExtractPlugin
|
|
||||||
);
|
|
||||||
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
|
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
|
||||||
miniCssExtractPlugin.options.chunkFilename =
|
miniCssExtractPlugin.options.chunkFilename = 'css/[id].[contenthash:4].c.css';
|
||||||
'css/[id].[contenthash:4].c.css';
|
|
||||||
|
// don't emit license file
|
||||||
|
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
|
||||||
|
terserPlugin.options.extractComments = false;
|
||||||
|
|
||||||
// build progmem data files
|
// build progmem data files
|
||||||
config.plugins.push(
|
config.plugins.push(new ProgmemGenerator({ outputPath: '../lib/framework/WWWData.h', bytesPerLine: 20 }));
|
||||||
new ProgmemGenerator({
|
|
||||||
outputPath: '../lib/framework/WWWData.h',
|
|
||||||
bytesPerLine: 20
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// add compression plugin, compress javascript
|
|
||||||
config.plugins.push(
|
|
||||||
new CompressionPlugin({
|
|
||||||
filename: '[path].gz[query]',
|
|
||||||
algorithm: 'gzip',
|
|
||||||
test: /\.(js)$/,
|
|
||||||
deleteOriginalAssets: true
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|||||||
40824
interface/package-lock.json
generated
40824
interface/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,41 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "emsesp-react",
|
"name": "EMS-ESP",
|
||||||
"version": "0.1.0",
|
"version": "3.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"proxy": "http://localhost:3080",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@material-ui/core": "^4.12.3",
|
"@emotion/react": "^11.10.4",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@emotion/styled": "^11.10.4",
|
||||||
"@msgpack/msgpack": "^2.7.0",
|
"@msgpack/msgpack": "^2.8.0",
|
||||||
"@types/lodash": "^4.14.172",
|
"@mui/icons-material": "^5.10.3",
|
||||||
"@types/node": "^12.20.20",
|
"@mui/material": "^5.10.5",
|
||||||
"@types/react": "^17.0.19",
|
"@table-library/react-table-library": "4.0.18",
|
||||||
"@types/react-dom": "^17.0.9",
|
"@types/lodash": "^4.14.185",
|
||||||
"@types/react-material-ui-form-validator": "^2.1.0",
|
"@types/node": "^18.7.18",
|
||||||
"@types/react-router": "^5.1.13",
|
"@types/react": "^18.0.20",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-dom": "^18.0.6",
|
||||||
"compression-webpack-plugin": "^5.0.2",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"env-cmd": "^10.1.0",
|
"async-validator": "^4.2.5",
|
||||||
"express": "^4.17.1",
|
"axios": "^0.27.2",
|
||||||
|
"http-proxy-middleware": "^2.0.6",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mime-types": "^2.1.30",
|
"notistack": "^2.0.5",
|
||||||
"notistack": "^1.0.6",
|
|
||||||
"parse-ms": "^3.0.0",
|
"parse-ms": "^3.0.0",
|
||||||
"react": "^17.0.2",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^17.0.2",
|
"react-app-rewired": "^2.2.1",
|
||||||
"react-dropzone": "^11.3.2",
|
"react-dom": "^18.2.0",
|
||||||
"react-form-validator-core": "^1.1.1",
|
"react-dropzone": "^14.2.2",
|
||||||
"react-material-ui-form-validator": "^2.1.4",
|
"react-icons": "^4.4.0",
|
||||||
"react-router": "^5.2.0",
|
"react-router-dom": "^6.4.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-scripts": "5.0.1",
|
||||||
"react-scripts": "4.0.3",
|
|
||||||
"sockette": "^2.0.6",
|
"sockette": "^2.0.6",
|
||||||
"typescript": "4.3.5",
|
"typescript": "^4.8.3"
|
||||||
"zlib": "^1.0.5"
|
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-app-rewired start",
|
"start": "react-app-rewired start",
|
||||||
"build": "react-app-rewired build",
|
"build": "react-app-rewired build",
|
||||||
|
"test": "react-app-rewired test",
|
||||||
|
"eject": "react-scripts eject",
|
||||||
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
|
"format": "prettier --write '**/*.{ts,tsx,js,css,json,md}'",
|
||||||
"build-hosted": "env-cmd -f .env.hosted npm run build",
|
"build-hosted": "env-cmd -f .env.hosted npm run build",
|
||||||
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
|
"build-localhost": "PUBLIC_URL=/ react-app-rewired build",
|
||||||
@@ -44,7 +45,44 @@
|
|||||||
"lint": "eslint . --ext .ts,.tsx"
|
"lint": "eslint . --ext .ts,.tsx"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": "react-app"
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"eol-last": 1,
|
||||||
|
"react/jsx-closing-bracket-location": 1,
|
||||||
|
"react/jsx-closing-tag-location": 1,
|
||||||
|
"react/jsx-wrap-multilines": 1,
|
||||||
|
"react/jsx-curly-newline": 1,
|
||||||
|
"no-multiple-empty-lines": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"max": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-trailing-spaces": 1,
|
||||||
|
"semi": 1,
|
||||||
|
"no-extra-semi": 1,
|
||||||
|
"react/jsx-max-props-per-line": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"when": "multiline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-first-prop-new-line": [
|
||||||
|
1,
|
||||||
|
"multiline"
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-shadow": 1,
|
||||||
|
"max-len": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"code": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"arrow-parens": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
"production": [
|
"production": [
|
||||||
@@ -59,13 +97,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^6.0.1",
|
"nodemon": "^2.0.20",
|
||||||
"eslint-config-prettier": "^8.3.0",
|
"npm-run-all": "^4.1.5"
|
||||||
"eslint-plugin-prettier": "^3.4.0",
|
|
||||||
"http-proxy-middleware": "^1.1.1",
|
|
||||||
"nodemon": "^2.0.7",
|
|
||||||
"npm-run-all": "^4.1.5",
|
|
||||||
"prettier": "^2.0.5",
|
|
||||||
"react-app-rewired": "^2.1.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
const { resolve, relative, sep } = require('path');
|
const { resolve, relative, sep } = require('path');
|
||||||
const {
|
const { readdirSync, existsSync, unlinkSync, readFileSync, createWriteStream } = require('fs');
|
||||||
readdirSync,
|
|
||||||
existsSync,
|
|
||||||
unlinkSync,
|
|
||||||
readFileSync,
|
|
||||||
createWriteStream
|
|
||||||
} = require('fs');
|
|
||||||
var zlib = require('zlib');
|
var zlib = require('zlib');
|
||||||
var mime = require('mime-types');
|
var mime = require('mime-types');
|
||||||
|
|
||||||
@@ -36,24 +30,15 @@ function cleanAndOpen(path) {
|
|||||||
|
|
||||||
class ProgmemGenerator {
|
class ProgmemGenerator {
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
const {
|
const { outputPath, bytesPerLine = 20, indent = ' ', includes = ARDUINO_INCLUDES } = options;
|
||||||
outputPath,
|
|
||||||
bytesPerLine = 20,
|
|
||||||
indent = ' ',
|
|
||||||
includes = ARDUINO_INCLUDES
|
|
||||||
} = options;
|
|
||||||
this.options = { outputPath, bytesPerLine, indent, includes };
|
this.options = { outputPath, bytesPerLine, indent, includes };
|
||||||
}
|
}
|
||||||
|
|
||||||
apply(compiler) {
|
apply(compiler) {
|
||||||
compiler.hooks.emit.tapAsync(
|
compiler.hooks.emit.tapAsync({ name: 'ProgmemGenerator' }, (compilation, callback) => {
|
||||||
{ name: 'ProgmemGenerator' },
|
|
||||||
(compilation, callback) => {
|
|
||||||
const { outputPath, bytesPerLine, indent, includes } = this.options;
|
const { outputPath, bytesPerLine, indent, includes } = this.options;
|
||||||
const fileInfo = [];
|
const fileInfo = [];
|
||||||
const writeStream = cleanAndOpen(
|
const writeStream = cleanAndOpen(resolve(compilation.options.context, outputPath));
|
||||||
resolve(compilation.options.context, outputPath)
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const writeIncludes = () => {
|
const writeIncludes = () => {
|
||||||
writeStream.write(includes);
|
writeStream.write(includes);
|
||||||
@@ -70,9 +55,7 @@ class ProgmemGenerator {
|
|||||||
writeStream.write('\n');
|
writeStream.write('\n');
|
||||||
writeStream.write(indent);
|
writeStream.write(indent);
|
||||||
}
|
}
|
||||||
writeStream.write(
|
writeStream.write('0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ',');
|
||||||
'0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ','
|
|
||||||
);
|
|
||||||
size++;
|
size++;
|
||||||
});
|
});
|
||||||
if (size % bytesPerLine) {
|
if (size % bytesPerLine) {
|
||||||
@@ -98,28 +81,19 @@ class ProgmemGenerator {
|
|||||||
// process assets
|
// process assets
|
||||||
const { assets } = compilation;
|
const { assets } = compilation;
|
||||||
Object.keys(assets).forEach((relativeFilePath) => {
|
Object.keys(assets).forEach((relativeFilePath) => {
|
||||||
writeFile(
|
writeFile(relativeFilePath, coherseToBuffer(assets[relativeFilePath].source()));
|
||||||
relativeFilePath,
|
|
||||||
coherseToBuffer(assets[relativeFilePath].source())
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateWWWClass = () => {
|
const generateWWWClass = () => {
|
||||||
|
// eslint-disable-next-line max-len
|
||||||
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
|
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
|
||||||
|
|
||||||
class WWWData {
|
class WWWData {
|
||||||
${indent}public:
|
${indent}public:
|
||||||
${indent.repeat(
|
${indent.repeat(2)}static void registerRoutes(RouteRegistrationHandler handler) {
|
||||||
2
|
|
||||||
)}static void registerRoutes(RouteRegistrationHandler handler) {
|
|
||||||
${fileInfo
|
${fileInfo
|
||||||
.map(
|
.map((file) => `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${file.variable}, ${file.size});`)
|
||||||
(file) =>
|
|
||||||
`${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${
|
|
||||||
file.variable
|
|
||||||
}, ${file.size});`
|
|
||||||
)
|
|
||||||
.join('\n')}
|
.join('\n')}
|
||||||
${indent.repeat(2)}}
|
${indent.repeat(2)}}
|
||||||
};
|
};
|
||||||
@@ -140,8 +114,7 @@ ${indent.repeat(2)}}
|
|||||||
} finally {
|
} finally {
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
/* Just supporting latin due to size constrains on the esp chip */
|
/*
|
||||||
@font-face {
|
* Just supporting latin due to size constrains on the esp chip
|
||||||
font-family: 'Roboto';
|
*
|
||||||
font-style: normal;
|
* The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
|
||||||
font-weight: 300;
|
*
|
||||||
src: local('Roboto Light'), local('Roboto-Light'),
|
* If using light or strong typography variants you will need to add additional fonts.
|
||||||
url(../fonts/li.woff2) format('woff2');
|
*/
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
|
||||||
}
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: local('Roboto'), local('Roboto-Regular'),
|
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
|
||||||
url(../fonts/re.woff2) format('woff2');
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Roboto';
|
font-family: 'Roboto';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
src: local('Roboto Medium'), local('Roboto-Medium'),
|
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.woff2) format('woff2');
|
||||||
url(../fonts/me.woff2) format('woff2');
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC,
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2212, U+2215;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
BIN
interface/public/fonts/md.woff2
Normal file
BIN
interface/public/fonts/md.woff2
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
<meta
|
||||||
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css">
|
name="viewport"
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json">
|
content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json" />
|
||||||
<title>EMS-ESP</title>
|
<title>EMS-ESP</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||||
You need to enable JavaScript to run this app.
|
|
||||||
</noscript>
|
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,57 +1,45 @@
|
|||||||
import React, { Component, RefObject } from 'react';
|
import { FC, createRef, createContext, useContext, RefObject } from 'react';
|
||||||
import { Redirect, Route, Switch } from 'react-router';
|
|
||||||
import { SnackbarProvider } from 'notistack';
|
import { SnackbarProvider } from 'notistack';
|
||||||
|
|
||||||
import { IconButton } from '@material-ui/core';
|
import { IconButton } from '@mui/material';
|
||||||
import CloseIcon from '@material-ui/icons/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
import { FeaturesLoader } from './contexts/features';
|
||||||
|
|
||||||
|
import CustomTheme from './CustomTheme';
|
||||||
import AppRouting from './AppRouting';
|
import AppRouting from './AppRouting';
|
||||||
import CustomMuiTheme from './CustomMuiTheme';
|
|
||||||
import { PROJECT_NAME } from './api';
|
|
||||||
import FeaturesWrapper from './features/FeaturesWrapper';
|
|
||||||
|
|
||||||
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
|
const App: FC = () => {
|
||||||
const unauthorizedRedirect = () => <Redirect to="/" />;
|
const notistackRef: RefObject<any> = createRef();
|
||||||
|
|
||||||
class App extends Component {
|
const onClickDismiss = (key: string | number | undefined) => () => {
|
||||||
notistackRef: RefObject<any> = React.createRef();
|
notistackRef.current.closeSnackbar(key);
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
document.title = PROJECT_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickDismiss = (key: string | number | undefined) => () => {
|
|
||||||
this.notistackRef.current.closeSnackbar(key);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
const ColorModeContext = createContext({ toggleColorMode: () => {} });
|
||||||
|
|
||||||
|
const colorMode = useContext(ColorModeContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomMuiTheme>
|
<ColorModeContext.Provider value={colorMode}>
|
||||||
|
<CustomTheme>
|
||||||
<SnackbarProvider
|
<SnackbarProvider
|
||||||
autoHideDuration={3000}
|
|
||||||
maxSnack={3}
|
maxSnack={3}
|
||||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
ref={this.notistackRef}
|
ref={notistackRef}
|
||||||
action={(key) => (
|
action={(key) => (
|
||||||
<IconButton onClick={this.onClickDismiss(key)} size="small">
|
<IconButton onClick={onClickDismiss(key)} size="small">
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FeaturesWrapper>
|
<FeaturesLoader>
|
||||||
<Switch>
|
<AppRouting />
|
||||||
<Route
|
</FeaturesLoader>
|
||||||
exact
|
|
||||||
path="/unauthorized"
|
|
||||||
component={unauthorizedRedirect}
|
|
||||||
/>
|
|
||||||
<Route component={AppRouting} />
|
|
||||||
</Switch>
|
|
||||||
</FeaturesWrapper>
|
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</CustomMuiTheme>
|
</CustomTheme>
|
||||||
|
</ColorModeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,67 +1,74 @@
|
|||||||
import React, { Component } from 'react';
|
import { FC, useContext, useEffect } from 'react';
|
||||||
import { Switch, Redirect } from 'react-router';
|
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
|
||||||
|
import { useSnackbar, VariantType } from 'notistack';
|
||||||
|
|
||||||
import * as Authentication from './authentication/Authentication';
|
import { Authentication, AuthenticationContext } from './contexts/authentication';
|
||||||
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
|
import { FeaturesContext } from './contexts/features';
|
||||||
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
|
import { RequireAuthenticated, RequireUnauthenticated } from './components';
|
||||||
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
|
|
||||||
|
|
||||||
import SignIn from './SignIn';
|
import SignIn from './SignIn';
|
||||||
import ProjectRouting from './project/ProjectRouting';
|
import AuthenticatedRouting from './AuthenticatedRouting';
|
||||||
import NetworkConnection from './network/NetworkConnection';
|
|
||||||
import AccessPoint from './ap/AccessPoint';
|
|
||||||
import NetworkTime from './ntp/NetworkTime';
|
|
||||||
import Security from './security/Security';
|
|
||||||
import System from './system/System';
|
|
||||||
|
|
||||||
import { PROJECT_PATH } from './api';
|
interface SecurityRedirectProps {
|
||||||
import Mqtt from './mqtt/Mqtt';
|
message: string;
|
||||||
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
|
variant?: VariantType;
|
||||||
import { Features } from './features/types';
|
signOut?: boolean;
|
||||||
|
|
||||||
export const getDefaultRoute = (features: Features) =>
|
|
||||||
features.project ? `/${PROJECT_PATH}/` : '/network/';
|
|
||||||
class AppRouting extends Component<WithFeaturesProps> {
|
|
||||||
componentDidMount() {
|
|
||||||
Authentication.clearLoginRedirect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
|
||||||
const { features } = this.props;
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
useEffect(() => {
|
||||||
|
signOut && authenticationContext.signOut(false);
|
||||||
|
enqueueSnackbar(message, { variant });
|
||||||
|
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
|
||||||
|
return <Navigate to="/" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RemoveTrailingSlashes = () => {
|
||||||
|
const location = useLocation();
|
||||||
return (
|
return (
|
||||||
<AuthenticationWrapper>
|
location.pathname.match('/.*/$') && (
|
||||||
<Switch>
|
<Navigate
|
||||||
{features.security && (
|
to={{
|
||||||
<UnauthenticatedRoute exact path="/" component={SignIn} />
|
pathname: location.pathname.replace(/\/+$/, ''),
|
||||||
)}
|
search: location.search
|
||||||
{features.project && (
|
}}
|
||||||
<AuthenticatedRoute
|
|
||||||
exact
|
|
||||||
path={`/${PROJECT_PATH}/*`}
|
|
||||||
component={ProjectRouting}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)
|
||||||
<AuthenticatedRoute
|
|
||||||
exact
|
|
||||||
path="/network/*"
|
|
||||||
component={NetworkConnection}
|
|
||||||
/>
|
|
||||||
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
|
|
||||||
{features.ntp && (
|
|
||||||
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
|
|
||||||
)}
|
|
||||||
{features.mqtt && (
|
|
||||||
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} />
|
|
||||||
)}
|
|
||||||
{features.security && (
|
|
||||||
<AuthenticatedRoute exact path="/security/*" component={Security} />
|
|
||||||
)}
|
|
||||||
<AuthenticatedRoute exact path="/system/*" component={System} />
|
|
||||||
<Redirect to={getDefaultRoute(features)} />
|
|
||||||
</Switch>
|
|
||||||
</AuthenticationWrapper>
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default withFeatures(AppRouting);
|
const AppRouting: FC = () => {
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Authentication>
|
||||||
|
<RemoveTrailingSlashes />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
|
||||||
|
<Route path="/fileUpdated" element={<RootRedirect message="Upload successful" variant="success" />} />
|
||||||
|
{features.security && (
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireUnauthenticated>
|
||||||
|
<SignIn />
|
||||||
|
</RequireUnauthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<RequireAuthenticated>
|
||||||
|
<AuthenticatedRouting />
|
||||||
|
</RequireAuthenticated>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Authentication>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppRouting;
|
||||||
|
|||||||
66
interface/src/AuthenticatedRouting.tsx
Normal file
66
interface/src/AuthenticatedRouting.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { FC, useCallback, useContext, useEffect } from 'react';
|
||||||
|
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
|
import { FeaturesContext } from './contexts/features';
|
||||||
|
import * as AuthenticationApi from './api/authentication';
|
||||||
|
import { PROJECT_PATH } from './api/env';
|
||||||
|
import { AXIOS } from './api/endpoints';
|
||||||
|
import { Layout, RequireAdmin } from './components';
|
||||||
|
|
||||||
|
import ProjectRouting from './project/ProjectRouting';
|
||||||
|
|
||||||
|
import NetworkConnection from './framework/network/NetworkConnection';
|
||||||
|
import AccessPoint from './framework/ap/AccessPoint';
|
||||||
|
import NetworkTime from './framework/ntp/NetworkTime';
|
||||||
|
import Mqtt from './framework/mqtt/Mqtt';
|
||||||
|
import System from './framework/system/System';
|
||||||
|
import Security from './framework/security/Security';
|
||||||
|
|
||||||
|
const AuthenticatedRouting: FC = () => {
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleApiResponseError = useCallback(
|
||||||
|
(error: AxiosError) => {
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
AuthenticationApi.storeLoginRedirect(location);
|
||||||
|
navigate('/unauthorized');
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
[location, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
|
||||||
|
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
|
||||||
|
}, [handleApiResponseError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Routes>
|
||||||
|
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
|
||||||
|
<Route path="/network/*" element={<NetworkConnection />} />
|
||||||
|
<Route path="/ap/*" element={<AccessPoint />} />
|
||||||
|
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
|
||||||
|
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
|
||||||
|
{features.security && (
|
||||||
|
<Route
|
||||||
|
path="/security/*"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<Security />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Route path="/system/*" element={<System />} />
|
||||||
|
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuthenticatedRouting;
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Component } from 'react';
|
|
||||||
|
|
||||||
import { CssBaseline } from '@material-ui/core';
|
|
||||||
import {
|
|
||||||
MuiThemeProvider,
|
|
||||||
createTheme,
|
|
||||||
StylesProvider
|
|
||||||
} from '@material-ui/core/styles';
|
|
||||||
import { blueGrey, orange, red, green } from '@material-ui/core/colors';
|
|
||||||
|
|
||||||
const theme = createTheme({
|
|
||||||
palette: {
|
|
||||||
type: 'dark',
|
|
||||||
primary: {
|
|
||||||
main: '#33bfff'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
main: '#3d5afe'
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
main: blueGrey[500]
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: orange[500]
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: red[500]
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
main: green[500]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default class CustomMuiTheme extends Component {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<StylesProvider>
|
|
||||||
<MuiThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
{this.props.children}
|
|
||||||
</MuiThemeProvider>
|
|
||||||
</StylesProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
interface/src/CustomTheme.tsx
Normal file
33
interface/src/CustomTheme.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { CssBaseline } from '@mui/material';
|
||||||
|
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
|
||||||
|
import { blueGrey, blue } from '@mui/material/colors';
|
||||||
|
|
||||||
|
import { RequiredChildrenProps } from './utils';
|
||||||
|
|
||||||
|
const theme = responsiveFontSizes(
|
||||||
|
createTheme({
|
||||||
|
typography: {
|
||||||
|
fontSize: 13
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
secondary: {
|
||||||
|
main: blue[500]
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: blueGrey[500]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomTheme;
|
||||||
@@ -1,165 +1,117 @@
|
|||||||
import React, { Component } from 'react';
|
import { FC, useContext, useState } from 'react';
|
||||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
import { ValidateFieldsError } from 'async-validator';
|
||||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
import { useSnackbar } from 'notistack';
|
||||||
|
|
||||||
import {
|
import { Box, Fab, Paper, Typography } from '@mui/material';
|
||||||
withStyles,
|
import ForwardIcon from '@mui/icons-material/Forward';
|
||||||
createStyles,
|
|
||||||
Theme,
|
|
||||||
WithStyles
|
|
||||||
} from '@material-ui/core/styles';
|
|
||||||
import { Paper, Typography, Fab } from '@material-ui/core';
|
|
||||||
import ForwardIcon from '@material-ui/icons/Forward';
|
|
||||||
|
|
||||||
import {
|
import * as AuthenticationApi from './api/authentication';
|
||||||
withAuthenticationContext,
|
import { PROJECT_NAME } from './api/env';
|
||||||
AuthenticationContextProps
|
import { AuthenticationContext } from './contexts/authentication';
|
||||||
} from './authentication/AuthenticationContext';
|
|
||||||
import { PasswordValidator } from './components';
|
|
||||||
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
|
|
||||||
|
|
||||||
const styles = (theme: Theme) =>
|
import { AxiosError } from 'axios';
|
||||||
createStyles({
|
|
||||||
signInPage: {
|
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
|
||||||
display: 'flex',
|
import { SignInRequest } from './types';
|
||||||
height: '100vh',
|
import { ValidatedTextField } from './components';
|
||||||
margin: 'auto',
|
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
|
||||||
padding: theme.spacing(2),
|
|
||||||
justifyContent: 'center',
|
const SignIn: FC = () => {
|
||||||
flexDirection: 'column',
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
maxWidth: theme.breakpoints.values.sm
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
},
|
|
||||||
signInPanel: {
|
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
|
||||||
|
username: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
const [processing, setProcessing] = useState<boolean>(false);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
|
||||||
|
|
||||||
|
const updateLoginRequestValue = updateValue(setSignInRequest);
|
||||||
|
|
||||||
|
const validateAndSignIn = async () => {
|
||||||
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
|
||||||
|
signIn();
|
||||||
|
} catch (errors: any) {
|
||||||
|
setFieldErrors(errors);
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signIn = async () => {
|
||||||
|
try {
|
||||||
|
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
|
||||||
|
authenticationContext.signIn(loginResponse.access_token);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
enqueueSnackbar('Invalid login details', { variant: 'warning' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
|
||||||
|
}
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitOnEnter = onEnterCallback(signIn);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
height="100vh"
|
||||||
|
margin="auto"
|
||||||
|
padding={2}
|
||||||
|
justifyContent="center"
|
||||||
|
flexDirection="column"
|
||||||
|
maxWidth={(theme) => theme.breakpoints.values.sm}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
sx={(theme) => ({
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(2),
|
||||||
paddingTop: '200px',
|
paddingTop: '200px',
|
||||||
backgroundImage: 'url("/app/icon.png")',
|
backgroundImage: 'url("/app/icon.png")',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
backgroundPosition: '50% ' + theme.spacing(2) + 'px',
|
backgroundPosition: '50% ' + theme.spacing(2),
|
||||||
backgroundSize: 'auto 150px',
|
backgroundSize: 'auto 150px',
|
||||||
width: '100%'
|
width: '100%'
|
||||||
},
|
})}
|
||||||
extendedIcon: {
|
>
|
||||||
marginRight: theme.spacing(0.5)
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginRight: theme.spacing(2),
|
|
||||||
marginTop: theme.spacing(2)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type SignInProps = WithSnackbarProps &
|
|
||||||
WithStyles<typeof styles> &
|
|
||||||
AuthenticationContextProps;
|
|
||||||
|
|
||||||
interface SignInState {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
processing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SignIn extends Component<SignInProps, SignInState> {
|
|
||||||
constructor(props: SignInProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
processing: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
||||||
const { name, value } = event.currentTarget;
|
|
||||||
this.setState((prevState) => ({
|
|
||||||
...prevState,
|
|
||||||
[name]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
onSubmit = () => {
|
|
||||||
const { username, password } = this.state;
|
|
||||||
const { authenticationContext } = this.props;
|
|
||||||
this.setState({ processing: true });
|
|
||||||
fetch(SIGN_IN_ENDPOINT, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, password }),
|
|
||||||
headers: new Headers({
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
return response.json();
|
|
||||||
} else if (response.status === 401) {
|
|
||||||
throw Error('Invalid credentials.');
|
|
||||||
} else {
|
|
||||||
throw Error('Invalid status code: ' + response.status);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
authenticationContext.signIn(json.access_token);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.props.enqueueSnackbar(error.message, {
|
|
||||||
variant: 'warning'
|
|
||||||
});
|
|
||||||
this.setState({ processing: false });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { username, password, processing } = this.state;
|
|
||||||
const { classes } = this.props;
|
|
||||||
return (
|
|
||||||
<div className={classes.signInPage}>
|
|
||||||
<Paper className={classes.signInPanel}>
|
|
||||||
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
<Typography variant="h4">{PROJECT_NAME}</Typography>
|
||||||
<ValidatorForm onSubmit={this.onSubmit}>
|
<ValidatedTextField
|
||||||
<TextValidator
|
fieldErrors={fieldErrors}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
validators={['required']}
|
|
||||||
errorMessages={['Username is required']}
|
|
||||||
name="username"
|
name="username"
|
||||||
label="Username"
|
label="Username"
|
||||||
fullWidth
|
value={signInRequest.username}
|
||||||
variant="outlined"
|
onChange={updateLoginRequestValue}
|
||||||
value={username}
|
|
||||||
onChange={this.updateInputElement}
|
|
||||||
margin="normal"
|
margin="normal"
|
||||||
inputProps={{
|
variant="outlined"
|
||||||
autoCapitalize: 'none',
|
fullWidth
|
||||||
autoCorrect: 'off'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<PasswordValidator
|
<ValidatedTextField
|
||||||
|
fieldErrors={fieldErrors}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
validators={['required']}
|
type="password"
|
||||||
errorMessages={['Password is required']}
|
|
||||||
name="password"
|
name="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
fullWidth
|
value={signInRequest.password}
|
||||||
variant="outlined"
|
onChange={updateLoginRequestValue}
|
||||||
value={password}
|
onKeyDown={submitOnEnter}
|
||||||
onChange={this.updateInputElement}
|
|
||||||
margin="normal"
|
margin="normal"
|
||||||
|
variant="outlined"
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<Fab
|
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
|
||||||
variant="extended"
|
<ForwardIcon sx={{ mr: 1 }} />
|
||||||
color="primary"
|
|
||||||
className={classes.button}
|
|
||||||
type="submit"
|
|
||||||
disabled={processing}
|
|
||||||
>
|
|
||||||
<ForwardIcon className={classes.extendedIcon} />
|
|
||||||
Sign In
|
Sign In
|
||||||
</Fab>
|
</Fab>
|
||||||
</ValidatorForm>
|
|
||||||
</Paper>
|
</Paper>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default withAuthenticationContext(
|
export default SignIn;
|
||||||
withSnackbar(withStyles(styles)(SignIn))
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { APSettings, APProvisionMode } from './types';
|
|
||||||
|
|
||||||
export const isAPEnabled = ({ provision_mode }: APSettings) => {
|
|
||||||
return (
|
|
||||||
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
|
|
||||||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Component } from 'react';
|
|
||||||
|
|
||||||
import { AP_SETTINGS_ENDPOINT } from '../api';
|
|
||||||
import {
|
|
||||||
restController,
|
|
||||||
RestControllerProps,
|
|
||||||
RestFormLoader,
|
|
||||||
SectionContent
|
|
||||||
} from '../components';
|
|
||||||
|
|
||||||
import APSettingsForm from './APSettingsForm';
|
|
||||||
import { APSettings } from './types';
|
|
||||||
|
|
||||||
type APSettingsControllerProps = RestControllerProps<APSettings>;
|
|
||||||
|
|
||||||
class APSettingsController extends Component<APSettingsControllerProps> {
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<SectionContent title="Access Point Settings" titleGutter>
|
|
||||||
<RestFormLoader
|
|
||||||
{...this.props}
|
|
||||||
render={(formProps) => <APSettingsForm {...formProps} />}
|
|
||||||
/>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default restController(AP_SETTINGS_ENDPOINT, APSettingsController);
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import React, { Fragment } from 'react';
|
|
||||||
import {
|
|
||||||
TextValidator,
|
|
||||||
ValidatorForm,
|
|
||||||
SelectValidator
|
|
||||||
} from 'react-material-ui-form-validator';
|
|
||||||
|
|
||||||
import MenuItem from '@material-ui/core/MenuItem';
|
|
||||||
import SaveIcon from '@material-ui/icons/Save';
|
|
||||||
|
|
||||||
import {
|
|
||||||
PasswordValidator,
|
|
||||||
RestFormProps,
|
|
||||||
FormActions,
|
|
||||||
FormButton
|
|
||||||
} from '../components';
|
|
||||||
|
|
||||||
import { isAPEnabled } from './APModes';
|
|
||||||
import { APSettings, APProvisionMode } from './types';
|
|
||||||
import { isIP } from '../validators';
|
|
||||||
|
|
||||||
type APSettingsFormProps = RestFormProps<APSettings>;
|
|
||||||
|
|
||||||
class APSettingsForm extends React.Component<APSettingsFormProps> {
|
|
||||||
componentDidMount() {
|
|
||||||
ValidatorForm.addValidationRule('isIP', isIP);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { data, handleValueChange, saveData } = this.props;
|
|
||||||
return (
|
|
||||||
<ValidatorForm onSubmit={saveData} ref="APSettingsForm">
|
|
||||||
<SelectValidator
|
|
||||||
name="provision_mode"
|
|
||||||
label="Provide Access Point…"
|
|
||||||
value={data.provision_mode}
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
onChange={handleValueChange('provision_mode')}
|
|
||||||
margin="normal"
|
|
||||||
>
|
|
||||||
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
|
|
||||||
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
|
|
||||||
When Network Disconnected
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
|
|
||||||
</SelectValidator>
|
|
||||||
{isAPEnabled(data) && (
|
|
||||||
<Fragment>
|
|
||||||
<TextValidator
|
|
||||||
validators={['required', 'matchRegexp:^.{1,32}$']}
|
|
||||||
errorMessages={[
|
|
||||||
'Access Point SSID is required',
|
|
||||||
'Access Point SSID must be 32 characters or less'
|
|
||||||
]}
|
|
||||||
name="ssid"
|
|
||||||
label="Access Point SSID"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={data.ssid}
|
|
||||||
onChange={handleValueChange('ssid')}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<PasswordValidator
|
|
||||||
validators={['required', 'matchRegexp:^.{8,64}$']}
|
|
||||||
errorMessages={[
|
|
||||||
'Access Point Password is required',
|
|
||||||
'Access Point Password must be 8-64 characters'
|
|
||||||
]}
|
|
||||||
name="password"
|
|
||||||
label="Access Point Password"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={data.password}
|
|
||||||
onChange={handleValueChange('password')}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<TextValidator
|
|
||||||
validators={['required', 'isIP']}
|
|
||||||
errorMessages={['Local IP is required', 'Must be an IP address']}
|
|
||||||
name="local_ip"
|
|
||||||
label="Local IP"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={data.local_ip}
|
|
||||||
onChange={handleValueChange('local_ip')}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<TextValidator
|
|
||||||
validators={['required', 'isIP']}
|
|
||||||
errorMessages={[
|
|
||||||
'Gateway IP is required',
|
|
||||||
'Must be an IP address'
|
|
||||||
]}
|
|
||||||
name="gateway_ip"
|
|
||||||
label="Gateway"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={data.gateway_ip}
|
|
||||||
onChange={handleValueChange('gateway_ip')}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
<TextValidator
|
|
||||||
validators={['required', 'isIP']}
|
|
||||||
errorMessages={[
|
|
||||||
'Subnet mask is required',
|
|
||||||
'Must be an IP address'
|
|
||||||
]}
|
|
||||||
name="subnet_mask"
|
|
||||||
label="Subnet"
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
value={data.subnet_mask}
|
|
||||||
onChange={handleValueChange('subnet_mask')}
|
|
||||||
margin="normal"
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
<FormActions>
|
|
||||||
<FormButton
|
|
||||||
startIcon={<SaveIcon />}
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</FormButton>
|
|
||||||
</FormActions>
|
|
||||||
</ValidatorForm>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default APSettingsForm;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { Theme } from '@material-ui/core';
|
|
||||||
import { APStatus, APNetworkStatus } from './types';
|
|
||||||
|
|
||||||
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
|
|
||||||
switch (status) {
|
|
||||||
case APNetworkStatus.ACTIVE:
|
|
||||||
return theme.palette.success.main;
|
|
||||||
case APNetworkStatus.INACTIVE:
|
|
||||||
return theme.palette.info.main;
|
|
||||||
case APNetworkStatus.LINGERING:
|
|
||||||
return theme.palette.warning.main;
|
|
||||||
default:
|
|
||||||
return theme.palette.warning.main;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const apStatus = ({ status }: APStatus) => {
|
|
||||||
switch (status) {
|
|
||||||
case APNetworkStatus.ACTIVE:
|
|
||||||
return 'Active';
|
|
||||||
case APNetworkStatus.INACTIVE:
|
|
||||||
return 'Inactive';
|
|
||||||
case APNetworkStatus.LINGERING:
|
|
||||||
return 'Lingering until idle';
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { Component } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
restController,
|
|
||||||
RestControllerProps,
|
|
||||||
RestFormLoader,
|
|
||||||
SectionContent
|
|
||||||
} from '../components';
|
|
||||||
import { AP_STATUS_ENDPOINT } from '../api';
|
|
||||||
|
|
||||||
import APStatusForm from './APStatusForm';
|
|
||||||
import { APStatus } from './types';
|
|
||||||
|
|
||||||
type APStatusControllerProps = RestControllerProps<APStatus>;
|
|
||||||
|
|
||||||
class APStatusController extends Component<APStatusControllerProps> {
|
|
||||||
componentDidMount() {
|
|
||||||
this.props.loadData();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<SectionContent title="Access Point Status">
|
|
||||||
<RestFormLoader
|
|
||||||
{...this.props}
|
|
||||||
render={(formProps) => <APStatusForm {...formProps} />}
|
|
||||||
/>
|
|
||||||
</SectionContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default restController(AP_STATUS_ENDPOINT, APStatusController);
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import React, { Component, Fragment } from 'react';
|
|
||||||
|
|
||||||
import { WithTheme, withTheme } from '@material-ui/core/styles';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemAvatar,
|
|
||||||
ListItemText
|
|
||||||
} from '@material-ui/core';
|
|
||||||
|
|
||||||
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
|
|
||||||
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
|
||||||
import ComputerIcon from '@material-ui/icons/Computer';
|
|
||||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
|
||||||
|
|
||||||
import {
|
|
||||||
RestFormProps,
|
|
||||||
FormActions,
|
|
||||||
FormButton,
|
|
||||||
HighlightAvatar
|
|
||||||
} from '../components';
|
|
||||||
import { apStatusHighlight, apStatus } from './APStatus';
|
|
||||||
import { APStatus } from './types';
|
|
||||||
|
|
||||||
type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
|
|
||||||
|
|
||||||
class APStatusForm extends Component<APStatusFormProps> {
|
|
||||||
createListItems() {
|
|
||||||
const { data, theme } = this.props;
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<HighlightAvatar color={apStatusHighlight(data, theme)}>
|
|
||||||
<SettingsInputAntennaIcon />
|
|
||||||
</HighlightAvatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText primary="Status" secondary={apStatus(data)} />
|
|
||||||
</ListItem>
|
|
||||||
<Divider variant="inset" component="li" />
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar>IP</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText primary="IP Address" secondary={data.ip_address} />
|
|
||||||
</ListItem>
|
|
||||||
<Divider variant="inset" component="li" />
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar>
|
|
||||||
<DeviceHubIcon />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText primary="MAC Address" secondary={data.mac_address} />
|
|
||||||
</ListItem>
|
|
||||||
<Divider variant="inset" component="li" />
|
|
||||||
<ListItem>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar>
|
|
||||||
<ComputerIcon />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText primary="AP Clients" secondary={data.station_num} />
|
|
||||||
</ListItem>
|
|
||||||
<Divider variant="inset" component="li" />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
<List>{this.createListItems()}</List>
|
|
||||||
<FormActions>
|
|
||||||
<FormButton
|
|
||||||
startIcon={<RefreshIcon />}
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
onClick={this.props.loadData}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</FormButton>
|
|
||||||
</FormActions>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withTheme(APStatusForm);
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { Component } from 'react';
|
|
||||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { Tabs, Tab } from '@material-ui/core';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AuthenticatedContextProps,
|
|
||||||
withAuthenticatedContext,
|
|
||||||
AuthenticatedRoute
|
|
||||||
} from '../authentication';
|
|
||||||
import { MenuAppBar } from '../components';
|
|
||||||
|
|
||||||
import APSettingsController from './APSettingsController';
|
|
||||||
import APStatusController from './APStatusController';
|
|
||||||
|
|
||||||
type AccessPointProps = AuthenticatedContextProps & RouteComponentProps;
|
|
||||||
|
|
||||||
class AccessPoint extends Component<AccessPointProps> {
|
|
||||||
handleTabChange = (path: string) => {
|
|
||||||
this.props.history.push(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { authenticatedContext } = this.props;
|
|
||||||
return (
|
|
||||||
<MenuAppBar sectionTitle="Access Point">
|
|
||||||
<Tabs
|
|
||||||
value={this.props.match.url}
|
|
||||||
onChange={(e, path) => this.handleTabChange(path)}
|
|
||||||
variant="fullWidth"
|
|
||||||
>
|
|
||||||
<Tab value="/ap/status" label="Access Point Status" />
|
|
||||||
<Tab
|
|
||||||
value="/ap/settings"
|
|
||||||
label="Access Point Settings"
|
|
||||||
disabled={!authenticatedContext.me.admin}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
|
||||||
<Switch>
|
|
||||||
<AuthenticatedRoute
|
|
||||||
exact
|
|
||||||
path="/ap/status"
|
|
||||||
component={APStatusController}
|
|
||||||
/>
|
|
||||||
<AuthenticatedRoute
|
|
||||||
exact
|
|
||||||
path="/ap/settings"
|
|
||||||
component={APSettingsController}
|
|
||||||
/>
|
|
||||||
<Redirect to="/ap/status" />
|
|
||||||
</Switch>
|
|
||||||
</MenuAppBar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withAuthenticatedContext(AccessPoint);
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ENDPOINT_ROOT } from './Env';
|
|
||||||
|
|
||||||
export const FEATURES_ENDPOINT = ENDPOINT_ROOT + 'features';
|
|
||||||
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'ntpStatus';
|
|
||||||
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'ntpSettings';
|
|
||||||
export const TIME_ENDPOINT = ENDPOINT_ROOT + 'time';
|
|
||||||
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'apSettings';
|
|
||||||
export const AP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'apStatus';
|
|
||||||
export const SCAN_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'scanNetworks';
|
|
||||||
export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'listNetworks';
|
|
||||||
export const NETWORK_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'networkSettings';
|
|
||||||
export const NETWORK_STATUS_ENDPOINT = ENDPOINT_ROOT + 'networkStatus';
|
|
||||||
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'otaSettings';
|
|
||||||
export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + 'uploadFirmware';
|
|
||||||
export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'mqttSettings';
|
|
||||||
export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + 'mqttStatus';
|
|
||||||
export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + 'systemStatus';
|
|
||||||
export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + 'signIn';
|
|
||||||
export const VERIFY_AUTHORIZATION_ENDPOINT =
|
|
||||||
ENDPOINT_ROOT + 'verifyAuthorization';
|
|
||||||
export const SECURITY_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'securitySettings';
|
|
||||||
export const GENERATE_TOKEN_ENDPOINT = ENDPOINT_ROOT + 'generateToken';
|
|
||||||
export const RESTART_ENDPOINT = ENDPOINT_ROOT + 'restart';
|
|
||||||
export const FACTORY_RESET_ENDPOINT = ENDPOINT_ROOT + 'factoryReset';
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!;
|
|
||||||
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!;
|
|
||||||
|
|
||||||
export const ENDPOINT_ROOT = calculateEndpointRoot('/rest/');
|
|
||||||
export const WEB_SOCKET_ROOT = calculateWebSocketRoot('/ws/');
|
|
||||||
export const EVENT_SOURCE_ROOT = calculateEndpointRoot('/es/');
|
|
||||||
export const API_ENDPOINT_ROOT = calculateEndpointRoot('/api/');
|
|
||||||
|
|
||||||
function calculateEndpointRoot(endpointPath: string) {
|
|
||||||
const httpRoot = process.env.REACT_APP_HTTP_ROOT;
|
|
||||||
if (httpRoot) {
|
|
||||||
return httpRoot + endpointPath;
|
|
||||||
}
|
|
||||||
const location = window.location;
|
|
||||||
return location.protocol + '//' + location.host + endpointPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateWebSocketRoot(webSocketPath: string) {
|
|
||||||
const webSocketRoot = process.env.REACT_APP_WEB_SOCKET_ROOT;
|
|
||||||
if (webSocketRoot) {
|
|
||||||
return webSocketRoot + webSocketPath;
|
|
||||||
}
|
|
||||||
const location = window.location;
|
|
||||||
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
return webProtocol + '//' + location.host + webSocketPath;
|
|
||||||
}
|
|
||||||
16
interface/src/api/ap.ts
Normal file
16
interface/src/api/ap.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
|
||||||
|
import { APSettings, APStatus } from '../types';
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readAPStatus(): AxiosPromise<APStatus> {
|
||||||
|
return AXIOS.get('/apStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAPSettings(): AxiosPromise<APSettings> {
|
||||||
|
return AXIOS.get('/apSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
|
||||||
|
return AXIOS.post('/apSettings', apSettings);
|
||||||
|
}
|
||||||
64
interface/src/api/authentication.ts
Normal file
64
interface/src/api/authentication.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
import * as H from 'history';
|
||||||
|
import jwtDecode from 'jwt-decode';
|
||||||
|
import { Path } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Features, Me, SignInRequest, SignInResponse } from '../types';
|
||||||
|
|
||||||
|
import { ACCESS_TOKEN, AXIOS } from './endpoints';
|
||||||
|
import { PROJECT_PATH } from './env';
|
||||||
|
|
||||||
|
export const SIGN_IN_PATHNAME = 'loginPathname';
|
||||||
|
export const SIGN_IN_SEARCH = 'loginSearch';
|
||||||
|
|
||||||
|
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
|
||||||
|
|
||||||
|
export function verifyAuthorization(): AxiosPromise<void> {
|
||||||
|
return AXIOS.get('/verifyAuthorization');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
|
||||||
|
return AXIOS.post('/signIn', request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
|
||||||
|
*/
|
||||||
|
export function getStorage() {
|
||||||
|
return localStorage || sessionStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeLoginRedirect(location?: H.Location) {
|
||||||
|
if (location) {
|
||||||
|
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
||||||
|
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLoginRedirect() {
|
||||||
|
getStorage().removeItem(SIGN_IN_PATHNAME);
|
||||||
|
getStorage().removeItem(SIGN_IN_SEARCH);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLoginRedirect(features: Features): Partial<Path> {
|
||||||
|
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
|
||||||
|
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
|
||||||
|
clearLoginRedirect();
|
||||||
|
return {
|
||||||
|
pathname: signInPathname || getDefaultRoute(features),
|
||||||
|
search: (signInPathname && signInSearch) || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
|
||||||
|
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
|
||||||
|
|
||||||
|
export function addAccessTokenParameter(url: string) {
|
||||||
|
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
||||||
|
if (!accessToken) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
|
||||||
|
return parsedUrl.toString();
|
||||||
|
}
|
||||||
105
interface/src/api/endpoints.ts
Normal file
105
interface/src/api/endpoints.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import axios, { AxiosPromise, CancelToken } from 'axios';
|
||||||
|
|
||||||
|
import { decode } from '@msgpack/msgpack';
|
||||||
|
|
||||||
|
export const WS_BASE_URL = '/ws/';
|
||||||
|
export const API_BASE_URL = '/rest/';
|
||||||
|
export const ES_BASE_URL = '/es/';
|
||||||
|
export const EMSESP_API_BASE_URL = '/api/';
|
||||||
|
export const ACCESS_TOKEN = 'access_token';
|
||||||
|
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
|
||||||
|
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
|
||||||
|
|
||||||
|
export const AXIOS = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
transformRequest: [
|
||||||
|
(data, headers) => {
|
||||||
|
if (headers) {
|
||||||
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
|
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
if (headers['Content-Type'] !== 'application/json') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AXIOS_API = axios.create({
|
||||||
|
baseURL: EMSESP_API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
transformRequest: [
|
||||||
|
(data, headers) => {
|
||||||
|
if (headers) {
|
||||||
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
|
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
if (headers['Content-Type'] !== 'application/json') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AXIOS_BIN = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
transformRequest: [
|
||||||
|
(data, headers) => {
|
||||||
|
if (headers) {
|
||||||
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||||
|
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
if (headers['Content-Type'] !== 'application/json') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(data);
|
||||||
|
}
|
||||||
|
],
|
||||||
|
transformResponse: [
|
||||||
|
(data) => {
|
||||||
|
return decode(data);
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
function calculateWebSocketRoot(webSocketPath: string) {
|
||||||
|
const location = window.location;
|
||||||
|
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return webProtocol + '//' + location.host + webSocketPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateEventSourceRoot(endpointPath: string) {
|
||||||
|
const location = window.location;
|
||||||
|
return location.protocol + '//' + location.host + endpointPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileUploadConfig {
|
||||||
|
cancelToken?: CancelToken;
|
||||||
|
onUploadProgress?: (progressEvent: ProgressEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const startUploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return AXIOS.post(url, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data'
|
||||||
|
},
|
||||||
|
...(config || {})
|
||||||
|
});
|
||||||
|
};
|
||||||
2
interface/src/api/env.ts
Normal file
2
interface/src/api/env.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
|
||||||
|
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';
|
||||||
9
interface/src/api/features.ts
Normal file
9
interface/src/api/features.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
|
||||||
|
import { Features } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readFeatures(): AxiosPromise<Features> {
|
||||||
|
return AXIOS.get('/features');
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './Env';
|
|
||||||
export * from './Endpoints';
|
|
||||||
16
interface/src/api/mqtt.ts
Normal file
16
interface/src/api/mqtt.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
import { MqttSettings, MqttStatus } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readMqttStatus(): AxiosPromise<MqttStatus> {
|
||||||
|
return AXIOS.get('/mqttStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readMqttSettings(): AxiosPromise<MqttSettings> {
|
||||||
|
return AXIOS.get('/mqttSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMqttSettings(ntpSettings: MqttSettings): AxiosPromise<MqttSettings> {
|
||||||
|
return AXIOS.post('/mqttSettings', ntpSettings);
|
||||||
|
}
|
||||||
25
interface/src/api/network.ts
Normal file
25
interface/src/api/network.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
|
||||||
|
import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
|
||||||
|
return AXIOS.get('/networkStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scanNetworks(): AxiosPromise<void> {
|
||||||
|
return AXIOS.get('/scanNetworks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNetworks(): AxiosPromise<WiFiNetworkList> {
|
||||||
|
return AXIOS.get('/listNetworks');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
|
||||||
|
return AXIOS.get('/networkSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
|
||||||
|
return AXIOS.post('/networkSettings', wifiSettings);
|
||||||
|
}
|
||||||
20
interface/src/api/ntp.ts
Normal file
20
interface/src/api/ntp.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
import { NTPSettings, NTPStatus, Time } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readNTPStatus(): AxiosPromise<NTPStatus> {
|
||||||
|
return AXIOS.get('/ntpStatus');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNTPSettings(): AxiosPromise<NTPSettings> {
|
||||||
|
return AXIOS.get('/ntpSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
|
||||||
|
return AXIOS.post('/ntpSettings', ntpSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTime(time: Time): AxiosPromise<Time> {
|
||||||
|
return AXIOS.post('/time', time);
|
||||||
|
}
|
||||||
17
interface/src/api/security.ts
Normal file
17
interface/src/api/security.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
|
||||||
|
import { SecuritySettings, Token } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS } from './endpoints';
|
||||||
|
|
||||||
|
export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
|
||||||
|
return AXIOS.get('/securitySettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
|
||||||
|
return AXIOS.post('/securitySettings', securitySettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateToken(username?: string): AxiosPromise<Token> {
|
||||||
|
return AXIOS.get('/generateToken', { params: { username } });
|
||||||
|
}
|
||||||
41
interface/src/api/system.ts
Normal file
41
interface/src/api/system.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { AxiosPromise } from 'axios';
|
||||||
|
|
||||||
|
import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
|
||||||
|
|
||||||
|
import { AXIOS, AXIOS_BIN, FileUploadConfig, startUploadFile } from './endpoints';
|
||||||
|
|
||||||
|
export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
|
||||||
|
return AXIOS.get('/systemStatus', { timeout });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restart(): AxiosPromise<void> {
|
||||||
|
return AXIOS.post('/restart');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function factoryReset(): AxiosPromise<void> {
|
||||||
|
return AXIOS.post('/factoryReset');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOTASettings(): AxiosPromise<OTASettings> {
|
||||||
|
return AXIOS.get('/otaSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
|
||||||
|
return AXIOS.post('/otaSettings', otaSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadFile = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
|
||||||
|
startUploadFile('/uploadFile', file, config);
|
||||||
|
|
||||||
|
export function readLogSettings(): AxiosPromise<LogSettings> {
|
||||||
|
return AXIOS.get('/logSettings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
|
||||||
|
return AXIOS.post('/logSettings', logSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLogEntries(): AxiosPromise<LogEntries> {
|
||||||
|
return AXIOS_BIN.get('/fetchLog');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import {
|
|
||||||
Redirect,
|
|
||||||
Route,
|
|
||||||
RouteProps,
|
|
||||||
RouteComponentProps
|
|
||||||
} from 'react-router-dom';
|
|
||||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
|
||||||
|
|
||||||
import * as Authentication from './Authentication';
|
|
||||||
import {
|
|
||||||
withAuthenticationContext,
|
|
||||||
AuthenticationContextProps,
|
|
||||||
AuthenticatedContext,
|
|
||||||
AuthenticatedContextValue
|
|
||||||
} from './AuthenticationContext';
|
|
||||||
|
|
||||||
interface AuthenticatedRouteProps
|
|
||||||
extends RouteProps,
|
|
||||||
WithSnackbarProps,
|
|
||||||
AuthenticationContextProps {
|
|
||||||
component:
|
|
||||||
| React.ComponentType<RouteComponentProps<any>>
|
|
||||||
| React.ComponentType<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
|
|
||||||
|
|
||||||
export class AuthenticatedRoute extends React.Component<AuthenticatedRouteProps> {
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
enqueueSnackbar,
|
|
||||||
authenticationContext,
|
|
||||||
component: Component,
|
|
||||||
...rest
|
|
||||||
} = this.props;
|
|
||||||
const { location } = this.props;
|
|
||||||
const renderComponent: RenderComponent = (props) => {
|
|
||||||
if (authenticationContext.me) {
|
|
||||||
return (
|
|
||||||
<AuthenticatedContext.Provider
|
|
||||||
value={authenticationContext as AuthenticatedContextValue}
|
|
||||||
>
|
|
||||||
<Component {...props} />
|
|
||||||
</AuthenticatedContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Authentication.storeLoginRedirect(location);
|
|
||||||
enqueueSnackbar('Please sign in to continue', { variant: 'info' });
|
|
||||||
return <Redirect to="/" />;
|
|
||||||
};
|
|
||||||
return <Route {...rest} render={renderComponent} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import * as H from 'history';
|
|
||||||
|
|
||||||
import history from '../history';
|
|
||||||
import { Features } from '../features/types';
|
|
||||||
import { getDefaultRoute } from '../AppRouting';
|
|
||||||
|
|
||||||
export const ACCESS_TOKEN = 'access_token';
|
|
||||||
export const SIGN_IN_PATHNAME = 'signInPathname';
|
|
||||||
export const SIGN_IN_SEARCH = 'signInSearch';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
|
|
||||||
*/
|
|
||||||
export function getStorage() {
|
|
||||||
return localStorage || sessionStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeLoginRedirect(location?: H.Location) {
|
|
||||||
if (location) {
|
|
||||||
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
|
|
||||||
getStorage().setItem(SIGN_IN_SEARCH, location.search);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearLoginRedirect() {
|
|
||||||
getStorage().removeItem(SIGN_IN_PATHNAME);
|
|
||||||
getStorage().removeItem(SIGN_IN_SEARCH);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchLoginRedirect(
|
|
||||||
features: Features
|
|
||||||
): H.LocationDescriptorObject {
|
|
||||||
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
|
|
||||||
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
|
|
||||||
clearLoginRedirect();
|
|
||||||
return {
|
|
||||||
pathname: signInPathname || getDefaultRoute(features),
|
|
||||||
search: (signInPathname && signInSearch) || undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the normal fetch routine with one with provides the access token if present.
|
|
||||||
*/
|
|
||||||
export function authorizedFetch(
|
|
||||||
url: RequestInfo,
|
|
||||||
params?: RequestInit
|
|
||||||
): Promise<Response> {
|
|
||||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
|
||||||
if (accessToken) {
|
|
||||||
params = params || {};
|
|
||||||
params.credentials = 'include';
|
|
||||||
params.headers = {
|
|
||||||
...params.headers,
|
|
||||||
Authorization: 'Bearer ' + accessToken
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return fetch(url, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request
|
|
||||||
* for a single file upload and takes care of adding the Authorization header and redirecting on
|
|
||||||
* authorization errors as we do for normal fetch operations.
|
|
||||||
*/
|
|
||||||
export function redirectingAuthorizedUpload(
|
|
||||||
xhr: XMLHttpRequest,
|
|
||||||
url: string,
|
|
||||||
file: File,
|
|
||||||
onProgress: (event: ProgressEvent<EventTarget>) => void
|
|
||||||
): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
xhr.open('POST', url, true);
|
|
||||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
|
||||||
if (accessToken) {
|
|
||||||
xhr.withCredentials = true;
|
|
||||||
xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
|
|
||||||
}
|
|
||||||
xhr.upload.onprogress = onProgress;
|
|
||||||
xhr.onload = function () {
|
|
||||||
if (xhr.status === 401 || xhr.status === 403) {
|
|
||||||
history.push('/unauthorized');
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function () {
|
|
||||||
reject(new DOMException('Error', 'UploadError'));
|
|
||||||
};
|
|
||||||
xhr.onabort = function () {
|
|
||||||
reject(new DOMException('Aborted', 'AbortError'));
|
|
||||||
};
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
xhr.send(formData);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps the normal fetch routine which redirects on 401 response.
|
|
||||||
*/
|
|
||||||
export function redirectingAuthorizedFetch(
|
|
||||||
url: RequestInfo,
|
|
||||||
params?: RequestInit
|
|
||||||
): Promise<Response> {
|
|
||||||
return new Promise<Response>((resolve, reject) => {
|
|
||||||
authorizedFetch(url, params)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
history.push('/unauthorized');
|
|
||||||
} else {
|
|
||||||
resolve(response);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addAccessTokenParameter(url: string) {
|
|
||||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
|
||||||
if (!accessToken) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
const parsedUrl = new URL(url);
|
|
||||||
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
|
|
||||||
return parsedUrl.toString();
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
export interface Me {
|
|
||||||
username: string;
|
|
||||||
admin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthenticationContextValue {
|
|
||||||
refresh: () => void;
|
|
||||||
signIn: (accessToken: string) => void;
|
|
||||||
signOut: () => void;
|
|
||||||
me?: Me;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
|
|
||||||
export const AuthenticationContext = React.createContext(
|
|
||||||
AuthenticationContextDefaultValue
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface AuthenticationContextProps {
|
|
||||||
authenticationContext: AuthenticationContextValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withAuthenticationContext<T extends AuthenticationContextProps>(
|
|
||||||
Component: React.ComponentType<T>
|
|
||||||
) {
|
|
||||||
return class extends React.Component<
|
|
||||||
Omit<T, keyof AuthenticationContextProps>
|
|
||||||
> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<AuthenticationContext.Consumer>
|
|
||||||
{(authenticationContext) => (
|
|
||||||
<Component
|
|
||||||
{...(this.props as T)}
|
|
||||||
authenticationContext={authenticationContext}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AuthenticationContext.Consumer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthenticatedContextValue extends AuthenticationContextValue {
|
|
||||||
me: Me;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
|
|
||||||
export const AuthenticatedContext = React.createContext(
|
|
||||||
AuthenticatedContextDefaultValue
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface AuthenticatedContextProps {
|
|
||||||
authenticatedContext: AuthenticatedContextValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(
|
|
||||||
Component: React.ComponentType<T>
|
|
||||||
) {
|
|
||||||
return class extends React.Component<
|
|
||||||
Omit<T, keyof AuthenticatedContextProps>
|
|
||||||
> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<AuthenticatedContext.Consumer>
|
|
||||||
{(authenticatedContext) => (
|
|
||||||
<Component
|
|
||||||
{...(this.props as T)}
|
|
||||||
authenticatedContext={authenticatedContext}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AuthenticatedContext.Consumer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
|
||||||
import jwtDecode from 'jwt-decode';
|
|
||||||
|
|
||||||
import history from '../history';
|
|
||||||
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
|
|
||||||
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
|
|
||||||
import {
|
|
||||||
AuthenticationContext,
|
|
||||||
AuthenticationContextValue,
|
|
||||||
Me
|
|
||||||
} from './AuthenticationContext';
|
|
||||||
import FullScreenLoading from '../components/FullScreenLoading';
|
|
||||||
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
|
|
||||||
|
|
||||||
export const decodeMeJWT = (accessToken: string): Me =>
|
|
||||||
jwtDecode(accessToken) as Me;
|
|
||||||
|
|
||||||
interface AuthenticationWrapperState {
|
|
||||||
context: AuthenticationContextValue;
|
|
||||||
initialized: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
|
|
||||||
|
|
||||||
class AuthenticationWrapper extends React.Component<
|
|
||||||
AuthenticationWrapperProps,
|
|
||||||
AuthenticationWrapperState
|
|
||||||
> {
|
|
||||||
constructor(props: AuthenticationWrapperProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
context: {
|
|
||||||
refresh: this.refresh,
|
|
||||||
signIn: this.signIn,
|
|
||||||
signOut: this.signOut
|
|
||||||
},
|
|
||||||
initialized: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
{this.state.initialized
|
|
||||||
? this.renderContent()
|
|
||||||
: this.renderContentLoading()}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderContent() {
|
|
||||||
return (
|
|
||||||
<AuthenticationContext.Provider value={this.state.context}>
|
|
||||||
{this.props.children}
|
|
||||||
</AuthenticationContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderContentLoading() {
|
|
||||||
return <FullScreenLoading />;
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh = () => {
|
|
||||||
// commented out, always need security - proddy
|
|
||||||
// if (!this.props.features.security) {
|
|
||||||
// this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
const accessToken = getStorage().getItem(ACCESS_TOKEN);
|
|
||||||
if (accessToken) {
|
|
||||||
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
|
|
||||||
.then((response) => {
|
|
||||||
const me =
|
|
||||||
response.status === 200 ? decodeMeJWT(accessToken) : undefined;
|
|
||||||
this.setState({
|
|
||||||
initialized: true,
|
|
||||||
context: { ...this.state.context, me }
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.setState({
|
|
||||||
initialized: true,
|
|
||||||
context: { ...this.state.context, me: undefined }
|
|
||||||
});
|
|
||||||
this.props.enqueueSnackbar(
|
|
||||||
'Error verifying authorization: ' + error.message,
|
|
||||||
{
|
|
||||||
variant: 'error'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
initialized: true,
|
|
||||||
context: { ...this.state.context, me: undefined }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
signIn = (accessToken: string) => {
|
|
||||||
try {
|
|
||||||
getStorage().setItem(ACCESS_TOKEN, accessToken);
|
|
||||||
const me: Me = decodeMeJWT(accessToken);
|
|
||||||
this.setState({ context: { ...this.state.context, me } });
|
|
||||||
this.props.enqueueSnackbar(`Logged in as ${me.username}`, {
|
|
||||||
variant: 'success'
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
this.setState({
|
|
||||||
initialized: true,
|
|
||||||
context: { ...this.state.context, me: undefined }
|
|
||||||
});
|
|
||||||
throw new Error('Failed to parse JWT ' + err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
signOut = () => {
|
|
||||||
getStorage().removeItem(ACCESS_TOKEN);
|
|
||||||
this.setState({
|
|
||||||
context: {
|
|
||||||
...this.state.context,
|
|
||||||
me: undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.props.enqueueSnackbar('You have signed out', { variant: 'success' });
|
|
||||||
history.push('/');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withFeatures(withSnackbar(AuthenticationWrapper));
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import {
|
|
||||||
Redirect,
|
|
||||||
Route,
|
|
||||||
RouteProps,
|
|
||||||
RouteComponentProps
|
|
||||||
} from 'react-router-dom';
|
|
||||||
|
|
||||||
import {
|
|
||||||
withAuthenticationContext,
|
|
||||||
AuthenticationContextProps
|
|
||||||
} from './AuthenticationContext';
|
|
||||||
import * as Authentication from './Authentication';
|
|
||||||
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
|
|
||||||
|
|
||||||
interface UnauthenticatedRouteProps
|
|
||||||
extends RouteProps,
|
|
||||||
AuthenticationContextProps,
|
|
||||||
WithFeaturesProps {
|
|
||||||
component:
|
|
||||||
| React.ComponentType<RouteComponentProps<any>>
|
|
||||||
| React.ComponentType<any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
|
|
||||||
public render() {
|
|
||||||
const { authenticationContext, features, ...rest } = this.props;
|
|
||||||
if (authenticationContext.me) {
|
|
||||||
return <Redirect to={Authentication.fetchLoginRedirect(features)} />;
|
|
||||||
}
|
|
||||||
return <Route {...rest} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
|
|
||||||
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
|
|
||||||
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
|
|
||||||
|
|
||||||
export * from './Authentication';
|
|
||||||
export * from './AuthenticationContext';
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import React, { FC } from 'react';
|
|
||||||
import { makeStyles } from '@material-ui/styles';
|
|
||||||
import { Paper, Typography, Box, CssBaseline } from '@material-ui/core';
|
|
||||||
import WarningIcon from '@material-ui/icons/Warning';
|
|
||||||
|
|
||||||
const styles = makeStyles({
|
|
||||||
siteErrorPage: {
|
|
||||||
display: 'flex',
|
|
||||||
height: '100vh',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flexDirection: 'column'
|
|
||||||
},
|
|
||||||
siteErrorPagePanel: {
|
|
||||||
textAlign: 'center',
|
|
||||||
padding: '280px 0 40px 0',
|
|
||||||
backgroundImage: 'url("/app/icon.png")',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundPosition: '50% 40px',
|
|
||||||
backgroundSize: '200px auto',
|
|
||||||
width: '100%'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ApplicationErrorProps {
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
|
|
||||||
const classes = styles();
|
|
||||||
return (
|
|
||||||
<div className={classes.siteErrorPage}>
|
|
||||||
<CssBaseline />
|
|
||||||
<Paper className={classes.siteErrorPagePanel} elevation={10}>
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
mb={2}
|
|
||||||
>
|
|
||||||
<WarningIcon fontSize="large" color="error" />
|
|
||||||
<Box ml={2}>
|
|
||||||
<Typography variant="h4">Application error</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="subtitle1" gutterBottom>
|
|
||||||
Failed to configure the application, please refresh to try again.
|
|
||||||
</Typography>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="subtitle2" gutterBottom>
|
|
||||||
Error: {error}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ApplicationError;
|
|
||||||
26
interface/src/components/ButtonRow.tsx
Normal file
26
interface/src/components/ButtonRow.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Box, BoxProps } from '@mui/material';
|
||||||
|
|
||||||
|
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
'& button, & a, & .MuiCard-root': {
|
||||||
|
mt: 2,
|
||||||
|
mx: 0.6,
|
||||||
|
'&:last-child': {
|
||||||
|
mr: 0
|
||||||
|
},
|
||||||
|
'&:first-of-type': {
|
||||||
|
ml: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ButtonRow;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Button, styled } from '@material-ui/core';
|
|
||||||
|
|
||||||
const ErrorButton = styled(Button)(({ theme }) => ({
|
|
||||||
color: theme.palette.getContrastText(theme.palette.error.main),
|
|
||||||
backgroundColor: theme.palette.error.main,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: theme.palette.error.dark
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default ErrorButton;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { styled, Box } from '@material-ui/core';
|
|
||||||
|
|
||||||
const FormActions = styled(Box)(({ theme }) => ({
|
|
||||||
marginTop: theme.spacing(1)
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default FormActions;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Button, styled } from '@material-ui/core';
|
|
||||||
|
|
||||||
const FormButton = styled(Button)(({ theme }) => ({
|
|
||||||
margin: theme.spacing(0, 1),
|
|
||||||
'&:last-child': {
|
|
||||||
marginRight: 0
|
|
||||||
},
|
|
||||||
'&:first-child': {
|
|
||||||
marginLeft: 0
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default FormButton;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { FC } from 'react';
|
|
||||||
|
|
||||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
|
||||||
import { Button, LinearProgress, Typography } from '@material-ui/core';
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
loadingSettings: {
|
|
||||||
margin: theme.spacing(0.5)
|
|
||||||
},
|
|
||||||
loadingSettingsDetails: {
|
|
||||||
margin: theme.spacing(4),
|
|
||||||
textAlign: 'center'
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginRight: theme.spacing(2),
|
|
||||||
marginTop: theme.spacing(2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
interface FormLoaderProps {
|
|
||||||
errorMessage?: string;
|
|
||||||
loadData: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, loadData }) => {
|
|
||||||
const classes = useStyles();
|
|
||||||
if (errorMessage) {
|
|
||||||
return (
|
|
||||||
<div className={classes.loadingSettings}>
|
|
||||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
|
||||||
{errorMessage}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
className={classes.button}
|
|
||||||
onClick={loadData}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={classes.loadingSettings}>
|
|
||||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
|
||||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
|
||||||
Loading…
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FormLoader;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import CircularProgress from '@material-ui/core/CircularProgress';
|
|
||||||
import { Typography, Theme } from '@material-ui/core';
|
|
||||||
import { makeStyles, createStyles } from '@material-ui/styles';
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
fullScreenLoading: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100vh',
|
|
||||||
flexDirection: 'column'
|
|
||||||
},
|
|
||||||
progress: {
|
|
||||||
margin: theme.spacing(4)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const FullScreenLoading = () => {
|
|
||||||
const classes = useStyles();
|
|
||||||
return (
|
|
||||||
<div className={classes.fullScreenLoading}>
|
|
||||||
<CircularProgress className={classes.progress} size={100} />
|
|
||||||
<Typography variant="h4">Loading…</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FullScreenLoading;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Avatar, makeStyles } from '@material-ui/core';
|
|
||||||
import { FC } from 'react';
|
|
||||||
|
|
||||||
interface HighlightAvatarProps {
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
|
||||||
root: (props: HighlightAvatarProps) => ({
|
|
||||||
backgroundColor: props.color
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
|
|
||||||
const classes = useStyles(props);
|
|
||||||
return <Avatar className={classes.root}>{props.children}</Avatar>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HighlightAvatar;
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
import React, { RefObject, Fragment } from 'react';
|
|
||||||
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Drawer,
|
|
||||||
AppBar,
|
|
||||||
Toolbar,
|
|
||||||
Avatar,
|
|
||||||
Divider,
|
|
||||||
Button,
|
|
||||||
Box,
|
|
||||||
IconButton
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import {
|
|
||||||
ClickAwayListener,
|
|
||||||
Popper,
|
|
||||||
Hidden,
|
|
||||||
Typography
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import {
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
ListItemAvatar
|
|
||||||
} from '@material-ui/core';
|
|
||||||
import { Card, CardContent, CardActions } from '@material-ui/core';
|
|
||||||
|
|
||||||
import {
|
|
||||||
withStyles,
|
|
||||||
createStyles,
|
|
||||||
Theme,
|
|
||||||
WithTheme,
|
|
||||||
WithStyles,
|
|
||||||
withTheme
|
|
||||||
} from '@material-ui/core/styles';
|
|
||||||
|
|
||||||
import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
|
|
||||||
import SettingsIcon from '@material-ui/icons/Settings';
|
|
||||||
import AccessTimeIcon from '@material-ui/icons/AccessTime';
|
|
||||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
|
||||||
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
|
|
||||||
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
|
||||||
import LockIcon from '@material-ui/icons/Lock';
|
|
||||||
import MenuIcon from '@material-ui/icons/Menu';
|
|
||||||
|
|
||||||
import ProjectMenu from '../project/ProjectMenu';
|
|
||||||
import { PROJECT_NAME } from '../api';
|
|
||||||
import {
|
|
||||||
withAuthenticatedContext,
|
|
||||||
AuthenticatedContextProps
|
|
||||||
} from '../authentication';
|
|
||||||
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
|
|
||||||
|
|
||||||
const drawerWidth = 290;
|
|
||||||
|
|
||||||
const styles = (theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
root: {
|
|
||||||
display: 'flex'
|
|
||||||
},
|
|
||||||
drawer: {
|
|
||||||
[theme.breakpoints.up('md')]: {
|
|
||||||
width: drawerWidth,
|
|
||||||
flexShrink: 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
flexGrow: 1
|
|
||||||
},
|
|
||||||
appBar: {
|
|
||||||
marginLeft: drawerWidth,
|
|
||||||
[theme.breakpoints.up('md')]: {
|
|
||||||
width: `calc(100% - ${drawerWidth}px)`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toolbarImage: {
|
|
||||||
[theme.breakpoints.up('xs')]: {
|
|
||||||
height: 24,
|
|
||||||
marginRight: theme.spacing(2)
|
|
||||||
},
|
|
||||||
[theme.breakpoints.up('sm')]: {
|
|
||||||
height: 36,
|
|
||||||
marginRight: theme.spacing(3)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
menuButton: {
|
|
||||||
marginRight: theme.spacing(2),
|
|
||||||
[theme.breakpoints.up('md')]: {
|
|
||||||
display: 'none'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
toolbar: theme.mixins.toolbar,
|
|
||||||
drawerPaper: {
|
|
||||||
width: drawerWidth
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flexGrow: 1
|
|
||||||
},
|
|
||||||
authMenu: {
|
|
||||||
zIndex: theme.zIndex.tooltip,
|
|
||||||
maxWidth: 400
|
|
||||||
},
|
|
||||||
authMenuActions: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
'& > * + *': {
|
|
||||||
marginLeft: theme.spacing(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
interface MenuAppBarState {
|
|
||||||
mobileOpen: boolean;
|
|
||||||
authMenuOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuAppBarProps
|
|
||||||
extends WithFeaturesProps,
|
|
||||||
AuthenticatedContextProps,
|
|
||||||
WithTheme,
|
|
||||||
WithStyles<typeof styles>,
|
|
||||||
RouteComponentProps {
|
|
||||||
sectionTitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
|
|
||||||
constructor(props: MenuAppBarProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
mobileOpen: false,
|
|
||||||
authMenuOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
|
|
||||||
|
|
||||||
handleToggle = () => {
|
|
||||||
this.setState({ authMenuOpen: !this.state.authMenuOpen });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClose = (event: React.MouseEvent<Document>) => {
|
|
||||||
if (
|
|
||||||
this.anchorRef.current &&
|
|
||||||
this.anchorRef.current.contains(event.currentTarget)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({ authMenuOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleDrawerToggle = () => {
|
|
||||||
this.setState({ mobileOpen: !this.state.mobileOpen });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
classes,
|
|
||||||
theme,
|
|
||||||
children,
|
|
||||||
sectionTitle,
|
|
||||||
authenticatedContext,
|
|
||||||
features
|
|
||||||
} = this.props;
|
|
||||||
const { mobileOpen, authMenuOpen } = this.state;
|
|
||||||
const path = this.props.match.url;
|
|
||||||
const drawer = (
|
|
||||||
<div>
|
|
||||||
<Toolbar>
|
|
||||||
<Box display="flex">
|
|
||||||
<img
|
|
||||||
src="/app/icon.png"
|
|
||||||
className={classes.toolbarImage}
|
|
||||||
alt={PROJECT_NAME}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Typography variant="h6" color="textPrimary">
|
|
||||||
{PROJECT_NAME}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Divider absolute />
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
{features.project && (
|
|
||||||
<Fragment>
|
|
||||||
<ProjectMenu />
|
|
||||||
<Divider />
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<List>
|
|
||||||
<ListItem
|
|
||||||
to="/network/"
|
|
||||||
selected={path.startsWith('/network/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<SettingsEthernetIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Network Connection" />
|
|
||||||
</ListItem>
|
|
||||||
<ListItem
|
|
||||||
to="/ap/"
|
|
||||||
selected={path.startsWith('/ap/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<SettingsInputAntennaIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Access Point" />
|
|
||||||
</ListItem>
|
|
||||||
{features.ntp && (
|
|
||||||
<ListItem
|
|
||||||
to="/ntp/"
|
|
||||||
selected={path.startsWith('/ntp/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<AccessTimeIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Network Time" />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
{features.mqtt && (
|
|
||||||
<ListItem
|
|
||||||
to="/mqtt/"
|
|
||||||
selected={path.startsWith('/mqtt/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<DeviceHubIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="MQTT" />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
{features.security && (
|
|
||||||
<ListItem
|
|
||||||
to="/security/"
|
|
||||||
selected={path.startsWith('/security/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
disabled={!authenticatedContext.me.admin}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<LockIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="Security" />
|
|
||||||
</ListItem>
|
|
||||||
)}
|
|
||||||
<ListItem
|
|
||||||
to="/system/"
|
|
||||||
selected={path.startsWith('/system/')}
|
|
||||||
button
|
|
||||||
component={Link}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<SettingsIcon />
|
|
||||||
</ListItemIcon>
|
|
||||||
<ListItemText primary="System" />
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const userMenu = (
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
ref={this.anchorRef}
|
|
||||||
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
|
|
||||||
aria-haspopup="true"
|
|
||||||
onClick={this.handleToggle}
|
|
||||||
color="inherit"
|
|
||||||
>
|
|
||||||
<AccountCircleIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Popper
|
|
||||||
open={authMenuOpen}
|
|
||||||
anchorEl={this.anchorRef.current}
|
|
||||||
transition
|
|
||||||
className={classes.authMenu}
|
|
||||||
>
|
|
||||||
<ClickAwayListener onClickAway={this.handleClose}>
|
|
||||||
<Card id="menu-list-grow">
|
|
||||||
<CardContent>
|
|
||||||
<List disablePadding>
|
|
||||||
<ListItem disableGutters>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Avatar>
|
|
||||||
<AccountCircleIcon />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
'Signed in as: ' + authenticatedContext.me.username
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
authenticatedContext.me.admin ? 'Admin User' : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
</List>
|
|
||||||
</CardContent>
|
|
||||||
<Divider />
|
|
||||||
<CardActions className={classes.authMenuActions}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
fullWidth
|
|
||||||
color="primary"
|
|
||||||
onClick={authenticatedContext.signOut}
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</Button>
|
|
||||||
</CardActions>
|
|
||||||
</Card>
|
|
||||||
</ClickAwayListener>
|
|
||||||
</Popper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classes.root}>
|
|
||||||
<AppBar position="fixed" className={classes.appBar} elevation={0}>
|
|
||||||
<Toolbar>
|
|
||||||
<IconButton
|
|
||||||
color="inherit"
|
|
||||||
aria-label="Open drawer"
|
|
||||||
edge="start"
|
|
||||||
onClick={this.handleDrawerToggle}
|
|
||||||
className={classes.menuButton}
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
color="inherit"
|
|
||||||
noWrap
|
|
||||||
className={classes.title}
|
|
||||||
>
|
|
||||||
{sectionTitle}
|
|
||||||
</Typography>
|
|
||||||
{features.security && userMenu}
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
<nav className={classes.drawer}>
|
|
||||||
<Hidden mdUp implementation="css">
|
|
||||||
<Drawer
|
|
||||||
variant="temporary"
|
|
||||||
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
|
|
||||||
open={mobileOpen}
|
|
||||||
onClose={this.handleDrawerToggle}
|
|
||||||
classes={{
|
|
||||||
paper: classes.drawerPaper
|
|
||||||
}}
|
|
||||||
ModalProps={{
|
|
||||||
keepMounted: true
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{drawer}
|
|
||||||
</Drawer>
|
|
||||||
</Hidden>
|
|
||||||
<Hidden smDown implementation="css">
|
|
||||||
<Drawer
|
|
||||||
classes={{
|
|
||||||
paper: classes.drawerPaper
|
|
||||||
}}
|
|
||||||
variant="permanent"
|
|
||||||
open
|
|
||||||
>
|
|
||||||
{drawer}
|
|
||||||
</Drawer>
|
|
||||||
</Hidden>
|
|
||||||
</nav>
|
|
||||||
<main className={classes.content}>
|
|
||||||
<div className={classes.toolbar} />
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withRouter(
|
|
||||||
withTheme(
|
|
||||||
withFeatures(withAuthenticatedContext(withStyles(styles)(MenuAppBar)))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
47
interface/src/components/MessageBox.tsx
Normal file
47
interface/src/components/MessageBox.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
|
||||||
|
|
||||||
|
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
|
||||||
|
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
|
||||||
|
|
||||||
|
export interface MessageBoxProps extends BoxProps {
|
||||||
|
level: MessageBoxLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_ICONS: { [type in MessageBoxLevel]: React.ComponentType<SvgIconProps> } = {
|
||||||
|
success: CheckCircleOutlineOutlinedIcon,
|
||||||
|
info: InfoOutlinedIcon,
|
||||||
|
warning: ReportProblemOutlinedIcon,
|
||||||
|
error: ErrorIcon
|
||||||
|
};
|
||||||
|
|
||||||
|
const LEVEL_BACKGROUNDS: { [type in MessageBoxLevel]: (theme: Theme) => string } = {
|
||||||
|
success: (theme: Theme) => theme.palette.success.dark,
|
||||||
|
info: (theme: Theme) => theme.palette.info.main,
|
||||||
|
warning: (theme: Theme) => theme.palette.warning.dark,
|
||||||
|
error: (theme: Theme) => theme.palette.error.dark
|
||||||
|
};
|
||||||
|
|
||||||
|
const MessageBox: FC<MessageBoxProps> = ({ level, message, sx, children, ...rest }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const Icon = LEVEL_ICONS[level];
|
||||||
|
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
|
||||||
|
const color = 'white';
|
||||||
|
return (
|
||||||
|
<Box p={2} display="flex" alignItems="center" borderRadius={1} sx={{ backgroundColor, color, ...sx }} {...rest}>
|
||||||
|
<Icon />
|
||||||
|
<Typography sx={{ ml: 2, flexGrow: 1 }} variant="body1">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageBox;
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
TextValidator,
|
|
||||||
ValidatorComponentProps
|
|
||||||
} from 'react-material-ui-form-validator';
|
|
||||||
|
|
||||||
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
|
|
||||||
import { InputAdornment, IconButton } from '@material-ui/core';
|
|
||||||
import { Visibility, VisibilityOff } from '@material-ui/icons';
|
|
||||||
|
|
||||||
const styles = createStyles({
|
|
||||||
input: {
|
|
||||||
'&::-ms-reveal': {
|
|
||||||
display: 'none'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type PasswordValidatorProps = WithStyles<typeof styles> &
|
|
||||||
Exclude<ValidatorComponentProps, 'type' | 'InputProps'>;
|
|
||||||
|
|
||||||
interface PasswordValidatorState {
|
|
||||||
showPassword: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PasswordValidator extends React.Component<
|
|
||||||
PasswordValidatorProps,
|
|
||||||
PasswordValidatorState
|
|
||||||
> {
|
|
||||||
state = {
|
|
||||||
showPassword: false
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleShowPassword = () => {
|
|
||||||
this.setState({
|
|
||||||
showPassword: !this.state.showPassword
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { classes, ...rest } = this.props;
|
|
||||||
return (
|
|
||||||
<TextValidator
|
|
||||||
{...rest}
|
|
||||||
type={this.state.showPassword ? 'text' : 'password'}
|
|
||||||
InputProps={{
|
|
||||||
classes,
|
|
||||||
endAdornment: (
|
|
||||||
<InputAdornment position="end">
|
|
||||||
<IconButton
|
|
||||||
aria-label="Toggle password visibility"
|
|
||||||
onClick={this.toggleShowPassword}
|
|
||||||
>
|
|
||||||
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withStyles(styles)(PasswordValidator);
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
|
||||||
|
|
||||||
import { redirectingAuthorizedFetch } from '../authentication';
|
|
||||||
|
|
||||||
export interface RestControllerProps<D> extends WithSnackbarProps {
|
|
||||||
handleValueChange: (
|
|
||||||
name: keyof D
|
|
||||||
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
|
|
||||||
setData: (data: D, callback?: () => void) => void;
|
|
||||||
saveData: () => void;
|
|
||||||
loadData: () => void;
|
|
||||||
|
|
||||||
data?: D;
|
|
||||||
loading: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const extractEventValue = (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
switch (event.target.type) {
|
|
||||||
case 'number':
|
|
||||||
return event.target.valueAsNumber;
|
|
||||||
case 'checkbox':
|
|
||||||
return event.target.checked;
|
|
||||||
default:
|
|
||||||
return event.target.value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface RestControllerState<D> {
|
|
||||||
data?: D;
|
|
||||||
loading: boolean;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restController<D, P extends RestControllerProps<D>>(
|
|
||||||
endpointUrl: string,
|
|
||||||
RestController: React.ComponentType<P & RestControllerProps<D>>
|
|
||||||
) {
|
|
||||||
return withSnackbar(
|
|
||||||
class extends React.Component<
|
|
||||||
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
|
|
||||||
RestControllerState<D>
|
|
||||||
> {
|
|
||||||
state: RestControllerState<D> = {
|
|
||||||
data: undefined,
|
|
||||||
loading: false,
|
|
||||||
errorMessage: undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
setData = (data: D, callback?: () => void) => {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
loading: false,
|
|
||||||
errorMessage: undefined
|
|
||||||
},
|
|
||||||
callback
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadData = () => {
|
|
||||||
this.setState({
|
|
||||||
data: undefined,
|
|
||||||
loading: true,
|
|
||||||
errorMessage: undefined
|
|
||||||
});
|
|
||||||
redirectingAuthorizedFetch(endpointUrl)
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
throw Error('Invalid status code: ' + response.status);
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
this.setState({ data: json, loading: false });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage = error.message || 'Unknown error';
|
|
||||||
this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
|
|
||||||
variant: 'error'
|
|
||||||
});
|
|
||||||
this.setState({ data: undefined, loading: false, errorMessage });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
saveData = () => {
|
|
||||||
this.setState({ loading: true });
|
|
||||||
redirectingAuthorizedFetch(endpointUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(this.state.data),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.status === 200) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
throw Error('Invalid status code: ' + response.status);
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
this.props.enqueueSnackbar('Update successful.', {
|
|
||||||
variant: 'success'
|
|
||||||
});
|
|
||||||
this.setState({ data: json, loading: false });
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const errorMessage = error.message || 'Unknown error';
|
|
||||||
this.props.enqueueSnackbar('Problem updating: ' + errorMessage, {
|
|
||||||
variant: 'error'
|
|
||||||
});
|
|
||||||
this.setState({ data: undefined, loading: false, errorMessage });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleValueChange = (name: keyof D) => (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
const data = { ...this.state.data!, [name]: extractEventValue(event) };
|
|
||||||
this.setState({ data });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<RestController
|
|
||||||
{...this.state}
|
|
||||||
{...(this.props as P)}
|
|
||||||
handleValueChange={this.handleValueChange}
|
|
||||||
setData={this.setData}
|
|
||||||
saveData={this.saveData}
|
|
||||||
loadData={this.loadData}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
|
||||||
import { Button, LinearProgress, Typography } from '@material-ui/core';
|
|
||||||
|
|
||||||
import { RestControllerProps } from '.';
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
loadingSettings: {
|
|
||||||
margin: theme.spacing(0.5)
|
|
||||||
},
|
|
||||||
loadingSettingsDetails: {
|
|
||||||
margin: theme.spacing(4),
|
|
||||||
textAlign: 'center'
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginRight: theme.spacing(2),
|
|
||||||
marginTop: theme.spacing(2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export type RestFormProps<D> = Omit<
|
|
||||||
RestControllerProps<D>,
|
|
||||||
'loading' | 'errorMessage'
|
|
||||||
> & { data: D };
|
|
||||||
|
|
||||||
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
|
|
||||||
render: (props: RestFormProps<D>) => JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
|
|
||||||
const { loading, errorMessage, loadData, render, data, ...rest } = props;
|
|
||||||
const classes = useStyles();
|
|
||||||
if (loading || !data) {
|
|
||||||
return (
|
|
||||||
<div className={classes.loadingSettings}>
|
|
||||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
|
||||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
|
||||||
Loading…
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (errorMessage) {
|
|
||||||
return (
|
|
||||||
<div className={classes.loadingSettings}>
|
|
||||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
|
||||||
{errorMessage}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
className={classes.button}
|
|
||||||
onClick={loadData}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return render({ ...rest, loadData, data });
|
|
||||||
}
|
|
||||||
@@ -1,31 +1,20 @@
|
|||||||
import React from 'react';
|
import { FC } from 'react';
|
||||||
|
|
||||||
import { Typography, Paper } from '@material-ui/core';
|
import { Paper, Divider } from '@mui/material';
|
||||||
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
import { RequiredChildrenProps } from '../utils';
|
||||||
createStyles({
|
|
||||||
content: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
margin: theme.spacing(3)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
interface SectionContentProps {
|
interface SectionContentProps extends RequiredChildrenProps {
|
||||||
title: string;
|
title: string;
|
||||||
titleGutter?: boolean;
|
titleGutter?: boolean;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SectionContent: React.FC<SectionContentProps> = (props) => {
|
const SectionContent: FC<SectionContentProps> = (props) => {
|
||||||
const { children, title, titleGutter, id } = props;
|
const { children, title, id } = props;
|
||||||
const classes = useStyles();
|
|
||||||
return (
|
return (
|
||||||
<Paper id={id} className={classes.content}>
|
<Paper id={id} sx={{ p: 2, m: 2 }}>
|
||||||
<Typography variant="h6" gutterBottom={titleGutter}>
|
<Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider>
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
{children}
|
{children}
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import React, { FC, Fragment } from 'react';
|
|
||||||
import { useDropzone, DropzoneState } from 'react-dropzone';
|
|
||||||
|
|
||||||
import { makeStyles, createStyles } from '@material-ui/styles';
|
|
||||||
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
|
|
||||||
import CancelIcon from '@material-ui/icons/Cancel';
|
|
||||||
import {
|
|
||||||
Theme,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
LinearProgress,
|
|
||||||
Button
|
|
||||||
} from '@material-ui/core';
|
|
||||||
|
|
||||||
interface SingleUploadStyleProps extends DropzoneState {
|
|
||||||
uploading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressPercentage = (progress: ProgressEvent) =>
|
|
||||||
Math.round((progress.loaded * 100) / progress.total);
|
|
||||||
|
|
||||||
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
|
|
||||||
if (props.isDragAccept) {
|
|
||||||
return theme.palette.success.main;
|
|
||||||
}
|
|
||||||
if (props.isDragReject) {
|
|
||||||
return theme.palette.error.main;
|
|
||||||
}
|
|
||||||
if (props.isDragActive) {
|
|
||||||
return theme.palette.info.main;
|
|
||||||
}
|
|
||||||
return theme.palette.grey[700];
|
|
||||||
};
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
dropzone: {
|
|
||||||
padding: theme.spacing(8, 2),
|
|
||||||
borderWidth: 2,
|
|
||||||
borderRadius: 2,
|
|
||||||
borderStyle: 'dashed',
|
|
||||||
color: theme.palette.grey[700],
|
|
||||||
transition: 'border .24s ease-in-out',
|
|
||||||
cursor: (props: SingleUploadStyleProps) =>
|
|
||||||
props.uploading ? 'default' : 'pointer',
|
|
||||||
width: '100%',
|
|
||||||
borderColor: (props: SingleUploadStyleProps) =>
|
|
||||||
getBorderColor(theme, props)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface SingleUploadProps {
|
|
||||||
onDrop: (acceptedFiles: File[]) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
accept?: string | string[];
|
|
||||||
uploading: boolean;
|
|
||||||
progress?: ProgressEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SingleUpload: FC<SingleUploadProps> = ({
|
|
||||||
onDrop,
|
|
||||||
onCancel,
|
|
||||||
accept,
|
|
||||||
uploading,
|
|
||||||
progress
|
|
||||||
}) => {
|
|
||||||
const dropzoneState = useDropzone({
|
|
||||||
onDrop,
|
|
||||||
accept,
|
|
||||||
disabled: uploading,
|
|
||||||
multiple: false
|
|
||||||
});
|
|
||||||
const { getRootProps, getInputProps } = dropzoneState;
|
|
||||||
const classes = useStyles({ ...dropzoneState, uploading });
|
|
||||||
|
|
||||||
const renderProgressText = () => {
|
|
||||||
if (uploading) {
|
|
||||||
if (progress?.lengthComputable) {
|
|
||||||
return `Uploading: ${progressPercentage(progress)}%`;
|
|
||||||
}
|
|
||||||
return 'Uploading\u2026';
|
|
||||||
}
|
|
||||||
return 'Drop file or click here';
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderProgress = (progress?: ProgressEvent) => (
|
|
||||||
<LinearProgress
|
|
||||||
variant={
|
|
||||||
!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
!progress
|
|
||||||
? 0
|
|
||||||
: progress.lengthComputable
|
|
||||||
? progressPercentage(progress)
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...getRootProps({ className: classes.dropzone })}>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<Box flexDirection="column" display="flex" alignItems="center">
|
|
||||||
<CloudUploadIcon fontSize="large" />
|
|
||||||
<Typography variant="h6">{renderProgressText()}</Typography>
|
|
||||||
{uploading && (
|
|
||||||
<Fragment>
|
|
||||||
<Box width="100%" p={2}>
|
|
||||||
{renderProgress(progress)}
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
startIcon={<CancelIcon />}
|
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SingleUpload;
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Sockette from 'sockette';
|
|
||||||
import throttle from 'lodash/throttle';
|
|
||||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
|
||||||
|
|
||||||
import { addAccessTokenParameter } from '../authentication';
|
|
||||||
import { extractEventValue } from '.';
|
|
||||||
|
|
||||||
export interface WebSocketControllerProps<D> extends WithSnackbarProps {
|
|
||||||
handleValueChange: (
|
|
||||||
name: keyof D
|
|
||||||
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
|
|
||||||
setData: (data: D, callback?: () => void) => void;
|
|
||||||
saveData: () => void;
|
|
||||||
saveDataAndClear(): () => void;
|
|
||||||
|
|
||||||
connected: boolean;
|
|
||||||
data?: D;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketControllerState<D> {
|
|
||||||
ws: Sockette;
|
|
||||||
connected: boolean;
|
|
||||||
clientId?: string;
|
|
||||||
data?: D;
|
|
||||||
}
|
|
||||||
|
|
||||||
enum WebSocketMessageType {
|
|
||||||
ID = 'id',
|
|
||||||
PAYLOAD = 'payload'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketIdMessage {
|
|
||||||
type: typeof WebSocketMessageType.ID;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WebSocketPayloadMessage<D> {
|
|
||||||
type: typeof WebSocketMessageType.PAYLOAD;
|
|
||||||
origin_id: string;
|
|
||||||
payload: D;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebSocketMessage<D> =
|
|
||||||
| WebSocketIdMessage
|
|
||||||
| WebSocketPayloadMessage<D>;
|
|
||||||
|
|
||||||
export function webSocketController<D, P extends WebSocketControllerProps<D>>(
|
|
||||||
wsUrl: string,
|
|
||||||
wsThrottle: number,
|
|
||||||
WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>
|
|
||||||
) {
|
|
||||||
return withSnackbar(
|
|
||||||
class extends React.Component<
|
|
||||||
Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps,
|
|
||||||
WebSocketControllerState<D>
|
|
||||||
> {
|
|
||||||
constructor(
|
|
||||||
props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps
|
|
||||||
) {
|
|
||||||
super(props);
|
|
||||||
this.state = {
|
|
||||||
ws: new Sockette(addAccessTokenParameter(wsUrl), {
|
|
||||||
onmessage: this.onMessage,
|
|
||||||
onopen: this.onOpen,
|
|
||||||
onclose: this.onClose
|
|
||||||
}),
|
|
||||||
connected: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.state.ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMessage = (event: MessageEvent) => {
|
|
||||||
const rawData = event.data;
|
|
||||||
if (typeof rawData === 'string' || rawData instanceof String) {
|
|
||||||
this.handleMessage(
|
|
||||||
JSON.parse(rawData as string) as WebSocketMessage<D>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMessage = (message: WebSocketMessage<D>) => {
|
|
||||||
const { clientId, data } = this.state;
|
|
||||||
|
|
||||||
switch (message.type) {
|
|
||||||
case WebSocketMessageType.ID:
|
|
||||||
this.setState({ clientId: message.id });
|
|
||||||
break;
|
|
||||||
case WebSocketMessageType.PAYLOAD:
|
|
||||||
if (clientId && (!data || clientId !== message.origin_id)) {
|
|
||||||
this.setState({ data: message.payload });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onOpen = () => {
|
|
||||||
this.setState({ connected: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onClose = () => {
|
|
||||||
this.setState({
|
|
||||||
connected: false,
|
|
||||||
clientId: undefined,
|
|
||||||
data: undefined
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
setData = (data: D, callback?: () => void) => {
|
|
||||||
this.setState({ data }, callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
saveData = throttle(() => {
|
|
||||||
const { ws, connected, data } = this.state;
|
|
||||||
if (connected) {
|
|
||||||
ws.json(data);
|
|
||||||
}
|
|
||||||
}, wsThrottle);
|
|
||||||
|
|
||||||
saveDataAndClear = throttle(() => {
|
|
||||||
const { ws, connected, data } = this.state;
|
|
||||||
if (connected) {
|
|
||||||
this.setState(
|
|
||||||
{
|
|
||||||
data: undefined
|
|
||||||
},
|
|
||||||
() => ws.json(data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, wsThrottle);
|
|
||||||
|
|
||||||
handleValueChange = (name: keyof D) => (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>
|
|
||||||
) => {
|
|
||||||
const data = { ...this.state.data!, [name]: extractEventValue(event) };
|
|
||||||
this.setState({ data });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<WebSocketController
|
|
||||||
{...(this.props as P)}
|
|
||||||
handleValueChange={this.handleValueChange}
|
|
||||||
setData={this.setData}
|
|
||||||
saveData={this.saveData}
|
|
||||||
saveDataAndClear={this.saveDataAndClear}
|
|
||||||
connected={this.state.connected}
|
|
||||||
data={this.state.data}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
|
||||||
import { LinearProgress, Typography } from '@material-ui/core';
|
|
||||||
|
|
||||||
import { WebSocketControllerProps } from '.';
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
loadingSettings: {
|
|
||||||
margin: theme.spacing(0.5)
|
|
||||||
},
|
|
||||||
loadingSettingsDetails: {
|
|
||||||
margin: theme.spacing(4),
|
|
||||||
textAlign: 'center'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export type WebSocketFormProps<D> = Omit<
|
|
||||||
WebSocketControllerProps<D>,
|
|
||||||
'connected'
|
|
||||||
> & { data: D };
|
|
||||||
|
|
||||||
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
|
|
||||||
render: (props: WebSocketFormProps<D>) => JSX.Element;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WebSocketFormLoader<D>(
|
|
||||||
props: WebSocketFormLoaderProps<D>
|
|
||||||
) {
|
|
||||||
const { connected, render, data, ...rest } = props;
|
|
||||||
const classes = useStyles();
|
|
||||||
if (!connected || !data) {
|
|
||||||
return (
|
|
||||||
<div className={classes.loadingSettings}>
|
|
||||||
<LinearProgress className={classes.loadingSettingsDetails} />
|
|
||||||
<Typography variant="h6" className={classes.loadingSettingsDetails}>
|
|
||||||
Connecting to WebSocket...
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return render({ ...rest, data });
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useLayoutEffect, useState } from 'react';
|
|
||||||
|
|
||||||
export function useWindowSize() {
|
|
||||||
const [size, setSize] = useState([0, 0]);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
function updateSize() {
|
|
||||||
setSize([window.innerWidth, window.innerHeight]);
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', updateSize);
|
|
||||||
updateSize();
|
|
||||||
return () => window.removeEventListener('resize', updateSize);
|
|
||||||
}, []);
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,8 @@
|
|||||||
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
export * from './inputs';
|
||||||
export { default as FormActions } from './FormActions';
|
export * from './layout';
|
||||||
export { default as FormButton } from './FormButton';
|
export * from './loading';
|
||||||
export { default as HighlightAvatar } from './HighlightAvatar';
|
export * from './routing';
|
||||||
export { default as MenuAppBar } from './MenuAppBar';
|
export * from './upload';
|
||||||
export { default as PasswordValidator } from './PasswordValidator';
|
|
||||||
export { default as RestFormLoader } from './RestFormLoader';
|
|
||||||
export { default as FormLoader } from './FormLoader';
|
|
||||||
export { default as SectionContent } from './SectionContent';
|
export { default as SectionContent } from './SectionContent';
|
||||||
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
|
export { default as ButtonRow } from './ButtonRow';
|
||||||
export { default as ErrorButton } from './ErrorButton';
|
export { default as MessageBox } from './MessageBox';
|
||||||
export { default as SingleUpload } from './SingleUpload';
|
|
||||||
|
|
||||||
export * from './RestFormLoader';
|
|
||||||
export * from './RestController';
|
|
||||||
|
|
||||||
export * from './WebSocketFormLoader';
|
|
||||||
export * from './WebSocketController';
|
|
||||||
|
|
||||||
export * from './WindowSize';
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
|
import { FormControlLabel, FormControlLabelProps } from '@mui/material';
|
||||||
|
|
||||||
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
||||||
<div>
|
<div>
|
||||||
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { FC, useState } from 'react';
|
||||||
|
|
||||||
|
import { IconButton, InputAdornment } from '@mui/material';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
|
|
||||||
|
import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
|
||||||
|
|
||||||
|
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
|
||||||
|
|
||||||
|
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, ...props }) => {
|
||||||
|
const [showPassword, setShowPassword] = useState<boolean>(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ValidatedTextField
|
||||||
|
{...props}
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
InputProps={{
|
||||||
|
...InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
aria-label="toggle password visibility"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
edge="end"
|
||||||
|
>
|
||||||
|
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValidatedPasswordField;
|
||||||
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
24
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { ValidateFieldsError } from 'async-validator';
|
||||||
|
|
||||||
|
import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
|
||||||
|
|
||||||
|
interface ValidatedFieldProps {
|
||||||
|
fieldErrors?: ValidateFieldsError;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
|
||||||
|
|
||||||
|
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({ fieldErrors, ...rest }) => {
|
||||||
|
const errors = fieldErrors && fieldErrors[rest.name];
|
||||||
|
const renderErrors = () => errors && errors.map((e, i) => <FormHelperText key={i}>{e.message}</FormHelperText>);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField error={!!errors} {...rest} />
|
||||||
|
{renderErrors()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValidatedTextField;
|
||||||
3
interface/src/components/inputs/index.ts
Normal file
3
interface/src/components/inputs/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
||||||
|
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
|
||||||
|
export { default as ValidatedTextField } from './ValidatedTextField';
|
||||||
38
interface/src/components/layout/Layout.tsx
Normal file
38
interface/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { FC, useState, useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Box, Toolbar } from '@mui/material';
|
||||||
|
|
||||||
|
import { PROJECT_NAME } from '../../api/env';
|
||||||
|
import { RequiredChildrenProps } from '../../utils';
|
||||||
|
|
||||||
|
import LayoutDrawer from './LayoutDrawer';
|
||||||
|
import LayoutAppBar from './LayoutAppBar';
|
||||||
|
import { LayoutContext } from './context';
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState(PROJECT_NAME);
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => setMobileOpen(false), [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutContext.Provider value={{ title, setTitle }}>
|
||||||
|
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
|
||||||
|
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
|
||||||
|
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
|
||||||
|
<Toolbar />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</LayoutContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
50
interface/src/components/layout/LayoutAppBar.tsx
Normal file
50
interface/src/components/layout/LayoutAppBar.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { FC, useContext } from 'react';
|
||||||
|
|
||||||
|
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
|
||||||
|
import LayoutAuthMenu from './LayoutAuthMenu';
|
||||||
|
|
||||||
|
import { FeaturesContext } from '../../contexts/features';
|
||||||
|
|
||||||
|
export const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
interface LayoutAppBarProps {
|
||||||
|
title: string;
|
||||||
|
onToggleDrawer: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||||
|
ml: { md: `${DRAWER_WIDTH}px` },
|
||||||
|
boxShadow: 'none',
|
||||||
|
backgroundColor: '#2e586a'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={onToggleDrawer}
|
||||||
|
sx={{ mr: 2, display: { md: 'none' } }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography variant="h6" noWrap component="div">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<Box flexGrow={1} />
|
||||||
|
{features.security && <LayoutAuthMenu />}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutAppBar;
|
||||||
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
73
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { FC, useState, useContext } from 'react';
|
||||||
|
|
||||||
|
import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
|
||||||
|
|
||||||
|
import PersonIcon from '@mui/icons-material/Person';
|
||||||
|
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||||
|
|
||||||
|
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||||
|
|
||||||
|
const ItemTypography = styled(Typography)<TypographyProps>({
|
||||||
|
maxWidth: '250px',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
});
|
||||||
|
|
||||||
|
const LayoutAuthMenu: FC = () => {
|
||||||
|
const { me, signOut } = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const id = anchorEl ? 'app-menu-popover' : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton id="open-auth-menu" sx={{ padding: 0 }} aria-describedby={id} color="inherit" onClick={handleClick}>
|
||||||
|
<AccountCircleIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Popover
|
||||||
|
id="app-menu-popover"
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
open={open}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: 'bottom',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: 'top',
|
||||||
|
horizontal: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="row" alignItems="center" p={2}>
|
||||||
|
<Avatar sx={{ width: 80, height: 80 }}>
|
||||||
|
<PersonIcon fontSize="large" />
|
||||||
|
</Avatar>
|
||||||
|
<Box pl={2}>
|
||||||
|
<ItemTypography variant="h6">{me.username}</ItemTypography>
|
||||||
|
<ItemTypography variant="body1">{me.admin ? 'Admin User' : 'Guest User'}</ItemTypography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Box p={1.5}>
|
||||||
|
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutAuthMenu;
|
||||||
73
interface/src/components/layout/LayoutDrawer.tsx
Normal file
73
interface/src/components/layout/LayoutDrawer.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
|
||||||
|
|
||||||
|
import { PROJECT_NAME } from '../../api/env';
|
||||||
|
|
||||||
|
import LayoutMenu from './LayoutMenu';
|
||||||
|
import { DRAWER_WIDTH } from './Layout';
|
||||||
|
|
||||||
|
const LayoutDrawerLogo = styled('img')(({ theme }) => ({
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
height: 24,
|
||||||
|
marginRight: theme.spacing(2)
|
||||||
|
},
|
||||||
|
[theme.breakpoints.up('sm')]: {
|
||||||
|
height: 36,
|
||||||
|
marginRight: theme.spacing(2)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface LayoutDrawerProps {
|
||||||
|
mobileOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
|
||||||
|
const drawer = (
|
||||||
|
<>
|
||||||
|
<Toolbar disableGutters>
|
||||||
|
<Box display="flex" alignItems="center" px={2}>
|
||||||
|
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
|
||||||
|
<Typography variant="h6" color="textPrimary">
|
||||||
|
{PROJECT_NAME}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider absolute />
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<LayoutMenu />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="nav" sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}>
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
ModalProps={{
|
||||||
|
keepMounted: true
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'block' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutDrawer;
|
||||||
42
interface/src/components/layout/LayoutMenu.tsx
Normal file
42
interface/src/components/layout/LayoutMenu.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { FC, useContext } from 'react';
|
||||||
|
|
||||||
|
import { Divider, List } from '@mui/material';
|
||||||
|
|
||||||
|
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
|
||||||
|
import AccessTimeIcon from '@mui/icons-material/AccessTime';
|
||||||
|
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
|
||||||
|
|
||||||
|
import { FeaturesContext } from '../../contexts/features';
|
||||||
|
import ProjectMenu from '../../project/ProjectMenu';
|
||||||
|
|
||||||
|
import LayoutMenuItem from './LayoutMenuItem';
|
||||||
|
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||||
|
|
||||||
|
const LayoutMenu: FC = () => {
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
const authenticatedContext = useContext(AuthenticatedContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{features.project && (
|
||||||
|
<List disablePadding component="nav">
|
||||||
|
<ProjectMenu />
|
||||||
|
<Divider />
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
<List disablePadding component="nav">
|
||||||
|
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
|
||||||
|
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
|
||||||
|
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
|
||||||
|
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
|
||||||
|
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
|
||||||
|
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
|
||||||
|
</List>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutMenu;
|
||||||
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
32
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
|
||||||
|
|
||||||
|
import { grey } from '@mui/material/colors';
|
||||||
|
|
||||||
|
import { routeMatches } from '../../utils';
|
||||||
|
|
||||||
|
interface LayoutMenuItemProps {
|
||||||
|
icon: React.ComponentType<SvgIconProps>;
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem disablePadding selected={routeMatches(to, pathname)}>
|
||||||
|
<ListItemButton component={Link} to={to} disabled={disabled}>
|
||||||
|
<ListItemIcon sx={{ color: grey[500] }}>
|
||||||
|
<Icon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>{label}</ListItemText>
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LayoutMenuItem;
|
||||||
25
interface/src/components/layout/context.ts
Normal file
25
interface/src/components/layout/context.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useRef, useEffect, createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export interface LayoutContextValue {
|
||||||
|
title: string;
|
||||||
|
setTitle: (title: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutContextDefaultValue = {} as LayoutContextValue;
|
||||||
|
export const LayoutContext = createContext(LayoutContextDefaultValue);
|
||||||
|
|
||||||
|
export const useLayoutTitle = (myTitle: string) => {
|
||||||
|
const { title, setTitle } = useContext(LayoutContext);
|
||||||
|
const previousTitle = useRef(title);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(myTitle);
|
||||||
|
}, [setTitle, myTitle]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
setTitle(previousTitle.current);
|
||||||
|
},
|
||||||
|
[setTitle]
|
||||||
|
);
|
||||||
|
};
|
||||||
2
interface/src/components/layout/index.ts
Normal file
2
interface/src/components/layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './context';
|
||||||
|
export { default as Layout } from './Layout';
|
||||||
43
interface/src/components/loading/ApplicationError.tsx
Normal file
43
interface/src/components/loading/ApplicationError.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { Box, Paper, Typography } from '@mui/material';
|
||||||
|
import WarningIcon from '@mui/icons-material/Warning';
|
||||||
|
|
||||||
|
interface ApplicationErrorProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApplicationError: FC<ApplicationErrorProps> = ({ message }) => (
|
||||||
|
<Box display="flex" height="100vh" justifyContent="center" flexDirection="column">
|
||||||
|
<Paper
|
||||||
|
elevation={10}
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '280px 0 40px 0',
|
||||||
|
backgroundImage: 'url("/app/icon.png")',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: '50% 40px',
|
||||||
|
backgroundSize: '200px auto',
|
||||||
|
width: '100%',
|
||||||
|
borderRadius: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center" mb={2}>
|
||||||
|
<WarningIcon fontSize="large" color="error" />
|
||||||
|
<Box ml={2}>
|
||||||
|
<Typography variant="h4">Application Error</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="subtitle1" gutterBottom>
|
||||||
|
Failed to configure the application, please refresh to try again.
|
||||||
|
</Typography>
|
||||||
|
{message && (
|
||||||
|
<Typography variant="subtitle2" gutterBottom>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ApplicationError;
|
||||||
38
interface/src/components/loading/FormLoader.tsx
Normal file
38
interface/src/components/loading/FormLoader.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { Box, Button, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
|
||||||
|
import { MessageBox } from '..';
|
||||||
|
|
||||||
|
interface FormLoaderProps {
|
||||||
|
message?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => {
|
||||||
|
if (errorMessage) {
|
||||||
|
return (
|
||||||
|
<MessageBox my={2} level="error" message={errorMessage}>
|
||||||
|
{onRetry && (
|
||||||
|
<Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</MessageBox>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box m={2} py={2} display="flex" alignItems="center" flexDirection="column">
|
||||||
|
<Box py={2}>
|
||||||
|
<CircularProgress size={100} />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h6" fontWeight={400} textAlign="center">
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormLoader;
|
||||||
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { FC } from 'react';
|
||||||
|
|
||||||
|
import { CircularProgress, Box, Typography, Theme } from '@mui/material';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
height?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => (
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}>
|
||||||
|
<CircularProgress
|
||||||
|
sx={(theme: Theme) => ({
|
||||||
|
margin: theme.spacing(4),
|
||||||
|
color: theme.palette.text.secondary
|
||||||
|
})}
|
||||||
|
size={100}
|
||||||
|
/>
|
||||||
|
<Typography variant="h4" color="textSecondary">
|
||||||
|
Loading…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
3
interface/src/components/loading/index.ts
Normal file
3
interface/src/components/loading/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as ApplicationError } from './ApplicationError';
|
||||||
|
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||||
|
export { default as FormLoader } from './FormLoader';
|
||||||
12
interface/src/components/routing/RequireAdmin.tsx
Normal file
12
interface/src/components/routing/RequireAdmin.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { FC, useContext } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AuthenticatedContext } from '../../contexts/authentication';
|
||||||
|
import { RequiredChildrenProps } from '../../utils';
|
||||||
|
|
||||||
|
const RequireAdmin: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
|
const authenticatedContext = useContext(AuthenticatedContext);
|
||||||
|
return authenticatedContext.me.admin ? <>{children}</> : <Navigate replace to="/" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequireAdmin;
|
||||||
32
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
32
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FC, useContext, useEffect } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthenticatedContext,
|
||||||
|
AuthenticatedContextValue,
|
||||||
|
AuthenticationContext
|
||||||
|
} from '../../contexts/authentication/context';
|
||||||
|
import { storeLoginRedirect } from '../../api/authentication';
|
||||||
|
|
||||||
|
import { RequiredChildrenProps } from '../../utils';
|
||||||
|
|
||||||
|
const RequireAuthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authenticationContext.me) {
|
||||||
|
storeLoginRedirect(location);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return authenticationContext.me ? (
|
||||||
|
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthenticatedContext.Provider>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/unauthorized" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequireAuthenticated;
|
||||||
16
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
16
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { FC, useContext } from 'react';
|
||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import * as AuthenticationApi from '../../api/authentication';
|
||||||
|
import { AuthenticationContext } from '../../contexts/authentication';
|
||||||
|
import { RequiredChildrenProps } from '../../utils';
|
||||||
|
import { FeaturesContext } from '../../contexts/features';
|
||||||
|
|
||||||
|
const RequireUnauthenticated: FC<RequiredChildrenProps> = ({ children }) => {
|
||||||
|
const { features } = useContext(FeaturesContext);
|
||||||
|
const authenticationContext = useContext(AuthenticationContext);
|
||||||
|
|
||||||
|
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RequireUnauthenticated;
|
||||||
29
interface/src/components/routing/RouterTabs.tsx
Normal file
29
interface/src/components/routing/RouterTabs.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Tabs, useMediaQuery, useTheme } from '@mui/material';
|
||||||
|
|
||||||
|
import { RequiredChildrenProps } from '../../utils';
|
||||||
|
|
||||||
|
interface RouterTabsProps extends RequiredChildrenProps {
|
||||||
|
value: string | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
|
||||||
|
|
||||||
|
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onChange={handleTabChange} variant={smallDown ? 'scrollable' : 'fullWidth'}>
|
||||||
|
{children}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RouterTabs;
|
||||||
6
interface/src/components/routing/index.ts
Normal file
6
interface/src/components/routing/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as RouterTabs } from './RouterTabs';
|
||||||
|
export { default as RequireAdmin } from './RequireAdmin';
|
||||||
|
export { default as RequireAuthenticated } from './RequireAuthenticated';
|
||||||
|
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
|
||||||
|
|
||||||
|
export * from './useRouterTab';
|
||||||
9
interface/src/components/routing/useRouterTab.ts
Normal file
9
interface/src/components/routing/useRouterTab.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { useMatch, useResolvedPath } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const useRouterTab = () => {
|
||||||
|
const routerTabPath = useResolvedPath(':tab');
|
||||||
|
const routerTabPathMatch = useMatch(routerTabPath.pathname);
|
||||||
|
|
||||||
|
const routerTab = routerTabPathMatch?.params?.tab || false;
|
||||||
|
return { routerTab } as const;
|
||||||
|
};
|
||||||
93
interface/src/components/upload/SingleUpload.tsx
Normal file
93
interface/src/components/upload/SingleUpload.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { FC, Fragment } from 'react';
|
||||||
|
import { useDropzone, DropzoneState } from 'react-dropzone';
|
||||||
|
|
||||||
|
import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
|
||||||
|
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
|
||||||
|
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
|
||||||
|
|
||||||
|
const getBorderColor = (theme: Theme, props: DropzoneState) => {
|
||||||
|
if (props.isDragAccept) {
|
||||||
|
return theme.palette.success.main;
|
||||||
|
}
|
||||||
|
if (props.isDragReject) {
|
||||||
|
return theme.palette.error.main;
|
||||||
|
}
|
||||||
|
if (props.isDragActive) {
|
||||||
|
return theme.palette.info.main;
|
||||||
|
}
|
||||||
|
return theme.palette.grey[700];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SingleUploadProps {
|
||||||
|
onDrop: (acceptedFiles: File[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
uploading: boolean;
|
||||||
|
progress?: ProgressEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, uploading, progress }) => {
|
||||||
|
const dropzoneState = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
accept: {
|
||||||
|
'application/octet-stream': ['.bin'],
|
||||||
|
'application/json': ['.json']
|
||||||
|
},
|
||||||
|
disabled: uploading,
|
||||||
|
multiple: false
|
||||||
|
});
|
||||||
|
const { getRootProps, getInputProps } = dropzoneState;
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const progressText = () => {
|
||||||
|
if (uploading) {
|
||||||
|
if (progress?.lengthComputable) {
|
||||||
|
return `Uploading: ${progressPercentage(progress)}%`;
|
||||||
|
}
|
||||||
|
return 'Uploading\u2026';
|
||||||
|
}
|
||||||
|
return 'Drop file or click here';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
{...getRootProps({
|
||||||
|
sx: {
|
||||||
|
py: 8,
|
||||||
|
px: 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
color: theme.palette.grey[700],
|
||||||
|
transition: 'border .24s ease-in-out',
|
||||||
|
width: '100%',
|
||||||
|
cursor: uploading ? 'default' : 'pointer',
|
||||||
|
borderColor: getBorderColor(theme, dropzoneState)
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Box flexDirection="column" display="flex" alignItems="center">
|
||||||
|
<CloudUploadIcon fontSize="large" />
|
||||||
|
<Typography variant="h6">{progressText()}</Typography>
|
||||||
|
{uploading && (
|
||||||
|
<Fragment>
|
||||||
|
<Box width="100%" p={2}>
|
||||||
|
<LinearProgress
|
||||||
|
variant={!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'}
|
||||||
|
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SingleUpload;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user