mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-11 09:03:33 +00:00
Compare commits
680
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7617430a3f | ||
|
|
a346c48d3e | ||
|
|
8a2c39ba8e | ||
|
|
65b86ac5f6 | ||
|
|
4ead740982 | ||
|
|
ddaf06b682 | ||
|
|
9e18c4db4e | ||
|
|
ee783f15c0 | ||
|
|
0c31843ed5 | ||
|
|
ffc93d20d8 | ||
|
|
3c62da7e19 | ||
|
|
6846dbbb28 | ||
|
|
270e3e3ead | ||
|
|
0d73b0f519 | ||
|
|
ee83022d19 | ||
|
|
f27467ed97 | ||
|
|
ddbf3d9f1f | ||
|
|
60bbbc54d7 | ||
|
|
538aa7dc29 | ||
|
|
b37af291cc | ||
|
|
333a1bcabe | ||
|
|
c2404a7eb6 | ||
|
|
bb602cd28e | ||
|
|
898fd3896c | ||
|
|
7fb0483889 | ||
|
|
7d77110c1f | ||
|
|
a7ce6b4419 | ||
|
|
204f6dbcde | ||
|
|
7fa997087f | ||
|
|
a96b08eaaa | ||
|
|
c9c997d13a | ||
|
|
6f3e0f62e1 | ||
|
|
824b39637c | ||
|
|
0100b2988a | ||
|
|
0e72329c77 | ||
|
|
9b7c3b8e9c | ||
|
|
b9a5a5f1d8 | ||
|
|
cc9cc411f0 | ||
|
|
5f75079f2f | ||
|
|
a6e16dcd17 | ||
|
|
f1b13a2919 | ||
|
|
737afb3c1b | ||
|
|
e21011fce1 | ||
|
|
613d0d7178 | ||
|
|
bb964aba20 | ||
|
|
6e5e2e0dd7 | ||
|
|
5afccac2ac | ||
|
|
4626df0bf7 | ||
|
|
252780a51a | ||
|
|
245e661159 | ||
|
|
659033e4eb | ||
|
|
82fbf91f8a | ||
|
|
4275060003 | ||
|
|
6fe74d49fd | ||
|
|
736145d9b4 | ||
|
|
d5ad716086 | ||
|
|
c3f1e7c5e8 | ||
|
|
e1b6c709b0 | ||
|
|
42de570a98 | ||
|
|
30469e63e5 | ||
|
|
899db30e3b | ||
|
|
51d0eaafcc | ||
|
|
cfd1333989 | ||
|
|
6c9d98a763 | ||
|
|
040549c40a | ||
|
|
0c6d777999 | ||
|
|
65c53fd670 | ||
|
|
1b7c9c52f6 | ||
|
|
a63b12b266 | ||
|
|
a8ff4adde7 | ||
|
|
3b8418ccfd | ||
|
|
b51b3065b0 | ||
|
|
1f0c6771e5 | ||
|
|
67277b4652 | ||
|
|
b0e30a896f | ||
|
|
93010dc06e | ||
|
|
302b8f3c21 | ||
|
|
62d087bb0e | ||
|
|
f25cab4682 | ||
|
|
d1de0770f2 | ||
|
|
2a3f481a24 | ||
|
|
ed492ccbf4 | ||
|
|
ea7535b250 | ||
|
|
b17b54490e | ||
|
|
72400af679 | ||
|
|
f8f1ddfebb | ||
|
|
6e4d08e4b5 | ||
|
|
5da8fe0f90 | ||
|
|
df9eda4866 | ||
|
|
6b030ab5ee | ||
|
|
e1493db7c8 | ||
|
|
205ea00c41 | ||
|
|
d428428a6e | ||
|
|
24e9c4cd22 | ||
|
|
d253c6c764 | ||
|
|
13b8f8d5da | ||
|
|
e769ddece4 | ||
|
|
64e807beef | ||
|
|
531af31490 | ||
|
|
e708e1d0c7 | ||
|
|
8fcf8b2478 | ||
|
|
4fdd5b2063 | ||
|
|
e4add31793 | ||
|
|
0cab2169c2 | ||
|
|
86f5b94852 | ||
|
|
f60449c253 | ||
|
|
c1c8b9aa61 | ||
|
|
dcf692d193 | ||
|
|
7f757b9f8a | ||
|
|
23b46cd432 | ||
|
|
68ef9ac913 | ||
|
|
3fa47f0318 | ||
|
|
9cdfa2de7b | ||
|
|
355e9177fc | ||
|
|
dbb6ead7ba | ||
|
|
ad888f6f9a | ||
|
|
07458cd712 | ||
|
|
6fc0d3f28d | ||
|
|
7a91e4dee6 | ||
|
|
3c40be97b7 | ||
|
|
ff00f7a99e | ||
|
|
b72fc48bdb | ||
|
|
e87a7115c5 | ||
|
|
14dac22732 | ||
|
|
ccdaaa6ce2 | ||
|
|
487eee93cd | ||
|
|
59737edfb4 | ||
|
|
30af13f793 | ||
|
|
19e8bfdb37 | ||
|
|
a89d7cf62e | ||
|
|
65e9f2e074 | ||
|
|
ff66d10aaa | ||
|
|
5203d40a24 | ||
|
|
237c718a0a | ||
|
|
fbfbe94d19 | ||
|
|
e24e505cc0 | ||
|
|
28743ba646 | ||
|
|
f63e73ee0a | ||
|
|
fbad7afa79 | ||
|
|
34cc38ab3c | ||
|
|
8645789550 | ||
|
|
4db6e7b1d7 | ||
|
|
1fae61b1fa | ||
|
|
b26eac3edb | ||
|
|
6d1354bf07 | ||
|
|
7148e302f6 | ||
|
|
763a1aea69 | ||
|
|
94a92e9b29 | ||
|
|
922f90c4eb | ||
|
|
ff11973a9a | ||
|
|
29fc2eea4d | ||
|
|
577e1e6b10 | ||
|
|
d113fab279 | ||
|
|
a6c401ab6a | ||
|
|
6cc8c8810b | ||
|
|
040a29da30 | ||
|
|
45f31ad931 | ||
|
|
39ddb984ab | ||
|
|
197788eb79 | ||
|
|
c85bc0323c | ||
|
|
65c2a6eff1 | ||
|
|
5a4f3a0388 | ||
|
|
03f9c6a26e | ||
|
|
ae7ecd4bc9 | ||
|
|
25469936fa | ||
|
|
bc2d066bb9 | ||
|
|
71e376e0e4 | ||
|
|
354b014245 | ||
|
|
d69779c497 | ||
|
|
af1219cda8 | ||
|
|
c7ea6790c8 | ||
|
|
779f1e9769 | ||
|
|
f690b3f5a7 | ||
|
|
39d87d5a8e | ||
|
|
6781170b85 | ||
|
|
7829a27c69 | ||
|
|
285001d184 | ||
|
|
d5a4d1e4d3 | ||
|
|
c59c64b65f | ||
|
|
5badd19015 | ||
|
|
a7eacfac46 | ||
|
|
20dae2ea2b | ||
|
|
dc50fd40a3 | ||
|
|
3df4d18a71 | ||
|
|
3aeee31193 | ||
|
|
af4cfec451 | ||
|
|
ae915a158b | ||
|
|
eac522fe63 | ||
|
|
a2bdf71422 | ||
|
|
f2d632959b | ||
|
|
b33412504d | ||
|
|
c458f6c64d | ||
|
|
d6489563b2 | ||
|
|
d651fc7a6c | ||
|
|
c86659f61c | ||
|
|
8959bbdee7 | ||
|
|
9a8474e3d8 | ||
|
|
c5e0e652f3 | ||
|
|
f047f97e44 | ||
|
|
cda8e928ef | ||
|
|
ccbea4acc5 | ||
|
|
8ae1528064 | ||
|
|
1c52aced63 | ||
|
|
72020b02b8 | ||
|
|
aa340b2158 | ||
|
|
faf909654b | ||
|
|
33f3458e38 | ||
|
|
3737410563 | ||
|
|
28afc439ba | ||
|
|
04ba067775 | ||
|
|
0a5d010827 | ||
|
|
1cef529ac7 | ||
|
|
094831f037 | ||
|
|
5148e92651 | ||
|
|
13be7a6697 | ||
|
|
1bef73f356 | ||
|
|
88cc57b8b1 | ||
|
|
b5d9ff3067 | ||
|
|
7c63e871f0 | ||
|
|
fd2c78eea0 | ||
|
|
e8e8926757 | ||
|
|
0b6a1e42d6 | ||
|
|
ff148a8e3c | ||
|
|
ecdecd8626 | ||
|
|
843f2377f0 | ||
|
|
a9a5fdf9dd | ||
|
|
a9742ebf81 | ||
|
|
df3e0fc047 | ||
|
|
05867dab48 | ||
|
|
4f3150d0f3 | ||
|
|
175aeea468 | ||
|
|
167f0b39e0 | ||
|
|
7a9b7d7bf6 | ||
|
|
1a1596ad3d | ||
|
|
8e49d19fbe | ||
|
|
df42d75651 | ||
|
|
c1e2a4c625 | ||
|
|
901acf2bed | ||
|
|
4ec7c8ca75 | ||
|
|
75c3619898 | ||
|
|
38f8d2e6ee | ||
|
|
e3584add7c | ||
|
|
77dcbccd5c | ||
|
|
0ff6359a41 | ||
|
|
66bfcab256 | ||
|
|
195dcf37d4 | ||
|
|
5830d99d45 | ||
|
|
d50cef7bb3 | ||
|
|
b79725bdf0 | ||
|
|
6bead5b5a6 | ||
|
|
183ea9d8c0 | ||
|
|
282736d2f6 | ||
|
|
45b30e5997 | ||
|
|
3d45e8ce6e | ||
|
|
393cf76839 | ||
|
|
410bc6a39b | ||
|
|
a56c00aeb3 | ||
|
|
c3d7d653fb | ||
|
|
9d72ef0122 | ||
|
|
b5aa22a6d8 | ||
|
|
aa05c16651 | ||
|
|
c543d0fb85 | ||
|
|
de7fa2dd9c | ||
|
|
f9829c2d5c | ||
|
|
e02091f645 | ||
|
|
8d15018986 | ||
|
|
76c63d38cd | ||
|
|
188b5c6c16 | ||
|
|
fc5a5d2035 | ||
|
|
838d106b62 | ||
|
|
d59a1af5d4 | ||
|
|
1638080e85 | ||
|
|
f4051b4cdf | ||
|
|
d054e64568 | ||
|
|
759176c4fc | ||
|
|
c2a9b7b1c6 | ||
|
|
2536b9fff7 | ||
|
|
b714547928 | ||
|
|
005fba5ca3 | ||
|
|
b27df0c0d8 | ||
|
|
341ab44f9e | ||
|
|
e0d35548c5 | ||
|
|
ba91730f43 | ||
|
|
f691a7a0ae | ||
|
|
5a43ecd8d2 | ||
|
|
73f33c31bb | ||
|
|
ffdce6f825 | ||
|
|
38b16daf51 | ||
|
|
bed5c74150 | ||
|
|
261454986e | ||
|
|
03784601ea | ||
|
|
b060861657 | ||
|
|
34a117b74c | ||
|
|
9e341afdcc | ||
|
|
0d585eef8f | ||
|
|
7e99c18410 | ||
|
|
68118248c9 | ||
|
|
f1a2ae16c1 | ||
|
|
7904cb22cf | ||
|
|
0e0fbae8bb | ||
|
|
3b72048b67 | ||
|
|
caa54809b4 | ||
|
|
23f5846122 | ||
|
|
de7b461b4e | ||
|
|
d92ea0bad7 | ||
|
|
d7bb4bb55f | ||
|
|
9ed8a3b91e | ||
|
|
dd78b43058 | ||
|
|
cd68e966ab | ||
|
|
6baeafce39 | ||
|
|
1d8900d6eb | ||
|
|
e82c8c4eb0 | ||
|
|
449d046ce3 | ||
|
|
06ab52bb6b | ||
|
|
181575f71f | ||
|
|
3d5a4af705 | ||
|
|
4e982be613 | ||
|
|
8c1c6387c9 | ||
|
|
0f9c2cd36f | ||
|
|
2296143883 | ||
|
|
fcb61b563c | ||
|
|
90e5336c5c | ||
|
|
37d56e24ac | ||
|
|
57f979e1bb | ||
|
|
00cbb63181 | ||
|
|
eaafb68117 | ||
|
|
486a3568e9 | ||
|
|
acff7ea631 | ||
|
|
6829046d8c | ||
|
|
182285d8a5 | ||
|
|
1af799789d | ||
|
|
7036d6c8fe | ||
|
|
0b067bd964 | ||
|
|
9fe5133ef1 | ||
|
|
c9aae4287c | ||
|
|
97846e01a7 | ||
|
|
b4b6c214cb | ||
|
|
1d77af5e22 | ||
|
|
1884f3baae | ||
|
|
0ebb6f4424 | ||
|
|
209899c3b8 | ||
|
|
4ec8cdf0ff | ||
|
|
53326b6de4 | ||
|
|
8c13282a55 | ||
|
|
0e3d94bf06 | ||
|
|
af8613096d | ||
|
|
92e045297e | ||
|
|
6176859c7c | ||
|
|
3935b1d614 | ||
|
|
c9d3e552ae | ||
|
|
d62305303a | ||
|
|
34c81f7f69 | ||
|
|
bd1f6b6d82 | ||
|
|
e728a721aa | ||
|
|
15a0c8a5c5 | ||
|
|
173f662fa7 | ||
|
|
15c4dc2df3 | ||
|
|
b9dcc4a9a8 | ||
|
|
f8648b92a1 | ||
|
|
4f0b13bdc7 | ||
|
|
1f9215a19f | ||
|
|
9e15e28147 | ||
|
|
09151d14a9 | ||
|
|
e66f6d098a | ||
|
|
8cc3d416b8 | ||
|
|
348b72ea69 | ||
|
|
050ba81760 | ||
|
|
fe16eabb6b | ||
|
|
3c58164539 | ||
|
|
5a9d6aa4dd | ||
|
|
5c413a9ebb | ||
|
|
d0ddec746a | ||
|
|
6e4754f475 | ||
|
|
63f89d628e | ||
|
|
dfaae34cf5 | ||
|
|
77654c7136 | ||
|
|
3bfc59d89a | ||
|
|
6bf84055d9 | ||
|
|
12d7d3ab64 | ||
|
|
b2913c7e0e | ||
|
|
352948f212 | ||
|
|
9a4dbe92a0 | ||
|
|
397b95f9e5 | ||
|
|
a411d21133 | ||
|
|
4f624cc130 | ||
|
|
f10bfafa1d | ||
|
|
10e37624a5 | ||
|
|
ebdfac39eb | ||
|
|
7cc032cdf4 | ||
|
|
4bc9039386 | ||
|
|
43b997fb18 | ||
|
|
6c883aa226 | ||
|
|
20cde3e2a4 | ||
|
|
ae7440f8ab | ||
|
|
03e228e5a0 | ||
|
|
14f2f9ff1e | ||
|
|
86544c1334 | ||
|
|
9e71c3132e | ||
|
|
817202fc20 | ||
|
|
931ec0113c | ||
|
|
365ebc78ab | ||
|
|
2a541a80dc | ||
|
|
a1d3f32f73 | ||
|
|
e9e6ea618d | ||
|
|
6021df7150 | ||
|
|
070b72b304 | ||
|
|
1f65a0f316 | ||
|
|
0f21f10c0d | ||
|
|
af2ff0149d | ||
|
|
8cd11cde3b | ||
|
|
fdbe6344d9 | ||
|
|
a56b41635c | ||
|
|
2c5c218fd1 | ||
|
|
749df70d5c | ||
|
|
4c4e1543ec | ||
|
|
9320fa252e | ||
|
|
9062129114 | ||
|
|
fa3805460c | ||
|
|
c2885c4829 | ||
|
|
a901f2b90b | ||
|
|
349b664270 | ||
|
|
a28fc958d4 | ||
|
|
0ec539854c | ||
|
|
9c5d89e0aa | ||
|
|
c9c0a447ce | ||
|
|
38819b0e0a | ||
|
|
d24dc41024 | ||
|
|
62774fe9c2 | ||
|
|
4b15650290 | ||
|
|
64e974f6c7 | ||
|
|
fb137a703c | ||
|
|
c43d1fadb4 | ||
|
|
91a9ba7757 | ||
|
|
48e65947f7 | ||
|
|
7f29dadc42 | ||
|
|
47d1e74852 | ||
|
|
6d6b35d5f8 | ||
|
|
0d56ab49ea | ||
|
|
daac8cf949 | ||
|
|
fa54b9a9fa | ||
|
|
d339d6a1bb | ||
|
|
b6f16c347d | ||
|
|
46fa93edbd | ||
|
|
2de7840897 | ||
|
|
499dfd1b68 | ||
|
|
425bbb36e3 | ||
|
|
b8f510bd7e | ||
|
|
aae1381de5 | ||
|
|
266d337dae | ||
|
|
e6bcc00927 | ||
|
|
d1f4bc678c | ||
|
|
52c44327d0 | ||
|
|
50b2362a70 | ||
|
|
fb8a16e1d7 | ||
|
|
a929729e6a | ||
|
|
b12961cbc9 | ||
|
|
f0465a8ad7 | ||
|
|
869ad9eb37 | ||
|
|
dcff2ae336 | ||
|
|
b6d6575543 | ||
|
|
a2fae27919 | ||
|
|
14f0f32560 | ||
|
|
195c2db70d | ||
|
|
8773d73ead | ||
|
|
eab20bc614 | ||
|
|
ab8365b1f7 | ||
|
|
c7b0400e84 | ||
|
|
10bac1bcd6 | ||
|
|
8a5bd3526d | ||
|
|
2590a701bb | ||
|
|
36b9cc0bea | ||
|
|
5260343700 | ||
|
|
5be21475ce | ||
|
|
1e8a451a68 | ||
|
|
b7f914d936 | ||
|
|
cefd9e95db | ||
|
|
4dfeddbdae | ||
|
|
11382e3af6 | ||
|
|
1400540852 | ||
|
|
d005195cfe | ||
|
|
97a59ea691 | ||
|
|
2812baccf9 | ||
|
|
f5de7903e7 | ||
|
|
9a65e79fc4 | ||
|
|
4598977b61 | ||
|
|
c461121d6d | ||
|
|
fcc560ef3f | ||
|
|
f76ea848cd | ||
|
|
431d0c89fe | ||
|
|
083a39d6c6 | ||
|
|
7e5e6013ab | ||
|
|
acbe20070b | ||
|
|
e89ca2bfa4 | ||
|
|
96da5badd4 | ||
|
|
e9734bdfc5 | ||
|
|
f1d9a82057 | ||
|
|
550d7014fa | ||
|
|
db134dbc1c | ||
|
|
7e38d23475 | ||
|
|
c23099e254 | ||
|
|
e4cfa38139 | ||
|
|
e9ab3da6c2 | ||
|
|
a150da0254 | ||
|
|
8c5f708386 | ||
|
|
436b569e93 | ||
|
|
3e47b07ba7 | ||
|
|
da5e58a417 | ||
|
|
c993bd88e0 | ||
|
|
5c038461a6 | ||
|
|
e2c60cca64 | ||
|
|
16c72fa9be | ||
|
|
8571d97182 | ||
|
|
c7e20c26cb | ||
|
|
ab3e35520b | ||
|
|
7a78cffa13 | ||
|
|
09dcce97b7 | ||
|
|
c20c075bcf | ||
|
|
d175195dd5 | ||
|
|
094d795b39 | ||
|
|
8266a87417 | ||
|
|
f23f6c908f | ||
|
|
9e5d944df3 | ||
|
|
b5068ad3d5 | ||
|
|
6853066a19 | ||
|
|
acbaced1ec | ||
|
|
b9eb54dd20 | ||
|
|
6f86a8d471 | ||
|
|
84a92ad522 | ||
|
|
5caa3564d1 | ||
|
|
d3598d8417 | ||
|
|
27f5e8a7eb | ||
|
|
b97f97de99 | ||
|
|
0c124d8d19 | ||
|
|
f7e3fbc8f9 | ||
|
|
100bfdd560 | ||
|
|
01f42a1c32 | ||
|
|
2cb8c0dabc | ||
|
|
cceeb74d96 | ||
|
|
c1bc96da01 | ||
|
|
89d363fbaa | ||
|
|
62cbf6547e | ||
|
|
ea7994dad9 | ||
|
|
c09ea4ae62 | ||
|
|
8c23f1f27c | ||
|
|
aa8d066fe1 | ||
|
|
485e1cffae | ||
|
|
eb7d7e8955 | ||
|
|
6392959e22 | ||
|
|
581dba7c01 | ||
|
|
ea57c55116 | ||
|
|
d9fcf59ca4 | ||
|
|
d66ecc6f0f | ||
|
|
eb6aa998a9 | ||
|
|
ef4dfe3298 | ||
|
|
354a0cbf84 | ||
|
|
94558b9a6f | ||
|
|
517bb06fb2 | ||
|
|
cd7e5ed412 | ||
|
|
58a6b21bf3 | ||
|
|
1763dc7574 | ||
|
|
5adb2bb5c8 | ||
|
|
4665130820 | ||
|
|
c3efd45a7f | ||
|
|
ad0298f8a0 | ||
|
|
6545026796 | ||
|
|
a10a10bcd3 | ||
|
|
908cc50e60 | ||
|
|
1a8f64966c | ||
|
|
1f70c1ba3c | ||
|
|
197781a4d8 | ||
|
|
38803ccf59 | ||
|
|
2148b01ba0 | ||
|
|
b404a4dac1 | ||
|
|
4340515012 | ||
|
|
5205ed1094 | ||
|
|
7961a7dd3f | ||
|
|
f8a2af8554 | ||
|
|
d1fb0026b6 | ||
|
|
7e9932de0a | ||
|
|
0dea1f9d19 | ||
|
|
d7be46c78e | ||
|
|
e95fd5b37d | ||
|
|
dee1abb1da | ||
|
|
4c84a9b5f6 | ||
|
|
016bf5dfcd | ||
|
|
9c15e392d1 | ||
|
|
613c6d3aa7 | ||
|
|
f10cd89647 | ||
|
|
6000541c32 | ||
|
|
87a86b8c0c | ||
|
|
f81d64cc2f | ||
|
|
9b31aeccd8 | ||
|
|
692054c540 | ||
|
|
91486718d8 | ||
|
|
55dd79aebd | ||
|
|
7141dfa9fc | ||
|
|
f16d0cd271 | ||
|
|
838e2fa287 | ||
|
|
b9439c3244 | ||
|
|
410855e8a0 | ||
|
|
71d606d7cd | ||
|
|
a32810417d | ||
|
|
4fcf767079 | ||
|
|
1e6fa11f0f | ||
|
|
9644952d81 | ||
|
|
d1cd299132 | ||
|
|
e3539015f7 | ||
|
|
874cedb422 | ||
|
|
63530a41ac | ||
|
|
d3bf5e87e1 | ||
|
|
10c6c3cecd | ||
|
|
4d8fa72491 | ||
|
|
b9c0a66948 | ||
|
|
6b859d31e8 | ||
|
|
4fd328e716 | ||
|
|
b606f14836 | ||
|
|
ad49c5c819 | ||
|
|
749a591465 | ||
|
|
a1b1c92648 | ||
|
|
85c6c4ebbc | ||
|
|
de7c9479c9 | ||
|
|
138f51eb39 | ||
|
|
107ca83882 | ||
|
|
839ad531ba | ||
|
|
174eb0c767 | ||
|
|
953422dcd4 | ||
|
|
a4015141c3 | ||
|
|
ca3bd0b22b | ||
|
|
797d11d58d | ||
|
|
8281a852ab | ||
|
|
e5c3f207d9 | ||
|
|
2efc8f4dda | ||
|
|
60a06d8c82 | ||
|
|
857500f9c9 | ||
|
|
396e6985ce | ||
|
|
6d75f29f03 | ||
|
|
f8cdf0837e | ||
|
|
5bb96233d1 | ||
|
|
f3ff6cde7b | ||
|
|
ec88f9822d | ||
|
|
235e81826b | ||
|
|
615ec17b96 | ||
|
|
ddc801fe93 | ||
|
|
24c6b7e7b3 | ||
|
|
55ea381b3b | ||
|
|
55fbc642c4 | ||
|
|
4b5d023d19 | ||
|
|
e0708a5288 | ||
|
|
d44a6e3159 | ||
|
|
09afe2a30c | ||
|
|
160b0b5a04 | ||
|
|
590c24a3bd | ||
|
|
e0978ee64b | ||
|
|
09811eafb7 | ||
|
|
795c410a92 | ||
|
|
6c25b42a94 | ||
|
|
9207af0aaa | ||
|
|
0ad91872a1 | ||
|
|
8b64818b4c | ||
|
|
03462f7ec8 | ||
|
|
3f52944f18 | ||
|
|
1740df6218 | ||
|
|
aa32349727 | ||
|
|
6d81520ff9 | ||
|
|
7d165bb342 | ||
|
|
6f3a5beeaa | ||
|
|
804c96d041 | ||
|
|
9c863c8339 | ||
|
|
3cb89fe502 | ||
|
|
1339c1a292 | ||
|
|
4b8356632c | ||
|
|
e34a38c1f0 | ||
|
|
e6ecfde339 | ||
|
|
688abbd999 | ||
|
|
5d758c8e6d | ||
|
|
ef6946bdd4 | ||
|
|
93ea829987 | ||
|
|
f316d5699d | ||
|
|
a58049d678 | ||
|
|
873df765a0 |
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"import": [
|
||||
"@cspell/dict-typescript/cspell-ext.json"
|
||||
],
|
||||
"ignoreRegExpList": [
|
||||
"import(?:(?:(?:[ \\n\\t]+([^ *\\n\\t\\{\\},]+)[ \\n\\t]*(?:,|[ \\n\\t]+))?([ \\n\\t]*\\{(?:[ \\n\\t]*[^ \\n\\t\"'\\{\\}]+[ \\n\\t]*,?)+\\})?[ \\n\\t]*)|[ \n\\n\\t]*\\*[ \\n\\t]*as[ \\n\\t]+([^ \\n\\t\\{\\}]+)[ \\n\\t]+)from[ \\n\\t]*(?:['\"])([^'\"\\n]+)(['\"])\n",
|
||||
"^import\\s+(['\"]).*\\1$"
|
||||
],
|
||||
"language": "en",
|
||||
"words": [
|
||||
"Bbreak",
|
||||
"nodered",
|
||||
"goog",
|
||||
"thomasnordquist",
|
||||
"nowrap",
|
||||
"subheader",
|
||||
"basepath",
|
||||
"repo",
|
||||
"hexagonalize",
|
||||
"pixelize",
|
||||
"Transistions",
|
||||
"provisionprofile",
|
||||
"Nsis",
|
||||
"webdriverio",
|
||||
"Appx",
|
||||
"Hashable",
|
||||
"clickaway",
|
||||
"Resizer",
|
||||
"Subnodes",
|
||||
"Unmount",
|
||||
"Monokai",
|
||||
"plottable",
|
||||
"snackbar",
|
||||
"Nordquist",
|
||||
"debounced",
|
||||
"mosquitto",
|
||||
"snapcraft",
|
||||
"unsquashfs",
|
||||
"armv",
|
||||
"livingroom",
|
||||
"selectall",
|
||||
"toggledevtools",
|
||||
"Octo",
|
||||
"zigbee",
|
||||
"memoization",
|
||||
"submenu",
|
||||
"AGPL",
|
||||
"Oooooops",
|
||||
"DEVTOOLS",
|
||||
"mixins",
|
||||
"Explorerdmg",
|
||||
"heapsnapshot",
|
||||
"noconflict",
|
||||
"sparkplugb",
|
||||
"protojson",
|
||||
"typesafe"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"commonjs": true,
|
||||
"es6": true
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"globals": {
|
||||
"Atomics": "readonly",
|
||||
"SharedArrayBuffer": "readonly"
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2018
|
||||
},
|
||||
"rules": {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package.ts @thomasnordquist
|
||||
.github @thomasnordquist
|
||||
scripts @thomasnordquist
|
||||
@@ -0,0 +1,5 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
patreon: thomasnordquist
|
||||
custom: https://paypal.me/ThomasNordquist
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- release
|
||||
- beta
|
||||
paths:
|
||||
- Dockerfile
|
||||
- .github
|
||||
|
||||
jobs:
|
||||
create-image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- beta
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
build:
|
||||
- os: ubuntu-latest
|
||||
task: linux
|
||||
- os: windows-latest
|
||||
task: win
|
||||
- os: macos-latest
|
||||
task: mac
|
||||
runs-on: ${{ matrix.build.os }}
|
||||
steps:
|
||||
- if: matrix.build.os == 'ubuntu-latest'
|
||||
run: sudo snap install snapcraft --classic
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm install -g yarn
|
||||
- run: yarn
|
||||
- id: create_token # get ReleaseBot access token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.RELEASE_BOT_APP_ID }}
|
||||
private_key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
|
||||
- name: Semantic Release
|
||||
uses: cycjimmy/semantic-release-action@v4
|
||||
id: semantic # Need an `id` for output variables
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.create_token.outputs.token }}
|
||||
- run: yarn build
|
||||
if: steps.semantic.outputs.new_release_published == 'true'
|
||||
- run: yarn prepare-release
|
||||
if: steps.semantic.outputs.new_release_published == 'true'
|
||||
- run: yarn package ${{ matrix.build.task }}
|
||||
if: steps.semantic.outputs.new_release_published == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
on:
|
||||
pull_request_target: # Use pull_request_target
|
||||
branches: [master, beta, release]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
options: --user root
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Test
|
||||
run: yarn test
|
||||
- name: UI-Test
|
||||
run: yarn ui-test
|
||||
- name: Post-processing
|
||||
run: ./scripts/prepareVideo.sh
|
||||
- uses: hkusu/s3-upload-action@v2
|
||||
id: upload # specify some ID for use in subsequent steps
|
||||
with:
|
||||
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: 'eu-central-1'
|
||||
aws-bucket: ${{ vars.AWS_BUCKET }}
|
||||
file-path: './ui-test.gif'
|
||||
content-type: image/gif
|
||||
output-file-url: 'true'
|
||||
- name: Show URL
|
||||
run: echo '${{ steps.upload.outputs.file-url }}'
|
||||
id: artifact-upload-step
|
||||
- run: echo '' >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Update Website
|
||||
|
||||
on: [release, workflow_dispatch]
|
||||
|
||||
jobs:
|
||||
update-website:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm install
|
||||
- run: npm run readme
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
@@ -7,3 +7,5 @@ build
|
||||
test.dot
|
||||
test.png
|
||||
.awcache
|
||||
.scannerwork
|
||||
screen*.png
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
build
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"branches": [
|
||||
"release",
|
||||
{
|
||||
"name": "beta",
|
||||
"prerelease": true
|
||||
}
|
||||
],
|
||||
repositoryUrl: "git@github.com:thomasnordquist/MQTT-Explorer.git",
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"semantic-release-export-data",
|
||||
"@semantic-release/changelog",
|
||||
[
|
||||
"@semantic-release/npm",
|
||||
{
|
||||
"npmPublish": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json",
|
||||
"yarn.lock"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
language: node_js
|
||||
|
||||
services:
|
||||
- xvfb
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
- $HOME/.cache/electron
|
||||
- $HOME/.cache/electron-builder
|
||||
- $HOME/.npm/_prebuilds
|
||||
|
||||
node_js:
|
||||
- "10"
|
||||
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
|
||||
dist: xenial
|
||||
|
||||
services:
|
||||
- docker
|
||||
|
||||
install:
|
||||
- yarn install
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then docker build docker --tag uitest; fi;
|
||||
|
||||
script:
|
||||
- yarn run build
|
||||
- yarn test
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then docker run -e GH_TOKEN=$GH_TOKEN -e GIT_TAG=$TRAVIS_TAG --rm -v `pwd`:/app uitest sh -c "cd app && docker/testMounted.sh"; fi
|
||||
- if [[ "$TRAVIS_TAG" != "" ]]; then yarn run prepare-release; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "linux" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- linux; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- mac; fi
|
||||
- if [[ "$TRAVIS_OS_NAME" == "osx" ]] && [[ "$TRAVIS_TAG" != "" ]]; then yarn run package -- win; fi
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"build/": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# 0.2.3
|
||||
|
||||
- Highlight differences in the last received message with a "diff" view
|
||||
- Magnify app content with "Ctrl +", "Ctrl -", "Ctrl 0"
|
||||
- Make user interactions more predictable by removing "select topic on mouse over"
|
||||
- Add "Quick Preview" setting, to enable topic selection on mouse over
|
||||
- Add JSON formatter
|
||||
- Show error when connection to the mqtt broker is lost
|
||||
- Fix bug where mqtt explorer resets the tree after a reconnect
|
||||
- Fix MQTT-Client id
|
||||
|
||||
# 0.2.0
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:11-stretch
|
||||
FROM node:20
|
||||
|
||||
RUN DEBIAN_FRONTEND="noninteractive" apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nano ffmpeg xvfb git-core tmux locales mosquitto x11vnc
|
||||
&& apt-get install -y --no-install-recommends ca-certificates nano ffmpeg xvfb git-core tmux locales mosquitto x11vnc
|
||||
RUN apt-get install -yq --no-install-recommends libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 libnss3
|
||||
|
||||
# Generate locales for TMUX
|
||||
@@ -12,6 +12,5 @@ ENV LC_ALL en_US.UTF-8
|
||||
|
||||
CMD /bin/bash
|
||||
|
||||
COPY cloneBuildAndTest.sh ./
|
||||
VOLUME /app
|
||||
EXPOSE 5900
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
## creative commons
|
||||
|
||||
# Attribution-NoDerivatives 4.0 International
|
||||
|
||||
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
|
||||
|
||||
### Using Creative Commons Public Licenses
|
||||
|
||||
Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.
|
||||
|
||||
* __Considerations for licensors:__ Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors).
|
||||
|
||||
* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
|
||||
|
||||
## Creative Commons Attribution-NoDerivatives 4.0 International Public License
|
||||
|
||||
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
|
||||
|
||||
### Section 1 – Definitions.
|
||||
|
||||
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
|
||||
|
||||
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
|
||||
|
||||
c. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
|
||||
|
||||
d. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
|
||||
|
||||
e. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
|
||||
|
||||
f. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
g. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
|
||||
|
||||
h. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
|
||||
|
||||
i. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
|
||||
|
||||
j. __You__ means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.
|
||||
|
||||
### Section 2 – Scope.
|
||||
|
||||
a. ___License grant.___
|
||||
|
||||
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
A. reproduce and Share the Licensed Material, in whole or in part; and
|
||||
|
||||
B. produce and reproduce, but not Share, Adapted Material.
|
||||
|
||||
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
|
||||
|
||||
3. __Term.__ The term of this Public License is specified in Section 6(a).
|
||||
|
||||
4. __Media and formats; technical modifications allowed.__ The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.
|
||||
|
||||
5. __Downstream recipients.__
|
||||
|
||||
A. __Offer from the Licensor – Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
|
||||
|
||||
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
|
||||
|
||||
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. ___Other rights.___
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.
|
||||
|
||||
### Section 3 – License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the following conditions.
|
||||
|
||||
a. ___Attribution.___
|
||||
|
||||
1. If You Share the Licensed Material, You must:
|
||||
|
||||
A. retain the following if it is supplied by the Licensor with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;
|
||||
|
||||
B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and
|
||||
|
||||
C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.
|
||||
|
||||
For the avoidance of doubt, You do not have permission under this Public License to Share Adapted Material.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.
|
||||
|
||||
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
|
||||
|
||||
### Section 4 – Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database, provided You do not Share Adapted Material;
|
||||
|
||||
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.
|
||||
|
||||
### Section 5 – Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. __Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.__
|
||||
|
||||
b. __To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.__
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.
|
||||
|
||||
### Section 6 – Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.
|
||||
|
||||
### Section 7 – Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.
|
||||
|
||||
### Section 8 – Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.
|
||||
|
||||
> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](http://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.
|
||||
>
|
||||
> Creative Commons may be contacted at creativecommons.org
|
||||
|
||||
@@ -1,83 +1,105 @@
|
||||
# [MQTT Explorer](https://thomasnordquist.github.io/MQTT-Explorer/)
|
||||
# [MQTT Explorer](https://mqtt-explorer.com)
|
||||
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
|
||||
[](https://ci.appveyor.com/project/thomasnordquist/mqtt-explorer/branch/master)
|
||||
[](https://app.codacy.com/app/thomasnordquist/MQTT-Explorer?utm_source=github.com&utm_medium=referral&utm_content=thomasnordquist/MQTT-Explorer&utm_campaign=Badge_Grade_Dashboard)
|
||||
|
||||
### Version 0.1.3
|
||||
| | | |
|
||||
| :---------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------: |
|
||||
| [](https://mqtt-explorer.com/img/screen-composite.png) | [](https://mqtt-explorer.com/img/screen2.png) | [](https://mqtt-explorer.com/img/screen3.png) |
|
||||
|
||||
See the whole picture of your broker.
|
||||
- What topics are active?
|
||||
- Which retained topics are abandoned?
|
||||
- Where is this new device / service I connected to the broker?
|
||||
# The App has moved to [mqtt-explorer.com](https://mqtt-explorer.com)
|
||||
|
||||
MQTT-Explorer strives to be THE swiss-army-knife tool.
|
||||
The perfect tool to integrate new services, IoT devices in your network.
|
||||
The hierarchical view allows for a quick understanding what is going on on your broker.
|
||||
MQTT Explorer is a comprehensive and easy-to-use MQTT Client.
|
||||
Downloads can be found at the link above.
|
||||
|
||||
The app is available for Windows ([portable](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3.exe), [installer](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-Setup-0.1.3.exe)), Linux ([AppImage](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3-x86_64.AppImage)) and Mac ([dmg](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3.dmg)).
|
||||
This page is dedicated to its development.
|
||||
Pull-Requests and error reports are welcome.
|
||||
|
||||
## Demo
|
||||

|
||||
## Run from sources
|
||||
|
||||
## Download
|
||||
|
||||
| Platform | | Downloads |
|
||||
|:----------:|:-------------:|:------:|
|
||||
|  | Windows | [portable](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3.exe), [installer](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-Setup-0.1.3.exe) |
|
||||
|  | Linux | [AppImage](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3-x86_64.AppImage)<br>*Run AppImage:<br>Make it executable and double-click it.* |
|
||||
|  | Mac | [dmg](https://github.com/thomasnordquist/MQTT-Explorer/releases/download/v0.1.3/MQTT-Explorer-0.1.3.dmg) |
|
||||
|
||||
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||
## Performance
|
||||
This App is optimized to handle thousands of topics and at least hundreds of thousands messages per minute.
|
||||
So don't worry if you got >=10k topics and >=100k messages per minute.
|
||||
|
||||
Future releases will allow for a more granular selection of subscribed topics to handle
|
||||
```bash
|
||||
npm install -g yarn
|
||||
yarn
|
||||
yarn build
|
||||
yarn start
|
||||
```
|
||||
|
||||
## Develop
|
||||
PRs and issues are welcome
|
||||
|
||||
Install with `npm run install`, build with `npm run build`
|
||||
Launch Application
|
||||
|
||||
Start with `npm run start`
|
||||
```bash
|
||||
npm install -g yarn
|
||||
yarn
|
||||
yarn dev
|
||||
```
|
||||
|
||||
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management.
|
||||
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management, `src` contains all the electron bindings. [mqttjs](https://github.com/mqttjs/MQTT.js) is used to facilitate communication to MQTT brokers.
|
||||
|
||||
## Automated Tests
|
||||
To achieve a reliable product automated tests run regulary on travis.
|
||||
|
||||
To achieve a reliable product automated tests run regularly on travis.
|
||||
|
||||
- Data model
|
||||
- MQTT integration
|
||||
- UI-Tests (The demo is a recorded ui test)
|
||||
|
||||
## Telemetry
|
||||
The App sends telemetry and error reports, this enables me to quickly react on bugs/errors I produced.
|
||||
This is a difficutlt task since this App runs on three different operating systems and architectures.
|
||||
## Run UI-tests
|
||||
|
||||
It basically sends: app version, processor architecture, operating system, used memory, user interactions and error stacks.
|
||||
A [mosquitto](https://mosquitto.org/) MQTT broker is required to run the ui-tests.
|
||||
|
||||
This greatly helps to improve the software quality and reliability.
|
||||
No data about you or your data is send or stored.
|
||||
Even thoug the data is purely technical, an option to disable telemetry is planned. [#52](https://github.com/thomasnordquist/MQTT-Explorer/issues/52)
|
||||
Run tests with
|
||||
|
||||
Example telemetry:
|
||||
```javascript
|
||||
{ system: { arch: 'x64', platform: 'darwin' },
|
||||
appVersion: '0.0.7',
|
||||
events: { HELLO_EVENT: [ 1547714886134 ] },
|
||||
now: 1547714886135,
|
||||
transactionId: '1767d251-f492-4f2c-aa62-88add3acc26b' }
|
||||
{ errors:
|
||||
[ { time: 1547714887921,
|
||||
message: 'He\'s dead Jim!',
|
||||
stack:
|
||||
'Error: He\'s dead Jim!\n at ./src/tracking.ts.exports.default (./mqtt-explorer/app/build/bundle.js:142765:11)\n at new Promise (<anonymous>)\n at Object../src/tracking.ts (./mqtt-explorer/app/build/bundle.js:142764:1)\n at __webpack_require__ (./mqtt-explorer/app/build/bundle.js:20:30)\n at Object../src/index.tsx (./mqtt-explorer/app/build/bundle.js:142618:1)\n at __webpack_require__ (./mqtt-explorer/app/build/bundle.js:20:30)\n at ../backend/node_modules/charenc/charenc.js.charenc.utf8.stringToBytes (./mqtt-explorer/app/build/bundle.js:84:18)\n at ./mqtt-explorer/app/build/bundle.js:87:10' } ],
|
||||
now: 1547714887921,
|
||||
transactionId: '53bf9aac-e695-40cc-9a81-b1cf3398843d' }
|
||||
```bash
|
||||
# Run chromedriver in a separate terminal session
|
||||
./node_modules/.bin/chromedriver --url-base=wd/hub --port=9515 --verbose
|
||||
```
|
||||
|
||||
Compile and execute tests
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
node dist/src/spec/webdriverio.js
|
||||
```
|
||||
|
||||
## Create a release
|
||||
|
||||
Create a PR to `release` branch.
|
||||
There needs to be a "feat: some new feature" or "fix: some bugfix" commit for a new release to be created
|
||||
|
||||
## Create a beta release
|
||||
|
||||
Create a PR to `beta` branch. A "feat" or "fix" commit is necessary to create a new version.
|
||||
|
||||
## Write docs
|
||||
|
||||
```
|
||||
git clone --single-branch -b gh-pages https://github.com/thomasnordquist/MQTT-Explorer.git mqtt-explorer-pages
|
||||
cd mqtt-explorer-pages
|
||||
bundle install
|
||||
bundle exec jekyll serve --incremental
|
||||
```
|
||||
|
||||
Readme file: `Readme.tpl.md`
|
||||
|
||||
Preview is available at
|
||||
http://localhost:4000/Readme.tpl
|
||||
|
||||
## Update docs
|
||||
|
||||
```
|
||||
npm install
|
||||
./updateReadme.ts
|
||||
```
|
||||
|
||||
The readme will be generated from the docs.
|
||||
|
||||
## License
|
||||
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
|
||||
|
||||

|
||||
[CC-BY-ND 4.0](https://creativecommons.org/licenses/by-nd/4.0/)
|
||||
|
||||
The license is a little restrictive to distributing derived work, this may change in the future if the interest arises or more people work on this project.
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# [MQTT Explorer](https://thomasnordquist.github.io/MQTT-Explorer/)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer/releases)
|
||||
[](https://travis-ci.org/thomasnordquist/MQTT-Explorer)
|
||||
|
||||
### Version {{ version }}
|
||||
|
||||
See the whole picture of your broker.
|
||||
- What topics are active?
|
||||
- Which retained topics are abandoned?
|
||||
- Where is this new device / service I connected to the broker?
|
||||
|
||||
MQTT-Explorer strives to be THE swiss-army-knife tool.
|
||||
The perfect tool to integrate new services, IoT devices in your network.
|
||||
The hierarchical view allows for a quick understanding what is going on on your broker.
|
||||
|
||||
The app is available for Windows ({{windowsTargets}}), Linux ({{linuxTargets}}) and Mac ({{macTargets}}).
|
||||
|
||||
## Demo
|
||||

|
||||
|
||||
## Download
|
||||
|
||||
| Platform | | Downloads |
|
||||
|:----------:|:-------------:|:------:|
|
||||
|  | Windows | {{windowsTargets}} |
|
||||
|  | Linux | {{linuxTargets}}<br>*Run AppImage:<br>Make it executable and double-click it.* |
|
||||
|  | Mac | {{macTargets}} |
|
||||
|
||||
[More Downloads](https://github.com/thomasnordquist/MQTT-Explorer/releases)
|
||||
|
||||
## Screenshots
|
||||

|
||||
|
||||
## Performance
|
||||
This App is optimized to handle thousands of topics and at least hundreds of thousands messages per minute.
|
||||
So don't worry if you got >=10k topics and >=100k messages per minute.
|
||||
|
||||
Future releases will allow for a more granular selection of subscribed topics to handle
|
||||
|
||||
## Develop
|
||||
PRs and issues are welcome
|
||||
|
||||
Install with `npm run install`, build with `npm run build`
|
||||
|
||||
Start with `npm run start`
|
||||
|
||||
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management.
|
||||
|
||||
## Automated Tests
|
||||
To achieve a reliable product automated tests run regulary on travis.
|
||||
- Data model
|
||||
- MQTT integration
|
||||
- UI-Tests (The demo is a recorded ui test)
|
||||
|
||||
## Telemetry
|
||||
The App sends telemetry and error reports, this enables me to quickly react on bugs/errors I produced.
|
||||
This is a difficutlt task since this App runs on three different operating systems and architectures.
|
||||
|
||||
It basically sends: app version, processor architecture, operating system, used memory, user interactions and error stacks.
|
||||
|
||||
This greatly helps to improve the software quality and reliability.
|
||||
No data about you or your data is send or stored.
|
||||
Even thoug the data is purely technical, an option to disable telemetry is planned. [#52](https://github.com/thomasnordquist/MQTT-Explorer/issues/52)
|
||||
|
||||
Example telemetry:
|
||||
```javascript
|
||||
{ system: { arch: 'x64', platform: 'darwin' },
|
||||
appVersion: '0.0.7',
|
||||
events: { HELLO_EVENT: [ 1547714886134 ] },
|
||||
now: 1547714886135,
|
||||
transactionId: '1767d251-f492-4f2c-aa62-88add3acc26b' }
|
||||
{ errors:
|
||||
[ { time: 1547714887921,
|
||||
message: 'He\'s dead Jim!',
|
||||
stack:
|
||||
'Error: He\'s dead Jim!\n at ./src/tracking.ts.exports.default (./mqtt-explorer/app/build/bundle.js:142765:11)\n at new Promise (<anonymous>)\n at Object../src/tracking.ts (./mqtt-explorer/app/build/bundle.js:142764:1)\n at __webpack_require__ (./mqtt-explorer/app/build/bundle.js:20:30)\n at Object../src/index.tsx (./mqtt-explorer/app/build/bundle.js:142618:1)\n at __webpack_require__ (./mqtt-explorer/app/build/bundle.js:20:30)\n at ../backend/node_modules/charenc/charenc.js.charenc.utf8.stringToBytes (./mqtt-explorer/app/build/bundle.js:84:18)\n at ./mqtt-explorer/app/build/bundle.js:87:10' } ],
|
||||
now: 1547714887921,
|
||||
transactionId: '53bf9aac-e695-40cc-9a81-b1cf3398843d' }
|
||||
```
|
||||
|
||||
## License
|
||||
Not yet decided which license exactly, but the basic idea is: "You may do whatever you want with this tool, except sell it."
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
theme: jekyll-theme-architect
|
||||
title: MQTT Explorer
|
||||
description: Tool to visualize your MQTT topics, a MQTT swiss-army knife
|
||||
author:
|
||||
name: Thomas Nordquist
|
||||
picture: https://avatars1.githubusercontent.com/u/7721625?s=460&v=4
|
||||
github_username: thomasnordquist
|
||||
logo: https://user-images.githubusercontent.com/7721625/52227945-5280b580-28b1-11e9-804c-75a842537393.png
|
||||
plugins:
|
||||
- jekyll-seo-tag
|
||||
google_analytics: UA-133696837-1
|
||||
@@ -1,6 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
{% include head.html %}
|
||||
{% seo %}
|
||||
</head>
|
||||
</html>
|
||||
+182
-119
@@ -1,134 +1,197 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>MQTT-Explorer</title>
|
||||
<script src="./bugtracking.bundle.js"></script>
|
||||
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
|
||||
<title>MQTT Explorer</title>
|
||||
<script src="./bugtracking.bundle.js"></script>
|
||||
|
||||
@keyframes example {
|
||||
0% {background-color: none;}
|
||||
25% {background-color: #3f51b5;}
|
||||
50% {background-color: #3f51b5;}
|
||||
100% {background-color: none;}
|
||||
}
|
||||
<style>
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
[tabindex] {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: rgba(0, 0, 0, 0.0);
|
||||
}
|
||||
@keyframes updateDark {
|
||||
0% {
|
||||
background-color: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(30,30,30,0.3);
|
||||
}
|
||||
25% {
|
||||
background-color: #595585;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(140,140,140,0.8);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
#splash {
|
||||
z-index: 1000000;
|
||||
background-color: #303030;
|
||||
display: block;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
opacity: 1;
|
||||
}
|
||||
#splash1 {
|
||||
margin: 37vh auto 0 auto;
|
||||
height: 25vh;
|
||||
width: 25vh;
|
||||
/* background-image:url('../rings.svg'); */
|
||||
background-size: cover;
|
||||
}
|
||||
#splash2 {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes unsplash {
|
||||
0% {opacity: 1;}
|
||||
100% {opacity: 0;}
|
||||
}
|
||||
50% {
|
||||
background-color: #595585;
|
||||
}
|
||||
|
||||
.Resizer {
|
||||
background: #eee;
|
||||
opacity: .2;
|
||||
z-index: 1;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
100% {
|
||||
background-color: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Resizer:hover {
|
||||
-webkit-transition: all 0.3s ease-out;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
@keyframes updateLight {
|
||||
0% {
|
||||
background-color: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.Resizer.horizontal {
|
||||
height: 11px;
|
||||
margin: -5px 0;
|
||||
border-top: 5px solid rgba(255, 255, 255, 0);
|
||||
border-bottom: 5px solid rgba(255, 255, 255, 0);
|
||||
cursor: row-resize;
|
||||
width: 100%;
|
||||
}
|
||||
25% {
|
||||
background-color: #c0c8c0;
|
||||
}
|
||||
|
||||
.Resizer.horizontal:hover {
|
||||
border-top: 5px solid rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
50% {
|
||||
background-color: #c0c8c0;
|
||||
}
|
||||
|
||||
.Resizer.vertical {
|
||||
width: 11px;
|
||||
margin: 0 -5px;
|
||||
border-left: 5px solid rgba(255, 255, 255, 0);
|
||||
border-right: 5px solid rgba(255, 255, 255, 0);
|
||||
cursor: col-resize;
|
||||
}
|
||||
100% {
|
||||
background-color: none;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.Resizer.vertical:hover {
|
||||
border-left: 5px solid rgba(255, 255, 255, 0.5);
|
||||
border-right: 5px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.Resizer.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.Resizer.disabled:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
<script src="http://localhost:35729/livereload.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="splash"><div id="splash1"></div></div>
|
||||
<div id="app" style="font:-webkit-control"></div>
|
||||
<script>
|
||||
function loadScript(path) {
|
||||
var script = document.createElement("script");
|
||||
script.src = path
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', onLoad(), false);
|
||||
function onLoad() {
|
||||
// <% _.forEach(htmlWebpackPlugin.files.js, function(file) { %>loadScript("<%- file %>");<% }); %>
|
||||
// loadScript("<%= JSON.stringify(htmlWebpackPlugin) %>")
|
||||
}
|
||||
</script>
|
||||
<% _.forEach(htmlWebpackPlugin.files.js, function(file) { %><script src="<%- file %>"></script><% }); %>
|
||||
</body>
|
||||
</html>
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
-webkit-box-shadow: inset 0 0 6px rgba(60, 60, 60, 0.5);
|
||||
background-color: rgba(140, 140, 140, 0.1);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(140, 140, 140, 0.8);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.Resizer {
|
||||
background: rgba(200, 200, 200, 0);
|
||||
z-index: 10;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.Resizer:hover {
|
||||
-webkit-transition: all 0.3s ease-out;
|
||||
transition: all 0.3s ease-out;
|
||||
}
|
||||
|
||||
.Resizer.horizontal {
|
||||
height: 10px;
|
||||
margin: -10px 0 0 0;
|
||||
border-top: 5px solid rgba(255, 255, 255, 0);
|
||||
border-bottom: 5px solid rgba(255, 255, 255, 0);
|
||||
cursor: row-resize;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Resizer.horizontal::before {
|
||||
content: '•••';
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
margin-top: -22px;
|
||||
color: #aaa;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.Resizer.horizontal:hover {
|
||||
border-top: 5px solid rgba(120, 120, 120, 0.3);
|
||||
border-bottom: 5px solid rgba(120, 120, 120, 0.3);
|
||||
}
|
||||
|
||||
.Resizer.vertical {
|
||||
width: 2px;
|
||||
margin: 0px -8px 0px 0px;
|
||||
border-left: 0px solid rgba(128, 128, 128, 0);
|
||||
border-right: 8px solid rgba(128, 128, 128, 0);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.Resizer.vertical::before {
|
||||
content: '•••';
|
||||
margin-left: -11px;
|
||||
height: 3em;
|
||||
margin-top: calc(50vh - 32px);
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
opacity: 1;
|
||||
writing-mode: vertical-lr;
|
||||
text-orientation: sideways;
|
||||
}
|
||||
|
||||
.Resizer.vertical:hover {
|
||||
border-left: 0px solid rgba(130, 130, 130, 0.3);
|
||||
border-right: 8px solid rgba(140, 140, 140, 0.3);
|
||||
}
|
||||
|
||||
.Resizer.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.Resizer.disabled:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.example-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.example-enter-active {
|
||||
opacity: 1;
|
||||
transition: opacity 300ms ease-in;
|
||||
}
|
||||
|
||||
.example-exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.example-exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease-in;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
global = globalThis //<- this should be enough
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app" style="font: -webkit-control;"></div>
|
||||
<script>
|
||||
function loadScript(path) {
|
||||
var script = document.createElement('script')
|
||||
script.src = path
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', onLoad(), false)
|
||||
function onLoad() {
|
||||
// <% _.forEach(htmlWebpackPlugin.files.js, function(file) { %>loadScript("<%- file %>");<% }); %>
|
||||
// loadScript("<%= JSON.stringify(htmlWebpackPlugin) %>")
|
||||
}
|
||||
</script>
|
||||
<% _.forEach(htmlWebpackPlugin.files.js, function(file) { %>
|
||||
<script src="<%- file %>"></script>
|
||||
<% }); %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+73
-37
@@ -4,54 +4,90 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "webpack --mode production"
|
||||
"build": "webpack --mode production",
|
||||
"dev": "node_modules/.bin/webpack-dev-server --mode development --progress",
|
||||
"test": "cross-env TS_NODE_PROJECT=test/tsconfig.json yarn mochatest",
|
||||
"mochatest": "mocha --require ts-node/register --require source-map-support/register --recursive src/*/**/*.spec.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@material-ui/core": "^3.9.0",
|
||||
"@material-ui/icons": "^3.0.1",
|
||||
"@material-ui/styles": "^3.0.0-alpha.8",
|
||||
"@types/node": "^10.12.18",
|
||||
"@types/react": "^16.7.18",
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/react-redux": "^6.0.12",
|
||||
"@types/react-resize-detector": "^3.1.0",
|
||||
"@types/react-split-pane": "^0.1.67",
|
||||
"@types/sha1": "^1.1.1",
|
||||
"@types/socket.io-client": "^1.4.32",
|
||||
"@types/vis": "^4.21.9",
|
||||
"awesome-typescript-loader": "^5.2.1",
|
||||
"compare-versions": "^3.4.0",
|
||||
"copy-text-to-clipboard": "^1.0.4",
|
||||
"css-loader": "^2.1.0",
|
||||
"electron-nucleus": "^1.11.0",
|
||||
"electron-telemetry": "git+https://github.com/thomasnordquist/electron-telemetry.git#dist",
|
||||
"html-webpack-plugin": "^4.0.0-beta.5",
|
||||
"jquery": "^3.3.1",
|
||||
"license": "CC-BY-ND-4.0",
|
||||
"dependencies": {
|
||||
"@material-ui/core": "4.12",
|
||||
"@material-ui/icons": "^4",
|
||||
"@material-ui/lab": "^4.0.0-alpha",
|
||||
"@material-ui/styles": "4.11",
|
||||
"@types/react-transition-group": "^4",
|
||||
"ace-builds": "^1.4.11",
|
||||
"axios": "^0.28.0",
|
||||
"compare-versions": "^3.5.0",
|
||||
"copy-text-to-clipboard": "^2.1.0",
|
||||
"d3": "^5.9.7",
|
||||
"d3-shape": "^1.3.5",
|
||||
"diff": "^4.0.1",
|
||||
"dot-prop": "^5.0.0",
|
||||
"get-value": "^3.0.1",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"in-viewport": "^3.6.0",
|
||||
"js-base64": "^2.5.1",
|
||||
"json-to-ast": "^2.1.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"moving-average": "^1.0.0",
|
||||
"number-abbreviate": "^2.0.0",
|
||||
"react": "^16.8.0-alpha.1",
|
||||
"react-ace": "^6.3.2",
|
||||
"parse-duration": "^0.1.1",
|
||||
"prismjs": "^1.15.0",
|
||||
"react": "^16.11",
|
||||
"react-ace": "^8",
|
||||
"react-dom": "^16.7.0",
|
||||
"react-json-view": "^1.19.1",
|
||||
"react-redux": "^6.0.0",
|
||||
"react-resize-detector": "^3.4.0",
|
||||
"react-redux": "^7.0.3",
|
||||
"react-resize-detector": "^4.1.4",
|
||||
"react-split-pane": "^0.1.85",
|
||||
"react-transition-group": "^4",
|
||||
"react-vis": "^1.11.6",
|
||||
"redux": "^4.0.1",
|
||||
"redux-batched-actions": "^0.4.1",
|
||||
"redux-batched-actions": "0.5",
|
||||
"redux-thunk": "^2.3.0",
|
||||
"sha1": "^1.1.1",
|
||||
"socket.io-client": "^2.2.0",
|
||||
"uuid": "7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/runtime": "^7.17.2",
|
||||
"@types/d3": "^5.7.2",
|
||||
"@types/diff": "^4.0.1",
|
||||
"@types/get-value": "^3.0.1",
|
||||
"@types/node": "^12.7.8",
|
||||
"@types/prismjs": "^1.9.1",
|
||||
"@types/react": "^16.9.4",
|
||||
"@types/react-dom": "^16.0.11",
|
||||
"@types/react-redux": "^7.0.9",
|
||||
"@types/react-resize-detector": "^4.0.1",
|
||||
"@types/sha1": "^1.1.1",
|
||||
"@types/socket.io-client": "^1.4.32",
|
||||
"@types/uuid": "^7.0.2",
|
||||
"@types/vis": "^4.21.9",
|
||||
"chai": "^4.2.0",
|
||||
"cross-env": "^7.0.2",
|
||||
"css-loader": "^3.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mocha": "^10.4.0",
|
||||
"moment": "^2.29.1",
|
||||
"node-loader": "^0.6.0",
|
||||
"source-map-loader": "^0.2.4",
|
||||
"style-loader": "^0.23.1",
|
||||
"typescript": "^3.2.2",
|
||||
"webpack": "^4.28.2",
|
||||
"webpack-bundle-analyzer": "^3.0.3",
|
||||
"webpack-cli": "^3.1.2",
|
||||
"webpack-dev-server": "^3.1.14",
|
||||
"webpack-livereload-plugin": "^2.2.0"
|
||||
"style-loader": "^1",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.5.5",
|
||||
"webpack": "^5.69.1",
|
||||
"webpack-bundle-analyzer": "^4.5.0",
|
||||
"webpack-cli": "^4.9.2",
|
||||
"webpack-dev-server": "^4.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"electron": "^29"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||
<svg width="45" height="45" viewBox="0 0 45 45" xmlns="http://www.w3.org/2000/svg" stroke="#fff">
|
||||
<g fill="none" fill-rule="evenodd" transform="translate(1 1)" stroke-width="2">
|
||||
<circle cx="22" cy="22" r="6" stroke-opacity="0">
|
||||
<animate attributeName="r"
|
||||
begin="1.5s" dur="3s"
|
||||
values="6;22"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity"
|
||||
begin="1.5s" dur="3s"
|
||||
values="1;0" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-width"
|
||||
begin="1.5s" dur="3s"
|
||||
values="2;0" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="22" cy="22" r="6" stroke-opacity="0">
|
||||
<animate attributeName="r"
|
||||
begin="3s" dur="3s"
|
||||
values="6;22"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-opacity"
|
||||
begin="3s" dur="3s"
|
||||
values="1;0" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="stroke-width"
|
||||
begin="3s" dur="3s"
|
||||
values="2;0" calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="22" cy="22" r="8">
|
||||
<animate attributeName="r"
|
||||
begin="0s" dur="1.5s"
|
||||
values="6;1;2;3;4;5;6"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
-125
@@ -1,125 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../backend/src/Model'
|
||||
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import { AppState } from './reducers'
|
||||
import Connection from './components/ConnectionSetup/Connection'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
const Settings = React.lazy(() => import('./components/Settings'))
|
||||
import Sidebar from './components/Sidebar/Sidebar'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import Tree from './components/Tree/Tree'
|
||||
import UpdateNotifier from './UpdateNotifier'
|
||||
import { connect } from 'react-redux'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import { default as SplitPane } from 'react-split-pane'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
connectionId: string
|
||||
classes: any
|
||||
settingsVisible: boolean
|
||||
}
|
||||
|
||||
class App extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { }
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { settingsVisible } = this.props
|
||||
const { content, contentShift, centerContent, paneDefaults, heightProperty } = this.props.classes
|
||||
|
||||
return (
|
||||
<div className={centerContent}>
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Settings />
|
||||
</React.Suspense>
|
||||
<div className={centerContent}>
|
||||
<div className={`${settingsVisible ? contentShift : content}`}>
|
||||
<TitleBar />
|
||||
</div>
|
||||
<div>
|
||||
<SplitPane
|
||||
step={20}
|
||||
primary="second"
|
||||
className={`${settingsVisible ? contentShift : content} ${heightProperty}`}
|
||||
split="vertical"
|
||||
minSize={250}
|
||||
defaultSize={500}
|
||||
resizerStyle={{ width: '2px', margin: '0 -10px 0 0px' }}
|
||||
allowResize={true}
|
||||
style={{ position: 'relative' }}
|
||||
pane1Style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className={paneDefaults}>
|
||||
<Tree />
|
||||
</div>
|
||||
<div className={paneDefaults}>
|
||||
<Sidebar connectionId={this.props.connectionId} />
|
||||
</div>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateNotifier />
|
||||
<Connection />
|
||||
</ErrorBoundary>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
settingsVisible: state.settings.visible,
|
||||
connectionId: state.connection.connectionId,
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
const drawerWidth = 300
|
||||
return {
|
||||
heightProperty: {
|
||||
height: 'calc(100vh - 64px) !important',
|
||||
},
|
||||
paneDefaults: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
overflowY: 'scroll' as 'scroll',
|
||||
overflowX: 'hidden' as 'hidden',
|
||||
display: 'block' as 'block',
|
||||
height: 'calc(100vh - 64px)',
|
||||
},
|
||||
centerContent: {
|
||||
width: '100vw',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
content: {
|
||||
width: '100vw',
|
||||
overflowX: 'hidden' as 'hidden',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
transition: theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
transform: 'translateX(0px)',
|
||||
},
|
||||
contentShift: {
|
||||
overflowX: 'hidden' as 'hidden',
|
||||
width: '100vw',
|
||||
padding: 0,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
transition: theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
transform: `translateX(${drawerWidth}px)`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps)(App))
|
||||
@@ -1,19 +0,0 @@
|
||||
import { EventDispatcher } from '../../events'
|
||||
|
||||
export class TopicViewModel {
|
||||
private selected: boolean
|
||||
public change = new EventDispatcher<void, TopicViewModel>(this)
|
||||
|
||||
public constructor() {
|
||||
this.selected = false
|
||||
}
|
||||
|
||||
public isSelected() {
|
||||
return this.selected
|
||||
}
|
||||
|
||||
public setSelected(selected: boolean) {
|
||||
this.selected = selected
|
||||
this.change.dispatch()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Action, ActionTypes, ChartParameters } from '../reducers/Charts'
|
||||
import { AppState } from '../reducers'
|
||||
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
|
||||
import { Dispatch } from 'redux'
|
||||
import { showError, showNotification } from './Global'
|
||||
|
||||
interface ConnectionViewState {
|
||||
charts: Array<ChartParameters>
|
||||
}
|
||||
|
||||
interface ConnectionViewStateDictionary {
|
||||
[s: string]: ConnectionViewState
|
||||
}
|
||||
const connectionViewStateIdentifier: StorageIdentifier<ConnectionViewStateDictionary> = {
|
||||
id: 'connection_view_state',
|
||||
}
|
||||
|
||||
export const loadCharts = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const connectionId = getState().connection.connectionId
|
||||
if (!connectionId) {
|
||||
return
|
||||
}
|
||||
|
||||
let viewStates: ConnectionViewStateDictionary | undefined
|
||||
try {
|
||||
viewStates = await persistentStorage.load(connectionViewStateIdentifier)
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
|
||||
if (!viewStates || !viewStates[connectionId]) {
|
||||
dispatch(setCharts([]))
|
||||
return
|
||||
}
|
||||
|
||||
const viewState = viewStates[connectionId]
|
||||
if (viewState) {
|
||||
dispatch(setCharts(viewState.charts))
|
||||
}
|
||||
}
|
||||
|
||||
export const saveCharts = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const connectionId = getState().connection.connectionId
|
||||
if (!connectionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const charts = getState().charts.get('charts').toArray()
|
||||
|
||||
let viewStates: ConnectionViewStateDictionary | undefined
|
||||
try {
|
||||
viewStates = (await persistentStorage.load(connectionViewStateIdentifier)) || {}
|
||||
const state: ConnectionViewState = viewStates[connectionId] || { charts: [] }
|
||||
state.charts = charts
|
||||
|
||||
viewStates[connectionId] = state
|
||||
await persistentStorage.store(connectionViewStateIdentifier, viewStates)
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
}
|
||||
|
||||
export const addChart =
|
||||
(chartParameters: ChartParameters) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const chartExists = Boolean(
|
||||
getState()
|
||||
.charts.get('charts')
|
||||
.find(chart => chart.topic === chartParameters.topic && chart.dotPath === chartParameters.dotPath)
|
||||
)
|
||||
if (chartExists) {
|
||||
dispatch(showNotification('Already added'))
|
||||
return
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ActionTypes.CHARTS_ADD,
|
||||
chart: chartParameters,
|
||||
})
|
||||
dispatch(saveCharts())
|
||||
dispatch(showNotification('Added to chart panel'))
|
||||
}
|
||||
|
||||
export const updateChart =
|
||||
(chartParameters: ChartParameters) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.CHARTS_UPDATE,
|
||||
topic: chartParameters.topic,
|
||||
dotPath: chartParameters.dotPath,
|
||||
parameters: chartParameters,
|
||||
})
|
||||
dispatch(saveCharts())
|
||||
}
|
||||
|
||||
export const removeChart =
|
||||
(chartParameters: ChartParameters) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch({
|
||||
chart: chartParameters,
|
||||
type: ActionTypes.CHARTS_REMOVE,
|
||||
})
|
||||
dispatch(saveCharts())
|
||||
}
|
||||
|
||||
export const moveChartUp =
|
||||
(parameters: { topic: string; dotPath?: string }) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch({
|
||||
topic: parameters.topic,
|
||||
dotPath: parameters.dotPath,
|
||||
type: ActionTypes.CHARTS_MOVE_UP,
|
||||
})
|
||||
dispatch(saveCharts())
|
||||
}
|
||||
|
||||
export const setCharts = (charts: Array<ChartParameters>): Action => {
|
||||
return {
|
||||
charts,
|
||||
type: ActionTypes.CHARTS_SET,
|
||||
}
|
||||
}
|
||||
@@ -1,55 +1,92 @@
|
||||
import { ActionTypes, Action, ConnectionState } from '../reducers/Connection'
|
||||
import { MqttOptions } from '../../../backend/src/DataSource'
|
||||
import { Dispatch } from 'redux'
|
||||
import { rendererEvents, addMqttConnectionEvent, makeConnectionStateEvent, removeConnection } from '../../../events'
|
||||
import { AppState } from '../reducers'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { showTree } from './Tree'
|
||||
import * as url from 'url'
|
||||
import { TopicViewModel } from '../TopicViewModel'
|
||||
import { Action, ActionTypes } from '../reducers/Connection'
|
||||
import { ActionTypes as SettingsActionTypes } from '../reducers/Settings'
|
||||
import { AppState } from '../reducers'
|
||||
import { DataSourceState, MqttOptions } from '../../../backend/src/DataSource'
|
||||
import { Dispatch } from 'redux'
|
||||
import { globalActions } from '.'
|
||||
import { resetStore as resetTreeStore, showTree } from './Tree'
|
||||
import { showError } from './Global'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../events'
|
||||
|
||||
export const connect = (options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch(connecting(connectionId))
|
||||
rendererEvents.emit(addMqttConnectionEvent, { options, id: connectionId })
|
||||
const event = makeConnectionStateEvent(connectionId)
|
||||
const host = url.parse(options.url).hostname
|
||||
export const connect =
|
||||
(options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch(connecting(connectionId))
|
||||
rendererEvents.emit(addMqttConnectionEvent, { options, id: connectionId })
|
||||
const event = makeConnectionStateEvent(connectionId)
|
||||
const host = url.parse(options.url).hostname
|
||||
|
||||
rendererEvents.subscribe(event, (dataSourceState) => {
|
||||
if (dataSourceState.connected) {
|
||||
const tree = new q.Tree<TopicViewModel>()
|
||||
tree.updateWithConnection(rendererEvents, connectionId)
|
||||
dispatch(connected(tree, host!))
|
||||
dispatch(showTree(tree))
|
||||
} else if (dataSourceState.error) {
|
||||
dispatch(showError(dataSourceState.error))
|
||||
dispatch(disconnect())
|
||||
}
|
||||
rendererEvents.subscribe(event, dataSourceState => {
|
||||
if (dataSourceState.connected) {
|
||||
const didReconnect = Boolean(getState().connection.tree)
|
||||
if (!didReconnect) {
|
||||
const tree = new q.Tree<TopicViewModel>()
|
||||
tree.updateWithConnection(rendererEvents, connectionId)
|
||||
dispatch(showTree(tree))
|
||||
dispatch(connected(tree, host!))
|
||||
}
|
||||
} else if (dataSourceState.error) {
|
||||
dispatch(showError(dataSourceState.error))
|
||||
dispatch(disconnect())
|
||||
}
|
||||
dispatch(updateHealth(dataSourceState))
|
||||
})
|
||||
}
|
||||
|
||||
const updateHealth = (dataSourceState: DataSourceState) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
let state
|
||||
if (dataSourceState.connecting) {
|
||||
state = 'connecting'
|
||||
} else if (!dataSourceState.connected) {
|
||||
state = 'offline'
|
||||
dispatch(globalActions.showError('Disconnected from server'))
|
||||
} else if (dataSourceState.connected) {
|
||||
state = 'online'
|
||||
} else {
|
||||
state = undefined
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: ActionTypes.CONNECTION_SET_HEALTH,
|
||||
health: state,
|
||||
})
|
||||
}
|
||||
|
||||
export const connected: (tree: q.Tree<TopicViewModel>, host: string) => Action = (tree: q.Tree<TopicViewModel>, host: string) => ({
|
||||
export const connected: (tree: q.Tree<TopicViewModel>, host: string) => Action = (
|
||||
tree: q.Tree<TopicViewModel>,
|
||||
host: string
|
||||
) => ({
|
||||
tree,
|
||||
host,
|
||||
type: ActionTypes.CONNECTION_SET_CONNECTED,
|
||||
})
|
||||
|
||||
export const connecting: (connectionId: string) => Action = (connectionId: string) => ({
|
||||
export const connecting: (connectionId: string) => Action = (connectionId: string) => ({
|
||||
connectionId,
|
||||
type: ActionTypes.CONNECTION_SET_CONNECTING,
|
||||
})
|
||||
|
||||
export const showError = (error?: string) => ({
|
||||
error,
|
||||
type: ActionTypes.CONNECTION_SET_SHOW_ERROR,
|
||||
})
|
||||
|
||||
export const disconnect = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
export const disconnect = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const { connectionId, tree } = getState().connection
|
||||
rendererEvents.emit(removeConnection, connectionId)
|
||||
tree && tree.stopUpdating()
|
||||
if (connectionId) {
|
||||
rendererEvents.emit(removeConnection, connectionId)
|
||||
rendererEvents.unsubscribeAll(makeConnectionStateEvent(connectionId))
|
||||
}
|
||||
|
||||
dispatch(showTree(undefined))
|
||||
tree && tree.stopUpdating()
|
||||
tree && tree.destroy()
|
||||
|
||||
// Clear topic filter
|
||||
dispatch({
|
||||
topicFilter: '',
|
||||
type: SettingsActionTypes.SETTINGS_FILTER_TOPICS,
|
||||
})
|
||||
|
||||
dispatch(resetTreeStore())
|
||||
dispatch({
|
||||
type: ActionTypes.CONNECTION_SET_DISCONNECTED,
|
||||
})
|
||||
dispatch(showTree(undefined))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { AppState } from '../reducers'
|
||||
import { clearLegacyConnectionOptions, loadLegacyConnectionOptions } from '../model/LegacyConnectionSettings'
|
||||
import {
|
||||
ConnectionOptions,
|
||||
createEmptyConnection,
|
||||
makeDefaultConnections,
|
||||
CertificateParameters,
|
||||
} from '../model/ConnectionOptions'
|
||||
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
|
||||
import { Dispatch } from 'redux'
|
||||
import { showError } from './Global'
|
||||
import { promises as fsPromise } from 'fs'
|
||||
import * as path from 'path'
|
||||
import { ActionTypes, Action } from '../reducers/ConnectionManager'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
import { connectionsMigrator } from './migrations/Connection'
|
||||
import { rendererRpc } from '../../../events'
|
||||
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
|
||||
|
||||
export interface ConnectionDictionary {
|
||||
[s: string]: ConnectionOptions
|
||||
}
|
||||
const storedConnectionsIdentifier: StorageIdentifier<ConnectionDictionary> = {
|
||||
id: 'ConnectionManager_connections',
|
||||
}
|
||||
|
||||
export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
let connections
|
||||
try {
|
||||
await ensureConnectionsHaveBeenInitialized()
|
||||
connections = await persistentStorage.load(storedConnectionsIdentifier)
|
||||
|
||||
// Apply migrations
|
||||
if (connections && connectionsMigrator.isMigrationNecessary(connections)) {
|
||||
connections = connectionsMigrator.applyMigrations(connections)
|
||||
await persistentStorage.store(storedConnectionsIdentifier, connections)
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
|
||||
if (!connections) {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(setConnections(connections))
|
||||
const firstKey = Object.keys(connections)[0]
|
||||
if (firstKey) {
|
||||
dispatch(selectConnection(firstKey))
|
||||
}
|
||||
}
|
||||
|
||||
export type CertificateTypes = 'selfSignedCertificate' | 'clientCertificate' | 'clientKey'
|
||||
export const selectCertificate =
|
||||
(type: CertificateTypes, connectionId: string) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
try {
|
||||
const certificate = await openCertificate()
|
||||
dispatch(
|
||||
updateConnection(connectionId, {
|
||||
[type]: certificate,
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function openCertificate(): Promise<CertificateParameters> {
|
||||
const rejectReasons = {
|
||||
noCertificateSelected: 'No certificate selected',
|
||||
certificateSizeDoesNotMatch: 'Certificate size larger/smaller then expected.',
|
||||
}
|
||||
|
||||
const openDialogReturnValue = await rendererRpc.call(makeOpenDialogRpc(), {
|
||||
properties: ['openFile'],
|
||||
securityScopedBookmarks: true,
|
||||
})
|
||||
|
||||
const selectedFile = openDialogReturnValue.filePaths && openDialogReturnValue.filePaths[0]
|
||||
if (!selectedFile) {
|
||||
throw rejectReasons.noCertificateSelected
|
||||
}
|
||||
|
||||
const data = await fsPromise.readFile(selectedFile)
|
||||
if (data.length > 16_384 || data.length < 64) {
|
||||
throw rejectReasons.certificateSizeDoesNotMatch
|
||||
}
|
||||
|
||||
return {
|
||||
data: data.toString('base64'),
|
||||
name: path.basename(selectedFile),
|
||||
}
|
||||
}
|
||||
|
||||
export const saveConnectionSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
try {
|
||||
console.log('store settings')
|
||||
await persistentStorage.store(storedConnectionsIdentifier, getState().connectionManager.connections)
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
}
|
||||
|
||||
export const updateConnection = (connectionId: string, changeSet: Partial<ConnectionOptions>): Action => ({
|
||||
connectionId,
|
||||
changeSet,
|
||||
type: ActionTypes.CONNECTION_MANAGER_UPDATE_CONNECTION,
|
||||
})
|
||||
|
||||
export const addSubscription = (subscription: Subscription, connectionId: string): Action => ({
|
||||
connectionId,
|
||||
subscription,
|
||||
type: ActionTypes.CONNECTION_MANAGER_ADD_SUBSCRIPTION,
|
||||
})
|
||||
|
||||
export const deleteSubscription = (subscription: Subscription, connectionId: string): Action => ({
|
||||
connectionId,
|
||||
subscription,
|
||||
type: ActionTypes.CONNECTION_MANAGER_DELETE_SUBSCRIPTION,
|
||||
})
|
||||
|
||||
export const createConnection = () => (dispatch: Dispatch<any>) => {
|
||||
const newConnection = createEmptyConnection()
|
||||
dispatch(addConnection(newConnection))
|
||||
dispatch(selectConnection(newConnection.id))
|
||||
}
|
||||
|
||||
export const setConnections = (connections: { [s: string]: ConnectionOptions }): Action => ({
|
||||
connections,
|
||||
type: ActionTypes.CONNECTION_MANAGER_SET_CONNECTIONS,
|
||||
})
|
||||
|
||||
export const selectConnection = (connectionId: string): Action => ({
|
||||
selected: connectionId,
|
||||
type: ActionTypes.CONNECTION_MANAGER_SELECT_CONNECTION,
|
||||
})
|
||||
|
||||
export const addConnection = (connection: ConnectionOptions): Action => ({
|
||||
connection,
|
||||
type: ActionTypes.CONNECTION_MANAGER_ADD_CONNECTION,
|
||||
})
|
||||
|
||||
export const toggleAdvancedSettings = (): Action => ({
|
||||
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_ADVANCED_SETTINGS,
|
||||
})
|
||||
|
||||
export const toggleCertificateSettings = (): Action => ({
|
||||
type: ActionTypes.CONNECTION_MANAGER_TOGGLE_CERTIFICATE_SETTINGS,
|
||||
})
|
||||
|
||||
export const deleteConnection = (connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const connectionIds = Object.keys(getState().connectionManager.connections)
|
||||
const connectionIdLocation = connectionIds.indexOf(connectionId)
|
||||
|
||||
const remainingIds = connectionIds.filter(id => id !== connectionId)
|
||||
const nextSelectedConnectionIndex = Math.min(remainingIds.length - 1, connectionIdLocation)
|
||||
const nextSelectedConnection = remainingIds[nextSelectedConnectionIndex]
|
||||
|
||||
dispatch({
|
||||
connectionId,
|
||||
type: ActionTypes.CONNECTION_MANAGER_DELETE_CONNECTION,
|
||||
})
|
||||
|
||||
if (nextSelectedConnection) {
|
||||
dispatch(selectConnection(nextSelectedConnection))
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConnectionsHaveBeenInitialized() {
|
||||
let connections = await persistentStorage.load(storedConnectionsIdentifier)
|
||||
const requiresInitialization = !connections
|
||||
if (requiresInitialization) {
|
||||
const migratedConnection = loadLegacyConnectionOptions()
|
||||
const defaultConnections = makeDefaultConnections()
|
||||
connections = {
|
||||
...migratedConnection,
|
||||
...defaultConnections,
|
||||
}
|
||||
await persistentStorage.store(storedConnectionsIdentifier, connections)
|
||||
|
||||
clearLegacyConnectionOptions()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ActionTypes, ConfirmationRequest } from '../reducers/Global'
|
||||
import { Dispatch } from 'redux'
|
||||
|
||||
export const showError = (error?: string | unknown) => ({
|
||||
error,
|
||||
type: ActionTypes.showError,
|
||||
})
|
||||
|
||||
export const showNotification = (notification?: string) => ({
|
||||
notification,
|
||||
type: ActionTypes.showNotification,
|
||||
})
|
||||
|
||||
export const didLaunch = () => ({
|
||||
type: ActionTypes.didLaunch,
|
||||
})
|
||||
|
||||
export const toggleSettingsVisibility = () => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
type: ActionTypes.toggleSettingsVisibility,
|
||||
})
|
||||
}
|
||||
|
||||
export const requestConfirmation = (title: string, inquiry: string) => (dispatch: Dispatch<any>) => {
|
||||
return new Promise(resolve => {
|
||||
const confirmationRequest = {
|
||||
title,
|
||||
inquiry,
|
||||
callback: (confirmed: boolean) => {
|
||||
resolve(confirmed)
|
||||
dispatch(removeConfirmationRequest(confirmationRequest))
|
||||
},
|
||||
}
|
||||
|
||||
dispatch({
|
||||
confirmationRequest,
|
||||
type: ActionTypes.requestConfirmation,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const removeConfirmationRequest = (confirmationRequest: ConfirmationRequest) => (dispatch: Dispatch<any>) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch({
|
||||
confirmationRequest,
|
||||
type: ActionTypes.removeConfirmationRequest,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ActionTypes, Action } from '../reducers/Publish'
|
||||
import { Action, ActionTypes } from '../reducers/Publish'
|
||||
import { AppState } from '../reducers'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
import { Dispatch } from 'redux'
|
||||
import { rendererEvents, makePublishEvent } from '../../../events'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
|
||||
export const setTopic = (topic?: string): Action => {
|
||||
export const setTopic = (topic?: string): Action => {
|
||||
return {
|
||||
topic,
|
||||
type: ActionTypes.PUBLISH_SET_TOPIC,
|
||||
@@ -31,9 +32,9 @@ export const setEditorMode = (editorMode: string): Action => {
|
||||
}
|
||||
}
|
||||
|
||||
export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, getState: () => AppState) => {
|
||||
export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, getState: () => AppState) => {
|
||||
const state = getState()
|
||||
const topic = state.publish.topic
|
||||
const topic = state.publish.manualTopic ?? state.tree.get('selectedTopic')?.path()
|
||||
|
||||
if (!topic) {
|
||||
return
|
||||
@@ -42,7 +43,7 @@ export const publish = (connectionId: string) => (dispatch: Dispatch<Action>, ge
|
||||
const publishEvent = makePublishEvent(connectionId)
|
||||
const mqttMessage = {
|
||||
topic,
|
||||
payload: state.publish.payload,
|
||||
payload: state.publish.payload ? Base64Message.fromString(state.publish.payload) : null,
|
||||
retain: state.publish.retain,
|
||||
qos: state.publish.qos,
|
||||
}
|
||||
|
||||
+108
-27
@@ -1,34 +1,99 @@
|
||||
import { Action, ActionTypes, TopicOrder } from '../reducers/Settings'
|
||||
import { ActionTypes as TreeActionTypes } from '../reducers/Tree'
|
||||
import { Dispatch } from 'redux'
|
||||
import { showTree } from './Tree'
|
||||
import { AppState } from '../reducers'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { ActionTypes, SettingsStateModel, TopicOrder } from '../reducers/Settings'
|
||||
import { AppState } from '../reducers'
|
||||
import { autoExpandLimitSet } from '../components/SettingsDrawer/Settings'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
import { batchActions } from 'redux-batched-actions'
|
||||
import { autoExpandLimitSet } from '../components/Settings'
|
||||
import { TopicViewModel } from '../TopicViewModel'
|
||||
import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage'
|
||||
import { Dispatch } from 'redux'
|
||||
import { globalActions } from './'
|
||||
import { showError } from './Global'
|
||||
import { showTree } from './Tree'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
|
||||
export const setAutoExpandLimit = (autoExpandLimit: number = 0): Action => {
|
||||
return {
|
||||
autoExpandLimit,
|
||||
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT,
|
||||
const settingsIdentifier: StorageIdentifier<Partial<SettingsStateModel>> = {
|
||||
id: 'Settings',
|
||||
}
|
||||
|
||||
export const loadSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
try {
|
||||
const settings = (await persistentStorage.load(settingsIdentifier)) || {}
|
||||
dispatch({
|
||||
settings: getState().settings.merge(settings),
|
||||
type: ActionTypes.SETTINGS_DID_LOAD_SETTINGS,
|
||||
})
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
dispatch(globalActions.didLaunch())
|
||||
}
|
||||
|
||||
export const storeSettings = () => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const settings = {
|
||||
...getState().settings.toJS(),
|
||||
autoExpandLimit: undefined,
|
||||
topicFilter: undefined,
|
||||
visible: undefined,
|
||||
}
|
||||
|
||||
try {
|
||||
await persistentStorage.store(settingsIdentifier, settings)
|
||||
} catch (error) {
|
||||
dispatch(showError(error))
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleSettingsVisibility = (): Action => {
|
||||
return {
|
||||
type: ActionTypes.SETTINGS_TOGGLE_VISIBILITY,
|
||||
export const setAutoExpandLimit =
|
||||
(autoExpandLimit: number = 0) =>
|
||||
(dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
autoExpandLimit,
|
||||
type: ActionTypes.SETTINGS_SET_AUTO_EXPAND_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
export const setTimeLocale = (timeLocale: string) => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
timeLocale,
|
||||
type: ActionTypes.SETTINGS_SET_TIME_LOCALE,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
export const setTopicOrder = (topicOrder: TopicOrder = TopicOrder.none): Action => {
|
||||
return {
|
||||
topicOrder,
|
||||
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER,
|
||||
}
|
||||
export const selectTopicWithMouseOver = (doSelect: boolean) => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
selectTopicWithMouseOver: doSelect,
|
||||
type: ActionTypes.SETTINGS_SET_SELECT_TOPIC_WITH_MOUSE_OVER,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
export const setValueDisplayMode = (valueRendererDisplayMode: 'diff' | 'raw') => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
valueRendererDisplayMode,
|
||||
type: ActionTypes.SETTINGS_SET_VALUE_RENDERER_DISPLAY_MODE,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
export const toggleHighlightTopicUpdates = () => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
type: ActionTypes.SETTINGS_TOGGLE_HIGHLIGHT_ACTIVITY,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
export const setTopicOrder =
|
||||
(topicOrder: TopicOrder = TopicOrder.none) =>
|
||||
(dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
topicOrder,
|
||||
type: ActionTypes.SETTINGS_SET_TOPIC_ORDER,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const { tree } = getState().connection
|
||||
|
||||
dispatch({
|
||||
@@ -36,8 +101,8 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, get
|
||||
type: ActionTypes.SETTINGS_FILTER_TOPICS,
|
||||
})
|
||||
|
||||
if (!filterStr || !tree) {
|
||||
dispatch(batchActions([setAutoExpandLimit(0), (showTree(tree) as any)]))
|
||||
if (!filterStr || !tree) {
|
||||
dispatch(batchActions([setAutoExpandLimit(0), showTree(tree) as any]))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,11 +114,16 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, get
|
||||
return true
|
||||
}
|
||||
|
||||
const messageMatches = (node.message && typeof node.message.value === 'string' && node.message.value.toLowerCase().indexOf(filterStr) !== -1)
|
||||
const messageMatches =
|
||||
node.message &&
|
||||
node.message.payload &&
|
||||
Base64Message.toUnicodeString(node.message.payload).toLowerCase().indexOf(filterStr) !== -1
|
||||
|
||||
return Boolean(messageMatches)
|
||||
}
|
||||
|
||||
const resultTree = tree.childTopics()
|
||||
const resultTree = tree
|
||||
.childTopics()
|
||||
.filter(nodeFilter)
|
||||
.map((node: q.TreeNode<TopicViewModel>) => {
|
||||
const clone = node.unconnectedClone()
|
||||
@@ -70,16 +140,17 @@ export const filterTopics = (filterStr: string) => (dispatch: Dispatch<any>, get
|
||||
nextTree.updateWithConnection(tree.updateSource, tree.connectionId, nodeFilter)
|
||||
}
|
||||
|
||||
dispatch(batchActions([setAutoExpandLimit(autoExpandLimitForTree(nextTree)), (showTree(nextTree) as any)]))
|
||||
dispatch(batchActions([setAutoExpandLimit(autoExpandLimitForTree(nextTree)), showTree(nextTree) as any]))
|
||||
}
|
||||
|
||||
function autoExpandLimitForTree(tree: q.Tree<TopicViewModel>) {
|
||||
if (!tree) {
|
||||
return 0
|
||||
}
|
||||
|
||||
function closestExistingLimit(i: number): number {
|
||||
const sorted = autoExpandLimitSet.sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
|
||||
return sorted[0]!.limit
|
||||
const sorted = [...autoExpandLimitSet].sort((a, b) => Math.abs(a.limit - i) - Math.abs(b.limit - i))
|
||||
return sorted[0].limit
|
||||
}
|
||||
|
||||
const count = tree.childTopicCount()
|
||||
@@ -87,3 +158,13 @@ function autoExpandLimitForTree(tree: q.Tree<TopicViewModel>) {
|
||||
|
||||
return closestExistingLimit(calculatedLimit)
|
||||
}
|
||||
|
||||
export const toggleTheme = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
dispatch({
|
||||
type:
|
||||
getState().settings.get('theme') === 'light'
|
||||
? ActionTypes.SETTINGS_SET_THEME_DARK
|
||||
: ActionTypes.SETTINGS_SET_THEME_LIGHT,
|
||||
})
|
||||
dispatch(storeSettings())
|
||||
}
|
||||
|
||||
+16
-14
@@ -1,21 +1,23 @@
|
||||
import { Dispatch, Action } from 'redux'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { ActionTypes } from '../reducers/Sidebar'
|
||||
import { AppState } from '../reducers'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { Dispatch } from 'redux'
|
||||
import { clearTopic } from './clearTopic'
|
||||
|
||||
export const clearRetainedTopic = () => (dispatch: Dispatch<Action>, getState: () => AppState) => {
|
||||
const { selectedTopic } = getState().tree
|
||||
const { connectionId } = getState().connection
|
||||
export { clearTopic } from './clearTopic'
|
||||
|
||||
if (!selectedTopic || !connectionId) {
|
||||
export const clearRetainedTopic = () => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const selectedTopic = getState().tree.get('selectedTopic')
|
||||
if (!selectedTopic) {
|
||||
return
|
||||
}
|
||||
|
||||
const publishEvent = makePublishEvent(connectionId)
|
||||
const mqttMessage = {
|
||||
topic: selectedTopic.path(),
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
}
|
||||
rendererEvents.emit(publishEvent, mqttMessage)
|
||||
dispatch(clearTopic(selectedTopic, false))
|
||||
}
|
||||
|
||||
export const setCompareMessage = (message?: q.Message) => (dispatch: Dispatch<any>) => {
|
||||
dispatch({
|
||||
message,
|
||||
type: ActionTypes.SIDEBAR_SET_COMPARE_MESSAGE,
|
||||
})
|
||||
}
|
||||
|
||||
+100
-40
@@ -1,59 +1,119 @@
|
||||
import { AppState } from '../reducers'
|
||||
import { ActionTypes } from '../reducers/Tree'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { Dispatch, AnyAction } from 'redux'
|
||||
import { setTopic } from './Publish'
|
||||
import { TopicViewModel } from '../TopicViewModel'
|
||||
import { ActionTypes } from '../reducers/Tree'
|
||||
import { ActionTypes as SidebarActionTypes } from '../reducers/Sidebar'
|
||||
import { AnyAction, Dispatch } from 'redux'
|
||||
import { AppState } from '../reducers'
|
||||
import { batchActions } from 'redux-batched-actions'
|
||||
import { globalActions } from './'
|
||||
import { setTopic } from './Publish'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
const debounce = require('lodash.debounce')
|
||||
export { clearTopic } from './clearTopic'
|
||||
|
||||
export const selectTopic = (topic: q.TreeNode<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
debouncedSelectTopic(topic, dispatch, getState)
|
||||
}
|
||||
export { moveSelectionUpOrDownwards, moveInward, moveOutward } from './visibleTreeTraversal'
|
||||
|
||||
const debouncedSelectTopic = debounce((topic: q.TreeNode<TopicViewModel>, dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const { selectedTopic } = getState().tree
|
||||
if (selectedTopic === topic) {
|
||||
return
|
||||
export const selectTopic =
|
||||
(topic: q.TreeNode<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
debouncedSelectTopic(topic, dispatch, getState)
|
||||
}
|
||||
|
||||
// Update publish topic
|
||||
let setTopicDispatch: any | undefined
|
||||
if (selectedTopic && (selectedTopic.path() === getState().publish.topic || !getState().publish.topic)) {
|
||||
setTopicDispatch = setTopic(topic.path())
|
||||
}
|
||||
const debouncedSelectTopic = debounce(
|
||||
(topic: q.TreeNode<TopicViewModel>, dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const previouslySelectedTopic = getState().tree.get('selectedTopic')
|
||||
|
||||
if (selectedTopic && selectedTopic.viewModel) {
|
||||
selectedTopic.viewModel.setSelected(false)
|
||||
}
|
||||
if (previouslySelectedTopic === topic) {
|
||||
return
|
||||
}
|
||||
|
||||
if (topic.viewModel) {
|
||||
topic.viewModel.setSelected(true)
|
||||
}
|
||||
// Update publish topic
|
||||
let setTopicDispatch: any | undefined
|
||||
if (!getState().publish.manualTopic) {
|
||||
setTopicDispatch = setTopic(topic.path())
|
||||
} else if (previouslySelectedTopic && previouslySelectedTopic.path() === getState().publish.manualTopic) {
|
||||
setTopicDispatch = setTopic(topic.path())
|
||||
}
|
||||
|
||||
const selectTreeTopicDispatch = {
|
||||
selectedTopic: topic,
|
||||
type: ActionTypes.TREE_SELECT_TOPIC,
|
||||
}
|
||||
if (previouslySelectedTopic && previouslySelectedTopic.viewModel) {
|
||||
previouslySelectedTopic.viewModel.setSelected(false)
|
||||
}
|
||||
|
||||
if (setTopicDispatch) {
|
||||
dispatch(batchActions([selectTreeTopicDispatch, setTopicDispatch]))
|
||||
} else {
|
||||
dispatch(selectTreeTopicDispatch)
|
||||
}
|
||||
}, 70)
|
||||
if (topic.viewModel) {
|
||||
topic.viewModel.setSelected(true)
|
||||
}
|
||||
|
||||
export const showTree = (tree?: q.Tree<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState): AnyAction => {
|
||||
const visibleTree = getState().tree.tree
|
||||
const connectionTree = getState().connection.tree
|
||||
const selectTreeTopicDispatch = {
|
||||
selectedTopic: topic,
|
||||
type: ActionTypes.TREE_SELECT_TOPIC,
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: SidebarActionTypes.SIDEBAR_SET_COMPARE_MESSAGE,
|
||||
message: undefined,
|
||||
})
|
||||
|
||||
if (setTopicDispatch) {
|
||||
dispatch(batchActions([selectTreeTopicDispatch, setTopicDispatch]))
|
||||
} else {
|
||||
dispatch(selectTreeTopicDispatch)
|
||||
}
|
||||
},
|
||||
70
|
||||
)
|
||||
|
||||
function destroyUnreferencedTree(state: AppState) {
|
||||
const visibleTree = state.tree.get('tree')
|
||||
const connectionTree = state.connection.tree
|
||||
|
||||
// Stop updates of old tree
|
||||
if (visibleTree !== connectionTree && visibleTree) {
|
||||
if (visibleTree && visibleTree !== connectionTree) {
|
||||
console.warn('destroy')
|
||||
visibleTree.stopUpdating()
|
||||
visibleTree.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
export const resetStore =
|
||||
() =>
|
||||
(dispatch: Dispatch<any>, getState: () => AppState): AnyAction => {
|
||||
destroyUnreferencedTree(getState())
|
||||
|
||||
return dispatch({
|
||||
type: ActionTypes.TREE_RESET_STORE,
|
||||
})
|
||||
}
|
||||
|
||||
return dispatch({
|
||||
tree,
|
||||
type: ActionTypes.TREE_SHOW_TREE,
|
||||
export const showTree =
|
||||
(tree: q.Tree<TopicViewModel> | undefined) =>
|
||||
(dispatch: Dispatch<any>, getState: () => AppState): AnyAction => {
|
||||
destroyUnreferencedTree(getState())
|
||||
|
||||
return dispatch({
|
||||
tree,
|
||||
type: ActionTypes.TREE_SHOW_TREE,
|
||||
})
|
||||
}
|
||||
|
||||
export const togglePause = (tree?: q.Tree<TopicViewModel>) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const paused = getState().tree.get('paused')
|
||||
const tree = getState().tree.get('tree')
|
||||
const changes = tree ? tree.unmergedChanges().length : 0
|
||||
|
||||
if (tree) {
|
||||
paused ? tree.resume() : tree.pause()
|
||||
}
|
||||
|
||||
if (paused && changes > 0) {
|
||||
dispatch(globalActions.showNotification('Applying recorded changes.'))
|
||||
}
|
||||
|
||||
// Allow for notification to be displayed
|
||||
setTimeout(() => {
|
||||
if (paused && changes > 0) {
|
||||
dispatch(globalActions.showNotification(`Successfully applied ${changes} changes.`))
|
||||
}
|
||||
}, 50)
|
||||
|
||||
dispatch({
|
||||
type: paused ? ActionTypes.TREE_RESUME_UPDATES : ActionTypes.TREE_PAUSE_UPDATES,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ActionTypes, CustomAction } from '../reducers'
|
||||
import { ActionTypes, GlobalAction } from '../reducers/Global'
|
||||
|
||||
export const showUpdateNotification = (show: boolean): CustomAction => {
|
||||
export const showUpdateNotification = (show: boolean): GlobalAction => {
|
||||
return {
|
||||
type: ActionTypes.showUpdateNotification,
|
||||
showUpdateNotification: show,
|
||||
}
|
||||
}
|
||||
|
||||
export const showUpdateDetails = (show: boolean): CustomAction => {
|
||||
export const showUpdateDetails = (show: boolean): GlobalAction => {
|
||||
return {
|
||||
type: ActionTypes.showUpdateDetails,
|
||||
showUpdateDetails: show,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
|
||||
import { globalActions } from '.'
|
||||
|
||||
export const clearTopic =
|
||||
(topic: q.TreeNode<any>, recursive: boolean) => async (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
const topicsForPurging = recursive ? [topic, ...topic.childTopics()] : [topic]
|
||||
|
||||
if (recursive) {
|
||||
const topicCount = topic.childTopicCount()
|
||||
|
||||
const topicDelta = topic.hasMessage() ? -1 : 0
|
||||
const childTopicsMessage =
|
||||
topicCount + topicDelta > 0
|
||||
? ` and ${topicCount + topicDelta} child ${topicCount + topicDelta === 1 ? 'topic' : 'topics'}`
|
||||
: ''
|
||||
|
||||
const confirmed = await dispatch(
|
||||
globalActions.requestConfirmation(
|
||||
'Confirm delete',
|
||||
`Do you want to clear "${topic.path()}"${childTopicsMessage}?\n\nThis function will send an empty payload (QoS 0, retain) to this and every subtopic, clearing retained topics in the process. Only use this function if you know what you are doing.`
|
||||
)
|
||||
)
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(moveSelectionUpOrDownwards('next'))
|
||||
|
||||
const { connectionId } = getState().connection
|
||||
if (!connectionId) {
|
||||
return
|
||||
}
|
||||
const publishEvent = makePublishEvent(connectionId)
|
||||
|
||||
topicsForPurging
|
||||
.filter(t => t.path() !== '' && t.hasMessage())
|
||||
.map(t => t.path())
|
||||
.forEach((path, idx) => {
|
||||
const mqttMessage = {
|
||||
topic: path,
|
||||
payload: null,
|
||||
retain: true,
|
||||
qos: 0 as 0,
|
||||
messageId: undefined,
|
||||
}
|
||||
// Rate limit deletion
|
||||
setTimeout(() => rendererEvents.emit(publishEvent, mqttMessage), 20 * idx)
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import * as settingsActions from './Settings'
|
||||
import * as chartActions from './Charts'
|
||||
import * as connectionActions from './Connection'
|
||||
import * as connectionManagerActions from './ConnectionManager'
|
||||
import * as globalActions from './Global'
|
||||
import * as publishActions from './Publish'
|
||||
import * as settingsActions from './Settings'
|
||||
import * as sidebarActions from './Sidebar'
|
||||
import * as treeActions from './Tree'
|
||||
import * as updateNotifierActions from './UpdateNotifier'
|
||||
import * as connectionActions from './Connection'
|
||||
import * as sidebarActons from './Sidebar'
|
||||
|
||||
export { settingsActions, treeActions, publishActions, updateNotifierActions, connectionActions, sidebarActons }
|
||||
export {
|
||||
settingsActions,
|
||||
treeActions,
|
||||
chartActions,
|
||||
publishActions,
|
||||
updateNotifierActions,
|
||||
connectionActions,
|
||||
sidebarActions,
|
||||
connectionManagerActions,
|
||||
globalActions,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { ConfigMigrator, Migration } from '../../utils/ConfigMigrator'
|
||||
import { ConnectionDictionary } from '../ConnectionManager'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
|
||||
export interface ConnectionOptionsV0 {
|
||||
type: 'mqtt'
|
||||
id: string
|
||||
host: string
|
||||
protocol: 'mqtt' | 'ws'
|
||||
basePath?: string
|
||||
port: number
|
||||
name: string
|
||||
username?: string
|
||||
password?: string
|
||||
encryption: boolean
|
||||
certValidation: boolean
|
||||
// selfSignedCertificate?: CertificateParameters
|
||||
// clientCertificate?: CertificateParameters
|
||||
// clientKey?: CertificateParameters
|
||||
clientId?: string
|
||||
subscriptions: Array<string>
|
||||
}
|
||||
|
||||
let migrations: Migration[] = [
|
||||
// iot.eclipse.org ha moved to mqtt.eclipse.org
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptionsV0 => {
|
||||
if (connection.id == 'iot.eclipse.org' && connection.host == 'iot.eclipse.org' && connection.port == 1883) {
|
||||
return {
|
||||
...connection,
|
||||
id: 'mqtt.eclipse.org',
|
||||
host: 'mqtt.eclipse.org',
|
||||
name: 'mqtt.eclipse.org',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...connection,
|
||||
}
|
||||
},
|
||||
},
|
||||
// Remove stored clientId if it is the default generated client id. This allows to connect multiple instances of mqtt explorer to the same broker.
|
||||
// A randomly generated clientId will be used if no clientId is set.
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptionsV0 => {
|
||||
if (connection.clientId && /mqtt-explorer-[0-9a-f]{8}/.test(connection.clientId)) {
|
||||
return {
|
||||
...connection,
|
||||
clientId: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...connection,
|
||||
}
|
||||
},
|
||||
},
|
||||
// Added QoS level to subscription options
|
||||
{
|
||||
from: undefined,
|
||||
apply: (connection: ConnectionOptionsV0): ConnectionOptions => {
|
||||
return {
|
||||
...connection,
|
||||
configVersion: 1,
|
||||
subscriptions: connection.subscriptions.map(topic => ({ topic, qos: 0 })),
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const connectionMigrator = new ConfigMigrator(migrations)
|
||||
|
||||
function isMigrationNecessary(connections: ConnectionDictionary): boolean {
|
||||
return Object.values(connections)
|
||||
.map(connection => connectionMigrator.isMigrationNecessary(connection))
|
||||
.reduce((a, b) => a || b, false)
|
||||
}
|
||||
|
||||
function applyMigrations(connections: ConnectionDictionary): ConnectionDictionary {
|
||||
let newConnectionDictionary: ConnectionDictionary = {}
|
||||
Object.keys(connections).forEach(key => {
|
||||
let newConnection = connectionMigrator.applyMigrations(connections[key]) as any
|
||||
newConnectionDictionary[newConnection.id] = newConnection
|
||||
})
|
||||
|
||||
return newConnectionDictionary
|
||||
}
|
||||
|
||||
export const connectionsMigrator = {
|
||||
isMigrationNecessary,
|
||||
applyMigrations,
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { selectTopic } from './Tree'
|
||||
import { SettingsState } from '../reducers/Settings'
|
||||
import { sortedNodes } from '../sortedNodes'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
|
||||
export const moveSelectionUpOrDownwards =
|
||||
(direction: 'next' | 'previous') =>
|
||||
(dispatch: Dispatch<any>, getState: () => AppState): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
const tree = state.tree.get('tree')
|
||||
|
||||
if (!selected || !tree) {
|
||||
if (tree) {
|
||||
dispatch(selectTopic(tree))
|
||||
}
|
||||
return
|
||||
}
|
||||
const nextTreeNode = nextVisibleElementInTree(state.settings, tree, selected, direction)
|
||||
if (nextTreeNode && nextTreeNode.viewModel) {
|
||||
dispatch(selectTopic(nextTreeNode))
|
||||
}
|
||||
}
|
||||
|
||||
export const moveInward =
|
||||
() =>
|
||||
(dispatch: Dispatch<any>, getState: () => AppState): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
if (!selected || !selected.viewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
|
||||
selected.viewModel.setExpanded(true, true)
|
||||
} else {
|
||||
dispatch(moveSelectionUpOrDownwards('next'))
|
||||
}
|
||||
}
|
||||
|
||||
export const moveOutward =
|
||||
() =>
|
||||
(dispatch: Dispatch<any>, getState: () => AppState): any => {
|
||||
const state = getState()
|
||||
const selected = state.tree.get('selectedTopic')
|
||||
if (!selected || !selected.viewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
if (selected.viewModel.isExpanded() && selected.edgeCount() > 0) {
|
||||
selected.viewModel.setExpanded(false, true)
|
||||
} else {
|
||||
dispatch(moveSelectionUpOrDownwards('previous'))
|
||||
}
|
||||
}
|
||||
|
||||
function isTreeNodeVisible(treeNode: q.TreeNode<any>) {
|
||||
return Boolean(treeNode.viewModel)
|
||||
}
|
||||
|
||||
function nextVisibleElementInTree(
|
||||
settings: SettingsState,
|
||||
tree: q.Tree<TopicViewModel>,
|
||||
node: q.TreeNode<TopicViewModel>,
|
||||
direction: 'next' | 'previous'
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
if (direction === 'next') {
|
||||
return findNextNodeDownward(settings, node)
|
||||
} else {
|
||||
return findNextNodeUpward(settings, node)
|
||||
}
|
||||
}
|
||||
|
||||
/** Not very efficient but easy to implement, complexity should not be an issue here */
|
||||
function findNextNodeUpward(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
|
||||
if (!parent) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const neighborNodes = sortedNodes(settings, parent)
|
||||
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
|
||||
if (nodeIdx === 0) {
|
||||
return parent
|
||||
}
|
||||
const upwardNeighbor = neighborNodes[nodeIdx - 1]
|
||||
if (upwardNeighbor) {
|
||||
return lastVisibleChild(settings, upwardNeighbor)
|
||||
} else {
|
||||
return findNextNodeUpward(settings, parent)
|
||||
}
|
||||
}
|
||||
|
||||
function lastVisibleChild(settings: SettingsState, treeNode: q.TreeNode<TopicViewModel>): q.TreeNode<TopicViewModel> {
|
||||
const nodes = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
|
||||
if (nodes.length === 0) {
|
||||
return treeNode
|
||||
}
|
||||
return lastVisibleChild(settings, nodes[nodes.length - 1])
|
||||
}
|
||||
|
||||
function findNextNodeDownward(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const children = sortedNodes(settings, treeNode).filter(isTreeNodeVisible)
|
||||
const firstChild = children[0]
|
||||
if (firstChild) {
|
||||
return firstChild
|
||||
}
|
||||
|
||||
return findNextNodeDownwardNeighbor(settings, treeNode)
|
||||
}
|
||||
|
||||
function findNextNodeDownwardNeighbor(
|
||||
settings: SettingsState,
|
||||
treeNode: q.TreeNode<TopicViewModel>
|
||||
): q.TreeNode<TopicViewModel> | undefined {
|
||||
const parent = treeNode.sourceEdge && treeNode.sourceEdge.source
|
||||
if (!parent) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const neighborNodes = sortedNodes(settings, parent).filter(isTreeNodeVisible)
|
||||
const nodeIdx = neighborNodes.findIndex(n => n.path() === treeNode.path())
|
||||
const downwardNeighbor = neighborNodes[nodeIdx + 1]
|
||||
if (downwardNeighbor) {
|
||||
return downwardNeighbor
|
||||
} else {
|
||||
return findNextNodeDownwardNeighbor(settings, parent)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { electronRendererTelementry } from 'electron-telemetry'
|
||||
|
||||
const spareMeFromGc = electronRendererTelementry
|
||||
electronRendererTelementry.registerErrorHandler()
|
||||
@@ -0,0 +1,163 @@
|
||||
import ConfirmationDialog from './ConfirmationDialog'
|
||||
import ConnectionSetup from './ConnectionSetup/ConnectionSetup'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import ErrorBoundary from './ErrorBoundary'
|
||||
import Notification from './Layout/Notification'
|
||||
import React from 'react'
|
||||
import TitleBar from './Layout/TitleBar'
|
||||
import UpdateNotifier from './UpdateNotifier'
|
||||
import { AppState } from '../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { ConfirmationRequest } from '../reducers/Global'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions } from '../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
;(window as any).global = window
|
||||
|
||||
const Settings = React.lazy(() => import('./SettingsDrawer/Settings'))
|
||||
const ContentView = React.lazy(() => import('./Layout/ContentView'))
|
||||
|
||||
interface Props {
|
||||
connectionId: string
|
||||
classes: any
|
||||
settingsVisible: boolean
|
||||
error?: string
|
||||
notification?: string
|
||||
actions: typeof globalActions
|
||||
settingsActions: typeof settingsActions
|
||||
launching: boolean
|
||||
confirmationRequests: Array<ConfirmationRequest>
|
||||
}
|
||||
|
||||
class App extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private renderNotification() {
|
||||
const message = this.props.error || this.props.notification
|
||||
const isError = message === this.props.error
|
||||
if (message) {
|
||||
// Guard in case someone ever calls showError with an error instead of a string
|
||||
const str = typeof message === 'string' ? message : JSON.stringify(message)
|
||||
return (
|
||||
<Notification
|
||||
message={str}
|
||||
type={isError ? 'error' : 'notification'}
|
||||
onClose={() => {
|
||||
isError ? this.props.actions.showError(undefined) : this.props.actions.showNotification(undefined)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.props.settingsActions.loadSettings()
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { settingsVisible } = this.props
|
||||
const { content, contentShift, centerContent, paneDefaults, heightProperty } = this.props.classes
|
||||
|
||||
if (this.props.launching) {
|
||||
return null
|
||||
}
|
||||
|
||||
const anyProps: any = {}
|
||||
|
||||
return (
|
||||
<div className={centerContent}>
|
||||
<CssBaseline />
|
||||
<ErrorBoundary>
|
||||
<ConfirmationDialog confirmationRequests={this.props.confirmationRequests} />
|
||||
{this.renderNotification()}
|
||||
<React.Suspense fallback={<div></div>}>
|
||||
<Settings {...anyProps} />
|
||||
</React.Suspense>
|
||||
<div className={centerContent}>
|
||||
<div className={`${settingsVisible ? contentShift : content}`}>
|
||||
<TitleBar />
|
||||
</div>
|
||||
<div className={settingsVisible ? contentShift : content}>
|
||||
<React.Suspense fallback={<div></div>}>
|
||||
<ContentView
|
||||
heightProperty={heightProperty}
|
||||
connectionId={this.props.connectionId}
|
||||
paneDefaults={paneDefaults}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateNotifier />
|
||||
<ConnectionSetup />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => {
|
||||
const drawerWidth = 300
|
||||
const contentBaseStyle = {
|
||||
width: '100vw',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}
|
||||
|
||||
return {
|
||||
heightProperty: {
|
||||
height: '100%', // 'calc(100vh - 64px) !important',
|
||||
},
|
||||
paneDefaults: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
display: 'block' as 'block',
|
||||
height: 'calc(100vh - 64px)',
|
||||
},
|
||||
centerContent: {
|
||||
width: '100vw',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
content: {
|
||||
...contentBaseStyle,
|
||||
transition: theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
transform: 'translateX(0px)',
|
||||
},
|
||||
contentShift: {
|
||||
...contentBaseStyle,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
transition: theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
transform: `translateX(${drawerWidth}px)`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(globalActions, dispatch),
|
||||
settingsActions: bindActionCreators(settingsActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
settingsVisible: state.globalState.get('settingsVisible'),
|
||||
connectionId: state.connection.connectionId,
|
||||
error: state.globalState.get('error'),
|
||||
notification: state.globalState.get('notification'),
|
||||
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
|
||||
launching: state.globalState.get('launching'),
|
||||
confirmationRequests: state.globalState.get('confirmationRequests'),
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(App))
|
||||
@@ -1,132 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../../backend/src/Model'
|
||||
|
||||
import { AppState } from '../reducers'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import { connect } from 'react-redux'
|
||||
import { TopicViewModel } from '../TopicViewModel'
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
interface Stats {
|
||||
topic: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const styles: StyleRulesCallback = theme => ({
|
||||
flex: {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '224px',
|
||||
backgroundColor: 'rebeccapurple',
|
||||
marginBottom: 0,
|
||||
marginTop: 'auto',
|
||||
padding: '8px',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
}
|
||||
|
||||
class BrokerStatistics extends React.Component<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tree, classes } = this.props
|
||||
if (!tree || !tree.findNode('$SYS')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats: any = {
|
||||
broker: {
|
||||
topic: '$SYS/broker/version',
|
||||
title: 'Broker',
|
||||
},
|
||||
clients: {
|
||||
topic: '$SYS/broker/clients/total',
|
||||
title: 'Clients',
|
||||
},
|
||||
subscriptions: {
|
||||
topic: '$SYS/broker/subscriptions/count',
|
||||
title: 'Subscriptions',
|
||||
},
|
||||
received: {
|
||||
topic: '$SYS/broker/messages/received',
|
||||
title: 'Received',
|
||||
},
|
||||
sent: {
|
||||
topic: '$SYS/broker/messages/sent',
|
||||
title: 'Sent',
|
||||
},
|
||||
received5m: {
|
||||
topic: '$SYS/broker/load/messages/received/5min',
|
||||
title: 'Received last 5min',
|
||||
},
|
||||
sent5m: {
|
||||
topic: '$SYS/broker/load/messages/sent/5min',
|
||||
title: 'Sent 5m',
|
||||
},
|
||||
heap: {
|
||||
topic: '$SYS/broker/heap/current',
|
||||
title: 'Memory',
|
||||
},
|
||||
heapMax: {
|
||||
topic: '$SYS/broker/heap/maximum',
|
||||
title: 'Memory (max)',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{this.renderStat(tree, stats.broker)}
|
||||
{this.renderPair(tree, stats.sent, stats.received)}
|
||||
{this.renderPair(tree, stats.clients, stats.subscriptions)}
|
||||
{this.renderPair(tree, stats.sent5m, stats.received5m)}
|
||||
{this.renderPair(tree, stats.heap, stats.heapMax)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
|
||||
return (
|
||||
<div className={this.props.classes.flex}>
|
||||
<div style={{ flex: 1 }}>{this.renderStat(tree, a)}</div>
|
||||
<div style={{ flex: 1 }}>{this.renderStat(tree, b)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
|
||||
const node = tree.findNode(stat.topic)
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
let value = node.message && node.message.value
|
||||
value = !isNaN(value) ? abbreviate(value) : value
|
||||
|
||||
return (
|
||||
<div key={stat.title}>
|
||||
<Typography><b>{stat.title}</b></Typography>
|
||||
<Typography style={{ paddingLeft: '8px' }}><i>{value}</i></Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
tree: state.connection.tree,
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))
|
||||
@@ -0,0 +1,105 @@
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import NoData from './NoData'
|
||||
import NumberFormatter from '../helper/NumberFormatter'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import TooltipComponent from './TooltipComponent'
|
||||
import { default as ReactResizeDetector } from 'react-resize-detector'
|
||||
import { emphasize } from '@material-ui/core/styles'
|
||||
import { mapCurveType } from './mapCurveType'
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
import { Point, Tooltip } from './Model'
|
||||
import { Theme, withTheme } from '@material-ui/core'
|
||||
import { useCustomXDomain } from './effects/useCustomXDomain'
|
||||
import { useCustomYDomain } from './effects/useCustomYDomain'
|
||||
import 'react-vis/dist/style.css'
|
||||
const { XYPlot, LineMarkSeries, YAxis, HorizontalGridLines, Hint } = require('react-vis')
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
export interface Props {
|
||||
data: Array<{ x: number; y: number }>
|
||||
theme: Theme
|
||||
interpolation?: PlotCurveTypes
|
||||
range?: [number?, number?]
|
||||
timeRangeStart?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export default withTheme(
|
||||
memo((props: Props) => {
|
||||
const [width, setWidth] = React.useState(300)
|
||||
const [tooltip, setTooltip] = React.useState<Tooltip | undefined>()
|
||||
const detectResize = React.useCallback(newWidth => setWidth(newWidth), [])
|
||||
|
||||
const hintFormatter = React.useCallback(
|
||||
(point: any) => [
|
||||
{ title: <b>Time</b>, value: <DateFormatter timeFirst={true} date={new Date(point.x)} /> },
|
||||
{ title: <b>Value</b>, value: <NumberFormatter value={point.y} /> },
|
||||
{ title: <b>Raw</b>, value: <span>{point.y}</span> },
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const onMouseLeave = React.useCallback(() => {
|
||||
setTooltip(undefined)
|
||||
}, [])
|
||||
|
||||
const showTooltip = React.useCallback((point: Point, something: { event: MouseEvent }) => {
|
||||
if (!something) {
|
||||
return
|
||||
}
|
||||
setTooltip({ point, value: hintFormatter(point), element: something.event.target as any })
|
||||
}, [])
|
||||
|
||||
const paletteColor =
|
||||
props.theme.palette.type === 'light' ? props.theme.palette.secondary.dark : props.theme.palette.primary.light
|
||||
const color = props.color ? props.color : paletteColor
|
||||
|
||||
const highlightSelectedPoint = useCallback(
|
||||
(point: Point) => {
|
||||
const highlight = tooltip && tooltip.point.x === point.x && tooltip.point.y === point.y
|
||||
return highlight ? emphasize(color, 0.8) : color
|
||||
},
|
||||
[tooltip, color]
|
||||
)
|
||||
|
||||
const formatYAxis = useCallback((num: number) => abbreviate(num), [])
|
||||
|
||||
const xDomain = useCustomXDomain(props)
|
||||
const yDomain = useCustomYDomain(props)
|
||||
|
||||
const data = props.data
|
||||
const hasData = data.length > 0
|
||||
const dummyDomain = [-1, 1]
|
||||
const dummyData = [{ x: -2, y: -2 }]
|
||||
return (
|
||||
<div>
|
||||
<div style={{ height: '150px', width: '100%', position: 'relative' }}>
|
||||
{data.length === 0 ? <NoData /> : null}
|
||||
<XYPlot
|
||||
width={width}
|
||||
height={180}
|
||||
yDomain={hasData ? yDomain : dummyDomain}
|
||||
xDomain={hasData ? xDomain : dummyDomain}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
<HorizontalGridLines />
|
||||
<YAxis width={45} tickFormat={formatYAxis} />
|
||||
<LineMarkSeries
|
||||
color={color}
|
||||
colorType="literal"
|
||||
getColor={highlightSelectedPoint}
|
||||
onValueMouseOver={showTooltip}
|
||||
size={3}
|
||||
data={hasData ? data : dummyData}
|
||||
curve={mapCurveType(props.interpolation)}
|
||||
/>
|
||||
<Hint value={{ x: 0, y: 0 }} style={{ pointerEvents: 'none' }}>
|
||||
<TooltipComponent tooltip={tooltip} theme={props.theme} />
|
||||
</Hint>
|
||||
</XYPlot>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectResize} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Tooltip {
|
||||
value: Array<TooltipRows>
|
||||
point: Point
|
||||
element: Element | null
|
||||
}
|
||||
|
||||
export interface TooltipRows {
|
||||
title: React.ReactElement
|
||||
value: React.ReactElement
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { memo } from 'react'
|
||||
import { Typography } from '@material-ui/core'
|
||||
|
||||
function NoData() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
color: '#ccc',
|
||||
verticalAlign: 'middle',
|
||||
paddingLeft: '30px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Typography style={{ fontWeight: 'bold' }} variant="h5">
|
||||
No Data
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NoData)
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { memo } from 'react'
|
||||
import { fade } from '@material-ui/core/styles'
|
||||
import { Fade, Grow, Paper, Popper, Theme, Typography, withTheme } from '@material-ui/core'
|
||||
import { Tooltip } from './Model'
|
||||
|
||||
function TooltipComponent(props: { tooltip?: Tooltip; theme: Theme }) {
|
||||
const { tooltip } = props
|
||||
return (
|
||||
<Popper
|
||||
style={Boolean(tooltip) ? { transition: 'all 0.1s ease-out' } : undefined}
|
||||
open={Boolean(tooltip)}
|
||||
transition={true}
|
||||
placement="top"
|
||||
anchorEl={tooltip && tooltip.element}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
paddingBottom: '8px',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
<Fade in={Boolean(tooltip)} timeout={300}>
|
||||
<Grow in={Boolean(tooltip)} timeout={300}>
|
||||
<Paper
|
||||
style={{
|
||||
padding: '4px',
|
||||
marginTop: '-12px',
|
||||
backgroundColor: fade(
|
||||
props.theme.palette.type === 'light'
|
||||
? props.theme.palette.background.paper
|
||||
: props.theme.palette.background.default,
|
||||
0.7
|
||||
),
|
||||
}}
|
||||
>
|
||||
<table style={{ lineHeight: '1.25em' }}>
|
||||
<tbody>
|
||||
{tooltip &&
|
||||
tooltip.value.map((v: any, idx: number) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<Typography style={{ lineHeight: '1.2' }}>{v.title}</Typography>
|
||||
</td>
|
||||
<td>
|
||||
<Typography style={{ lineHeight: '1.2' }}>{v.value}</Typography>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Paper>
|
||||
</Grow>
|
||||
</Fade>
|
||||
</div>
|
||||
</Popper>
|
||||
)
|
||||
}
|
||||
|
||||
export default withTheme(memo(TooltipComponent))
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Props } from '../Chart'
|
||||
|
||||
export function useCustomXDomain(props: Props): [number, number] | undefined {
|
||||
return useMemo(() => {
|
||||
const lastDataPoint = [...props.data].sort((a, b) => b.x - a.x)[0]
|
||||
const lastDataDate = lastDataPoint ? lastDataPoint.x : Date.now()
|
||||
return props.timeRangeStart ? [Date.now() - props.timeRangeStart, lastDataDate] : undefined
|
||||
}, [props.data, props.timeRangeStart])
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Props } from '../Chart'
|
||||
import { useMemo } from 'react'
|
||||
import { Point } from '../Model'
|
||||
|
||||
function defaultFor(a: number | undefined, b: number) {
|
||||
return a === undefined ? b : a
|
||||
}
|
||||
|
||||
export function useCustomYDomain(props: Props) {
|
||||
return useMemo(() => {
|
||||
const data = props.data
|
||||
const calculatedDomain = domainForData(data)
|
||||
const yDomain: [number, number] = props.range
|
||||
? [defaultFor(props.range[0], calculatedDomain[0]), defaultFor(props.range[1], calculatedDomain[1])]
|
||||
: calculatedDomain
|
||||
|
||||
return yDomain
|
||||
}, [props.data, props.range])
|
||||
}
|
||||
|
||||
function domainForData(data: Array<Point>): [number, number] {
|
||||
if (!data[0]) {
|
||||
const defaultDomain: [number, number] = [-1, 1]
|
||||
return defaultDomain
|
||||
}
|
||||
|
||||
let max = data[0].y
|
||||
let min = data[0].y
|
||||
|
||||
data.forEach(d => {
|
||||
if (max < d.y) {
|
||||
max = d.y
|
||||
}
|
||||
if (min > d.y) {
|
||||
min = d.y
|
||||
}
|
||||
})
|
||||
if ((max === 1 || max === 0) && (min === 1 || min === 0)) {
|
||||
return [0, 1]
|
||||
}
|
||||
if (min === max) {
|
||||
return [min - 0.5 * min, min + 0.5 * min]
|
||||
}
|
||||
return [min, max]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PlotCurveTypes } from '../../reducers/Charts'
|
||||
export function mapCurveType(type: PlotCurveTypes | undefined) {
|
||||
switch (type) {
|
||||
case 'curve':
|
||||
return 'curveMonotoneX'
|
||||
case 'linear':
|
||||
return 'curveLinear'
|
||||
case 'cubic_basis_spline':
|
||||
return 'curveBasis'
|
||||
case 'step_after':
|
||||
return 'curveStepAfter'
|
||||
case 'step_before':
|
||||
return 'curveStepBefore'
|
||||
default:
|
||||
return 'curveMonotoneX'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useRef } from 'react'
|
||||
import Play from '@material-ui/icons/PlayArrow'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Clear from '@material-ui/icons/Clear'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { SettingsButton } from './ChartSettings/SettingsButton'
|
||||
|
||||
export function ChartActions(props: {
|
||||
paused: boolean
|
||||
togglePause: () => void
|
||||
parameters: ChartParameters
|
||||
onRemove: () => void
|
||||
resetDataAction: () => void
|
||||
}) {
|
||||
const menuAnchor = useRef()
|
||||
return (
|
||||
<div style={{ display: 'flex' }}>
|
||||
<CustomIconButton tooltip={props.paused ? 'Resume chart' : 'Pause chart'} onClick={props.togglePause}>
|
||||
{props.paused ? <Play /> : <Pause />}
|
||||
</CustomIconButton>
|
||||
<SettingsButton menuAnchor={menuAnchor} parameters={props.parameters} resetDataAction={props.resetDataAction} />
|
||||
<CustomIconButton tooltip="Remove chart" onClick={props.onRemove}>
|
||||
<Clear data-test-type="RemoveChart" data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`} />
|
||||
</CustomIconButton>
|
||||
<div style={{ width: 0, overflow: 'hidden' }}>
|
||||
{/* Helper element to provide an anchor element for the menu,
|
||||
* so the menu prefers not to overlap with the chart
|
||||
*/}
|
||||
<div style={{ marginLeft: '11px' }} ref={menuAnchor as any} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { memo } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Menu, MenuItem } from '@material-ui/core'
|
||||
import { colors as createColors } from './colors'
|
||||
|
||||
function chartParametersForColor(chart: ChartParameters, color?: string) {
|
||||
return {
|
||||
color,
|
||||
topic: chart.topic,
|
||||
dotPath: chart.dotPath,
|
||||
}
|
||||
}
|
||||
|
||||
const colors: Array<string> = createColors()
|
||||
|
||||
function ColorSettings(props: {
|
||||
chart: ChartParameters
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
close: () => void
|
||||
}) {
|
||||
const setColor = React.useCallback(
|
||||
(color?: string) => props.actions.chart.updateChart(chartParametersForColor(props.chart, color)),
|
||||
[props.chart]
|
||||
)
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
return colors.map(color => (
|
||||
<MenuItem
|
||||
style={{ minWidth: '8em', minHeight: '36px', backgroundColor: color, textAlign: 'center' }}
|
||||
key={color}
|
||||
onClick={() => setColor(color)}
|
||||
>
|
||||
{props.chart.color === color ? 'X' : ''}
|
||||
</MenuItem>
|
||||
))
|
||||
}, [colors, props.chart])
|
||||
|
||||
return (
|
||||
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
|
||||
<MenuItem
|
||||
style={{ minWidth: '8em', minHeight: '36px', textAlign: 'center' }}
|
||||
key="none"
|
||||
onClick={() => setColor()}
|
||||
selected={props.chart.color === undefined}
|
||||
>
|
||||
default
|
||||
</MenuItem>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(memo(ColorSettings))
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters, PlotCurveTypes } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Menu, MenuItem, Typography } from '@material-ui/core'
|
||||
|
||||
function chartParametersForAction(chart: ChartParameters, action: string) {
|
||||
return {
|
||||
topic: chart.topic,
|
||||
dotPath: chart.dotPath,
|
||||
interpolation: action as any,
|
||||
}
|
||||
}
|
||||
|
||||
const curves: Array<PlotCurveTypes> = ['curve', 'linear', 'step_after', 'step_before', 'cubic_basis_spline']
|
||||
|
||||
function InterpolationSettings(props: {
|
||||
chart: ChartParameters
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
close: () => void
|
||||
}) {
|
||||
const callbacks = React.useMemo(() => {
|
||||
const createCurveCallback = (curve: PlotCurveTypes) => () => {
|
||||
props.actions.chart.updateChart(chartParametersForAction(props.chart, curve))
|
||||
}
|
||||
|
||||
const callbacks: { [key: string]: () => void } = {}
|
||||
for (const curve of curves) {
|
||||
callbacks[curve] = createCurveCallback(curve)
|
||||
}
|
||||
return callbacks
|
||||
}, [curves])
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
return curves.map(curve => (
|
||||
<MenuItem key={curve} onClick={callbacks[curve]} selected={props.chart.interpolation === curve}>
|
||||
<Typography variant="inherit">{curve.replace(/_/g, ' ')}</Typography>
|
||||
</MenuItem>
|
||||
))
|
||||
}, [curves, props.chart])
|
||||
|
||||
return (
|
||||
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
|
||||
{menuItems}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(InterpolationSettings)
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import ArrowUpward from '@material-ui/icons/ArrowUpward'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { MenuItem, Typography, ListItemIcon } from '@material-ui/core'
|
||||
|
||||
function MoveUp(props: { actions: { chart: typeof chartActions }; chart: ChartParameters; close: () => void }) {
|
||||
const moveUp = React.useCallback(() => {
|
||||
props.actions.chart.moveChartUp({
|
||||
topic: props.chart.topic,
|
||||
dotPath: props.chart.dotPath,
|
||||
})
|
||||
props.close()
|
||||
}, [props.chart])
|
||||
|
||||
return (
|
||||
<MenuItem key="size" onClick={moveUp}>
|
||||
<ListItemIcon>
|
||||
<ArrowUpward />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Move up</Typography>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(MoveUp)
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useCallback, useState, ChangeEvent, MouseEvent, useRef, useEffect, useMemo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { KeyCodes } from '../../../utils/KeyCodes'
|
||||
|
||||
interface Props {
|
||||
actions: { chart: typeof chartActions }
|
||||
chart: ChartParameters
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function RangeSettings(props: Props) {
|
||||
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
|
||||
const [rangeFrom, setRangeFrom] = useState<string | number | undefined>(props.chart.range && props.chart.range.from)
|
||||
const [rangeTo, setRangeTo] = useState<string | number | undefined>(props.chart.range && props.chart.range.to)
|
||||
useRangeStateToFireUpdateAction(rangeFrom, rangeTo, props)
|
||||
const rangeFromRef = useRef<HTMLInputElement>()
|
||||
const rangeToRef = useRef<HTMLInputElement>()
|
||||
|
||||
useEffect(() => {
|
||||
rangeFromRef.current && rangeFromRef.current.focus()
|
||||
}, [props.open])
|
||||
|
||||
const handleKeyEvents = (e: React.KeyboardEvent<any>) => {
|
||||
if (e.keyCode === KeyCodes.tab) {
|
||||
// Switch focus between those two
|
||||
if (document.activeElement === rangeFromRef.current) {
|
||||
rangeToRef.current && rangeToRef.current.focus()
|
||||
} else {
|
||||
rangeFromRef.current && rangeFromRef.current.focus()
|
||||
}
|
||||
|
||||
// Prevent closing the menu
|
||||
e.stopPropagation()
|
||||
// Prevent default tab behavior (focus/blur)
|
||||
e.preventDefault()
|
||||
} else if (e.keyCode === KeyCodes.enter) {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const setFromHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeFrom(e.target.value), [])
|
||||
const setToHandler = useCallback((e: ChangeEvent<HTMLInputElement>) => setRangeTo(e.target.value), [])
|
||||
return useMemo(
|
||||
() => (
|
||||
<Menu
|
||||
style={{ textAlign: 'center' }}
|
||||
keepMounted={true}
|
||||
anchorEl={props.anchorEl}
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
onKeyDownCapture={handleKeyEvents}
|
||||
>
|
||||
<div style={{ padding: '0 16px', width: '275px' }}>
|
||||
<Typography>Define custom ranges for the Y-Axis</Typography>
|
||||
<TextField
|
||||
inputProps={{
|
||||
ref: rangeFromRef,
|
||||
}}
|
||||
autoFocus={true}
|
||||
style={{ marginTop: '0' }}
|
||||
label="from"
|
||||
value={rangeFrom}
|
||||
onChange={setFromHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
inputProps={{
|
||||
ref: rangeToRef,
|
||||
}}
|
||||
style={{ marginLeft: '8px', marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="to"
|
||||
value={rangeTo}
|
||||
onChange={setToHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
),
|
||||
[rangeFrom, rangeTo, props.open]
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(RangeSettings)
|
||||
|
||||
function useRangeStateToFireUpdateAction(
|
||||
rangeFrom: string | number | undefined,
|
||||
rangeTo: string | number | undefined,
|
||||
props: Props
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const from = parseFloat(rangeFrom as any)
|
||||
const to = parseFloat(rangeTo as any)
|
||||
props.actions.chart.updateChart({
|
||||
topic: props.chart.topic,
|
||||
dotPath: props.chart.dotPath,
|
||||
range: {
|
||||
from: isNaN(from) ? undefined : from,
|
||||
to: isNaN(to) ? undefined : to,
|
||||
},
|
||||
})
|
||||
}, [rangeFrom, rangeTo])
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import ChartSettings from '.'
|
||||
import CustomIconButton from '../../helper/CustomIconButton'
|
||||
import MoreVertIcon from '@material-ui/icons/Settings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
|
||||
export function SettingsButton(props: {
|
||||
parameters: ChartParameters
|
||||
resetDataAction: () => void
|
||||
menuAnchor: React.MutableRefObject<undefined>
|
||||
}) {
|
||||
const [visible, setVisible] = React.useState(false)
|
||||
const toggleSettings = React.useCallback(() => {
|
||||
setVisible(!visible)
|
||||
}, [visible])
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setVisible(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<ChartSettings
|
||||
open={visible}
|
||||
close={close}
|
||||
anchorEl={props.menuAnchor}
|
||||
chart={props.parameters}
|
||||
resetDataAction={props.resetDataAction}
|
||||
/>
|
||||
<CustomIconButton tooltip="Chart settings" onClick={toggleSettings}>
|
||||
<MoreVertIcon
|
||||
data-test-type="ChartSettings"
|
||||
data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`}
|
||||
/>
|
||||
</CustomIconButton>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { memo } from 'react'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, TextField, Typography } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
|
||||
function Size(props: {
|
||||
actions: { chart: typeof chartActions }
|
||||
chart: ChartParameters
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
close: () => void
|
||||
}) {
|
||||
const setChartWidth = (width?: 'big' | 'medium' | 'small') => () => {
|
||||
props.actions.chart.updateChart({
|
||||
width,
|
||||
topic: props.chart.topic,
|
||||
dotPath: props.chart.dotPath,
|
||||
})
|
||||
props.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu anchorEl={props.anchorEl} open={props.open} onClose={props.close}>
|
||||
<MenuItem selected={props.chart.width === undefined} onClick={setChartWidth()}>
|
||||
<Typography variant="inherit">auto</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'big'} onClick={setChartWidth('big')}>
|
||||
<Typography variant="inherit">100% width</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'medium'} onClick={setChartWidth('medium')}>
|
||||
<Typography variant="inherit">50% width</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem selected={props.chart.width === 'small'} onClick={setChartWidth('small')}>
|
||||
<Typography variant="inherit">33% width</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(memo(Size))
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { ChangeEvent, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Menu, TextField, Typography } from '@material-ui/core'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
const parseDuration = require('parse-duration')
|
||||
|
||||
interface Props {
|
||||
actions: { chart: typeof chartActions }
|
||||
chart: ChartParameters
|
||||
anchorEl?: Element
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function TimeRangeSettings(props: Props) {
|
||||
const dismissClick = useCallback((e: MouseEvent) => e.stopPropagation(), [])
|
||||
const [value, setValue] = useState<string | undefined>(
|
||||
props.chart.timeRange ? props.chart.timeRange.until : undefined
|
||||
)
|
||||
const ranges = ['all', '10s', '30s', '1m', '5m', '15m', '1h', '6h', '1d']
|
||||
|
||||
const manuallySetIntervalHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
props.actions.chart.updateChart({
|
||||
...props.chart,
|
||||
timeRange: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const canBeParsed = Boolean(parseDuration(value))
|
||||
if (canBeParsed) {
|
||||
props.actions.chart.updateChart({
|
||||
...props.chart,
|
||||
timeRange: {
|
||||
until: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return useMemo(() => {
|
||||
const createRangeHandler = (range: string) => (e: React.MouseEvent) => setValue(range === 'all' ? undefined : range)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
style={{ textAlign: 'center' }}
|
||||
keepMounted={true}
|
||||
anchorEl={props.anchorEl}
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<Typography>Chart data within a time interval</Typography>
|
||||
<div style={{ padding: '0 16px', width: '275px', textAlign: 'center' }}>
|
||||
{ranges.map(r => {
|
||||
return (
|
||||
<Button
|
||||
style={{ margin: '4px', textTransform: 'none' }}
|
||||
variant="contained"
|
||||
key={r}
|
||||
onClick={createRangeHandler(r)}
|
||||
>
|
||||
{r}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Typography style={{ fontSize: '0.75em' }}>
|
||||
<i>Limited to 500 data points</i>
|
||||
</Typography>
|
||||
<br />
|
||||
<TextField
|
||||
style={{ marginLeft: '8px', marginTop: '0' }}
|
||||
onClick={dismissClick}
|
||||
label="custom interval"
|
||||
value={value || ''}
|
||||
onChange={manuallySetIntervalHandler}
|
||||
margin="normal"
|
||||
/>
|
||||
</Menu>
|
||||
)
|
||||
}, [value, props.open])
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(TimeRangeSettings)
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
amber,
|
||||
orange,
|
||||
pink,
|
||||
purple,
|
||||
deepPurple,
|
||||
teal,
|
||||
red,
|
||||
green,
|
||||
lime,
|
||||
indigo,
|
||||
yellow,
|
||||
brown,
|
||||
blueGrey,
|
||||
} from '@material-ui/core/colors'
|
||||
|
||||
export function colors() {
|
||||
function colorToInt(color: string): [number, number, number] {
|
||||
const str = color.replace('#', '')
|
||||
return [parseInt(str.slice(0, 2), 16), parseInt(str.slice(2, 4), 16), parseInt(str.slice(4, 6), 16)]
|
||||
}
|
||||
function colorCompare(colorA: string, colorB: string) {
|
||||
const a = colorToInt(colorA)
|
||||
const b = colorToInt(colorB)
|
||||
return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2))
|
||||
}
|
||||
const colors: Array<string> = [
|
||||
brown,
|
||||
blueGrey,
|
||||
amber,
|
||||
orange,
|
||||
pink,
|
||||
purple,
|
||||
deepPurple,
|
||||
teal,
|
||||
red,
|
||||
green,
|
||||
lime,
|
||||
indigo,
|
||||
yellow,
|
||||
]
|
||||
.map(color => [color[200], color[500], color[700]])
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
.sort((a, b) => colorCompare(a, b))
|
||||
return colors
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import BarChart from '@material-ui/icons/BarChart'
|
||||
import Clear from '@material-ui/icons/Refresh'
|
||||
import ColorLens from '@material-ui/icons/ColorLens'
|
||||
import ColorSettings from './ColorSettings'
|
||||
import InterpolationSettings from './InterpolationSettings'
|
||||
import MoveUp from './MoveUp'
|
||||
import MultilineChart from '@material-ui/icons/MultilineChart'
|
||||
import RangeSettings from './RangeSettings'
|
||||
import React, { memo } from 'react'
|
||||
import Size from './Size'
|
||||
import Sort from '@material-ui/icons/Sort'
|
||||
import TimeRangeSettings from './TimeRangeSettings'
|
||||
import { ChartParameters } from '../../../reducers/Charts'
|
||||
import { Menu, MenuItem, ListItemIcon, Typography } from '@material-ui/core'
|
||||
|
||||
function ChartSettings(props: {
|
||||
open: boolean
|
||||
close: () => void
|
||||
resetDataAction: () => void
|
||||
chart: ChartParameters
|
||||
anchorEl: React.MutableRefObject<undefined>
|
||||
}) {
|
||||
const [rangeVisible, setRangeVisible] = React.useState(false)
|
||||
const [timeRangeVisible, setTimeRangeVisible] = React.useState(false)
|
||||
const [interpolationVisible, setInterpolationVisible] = React.useState(false)
|
||||
const [sizeVisible, setSizeVisible] = React.useState(false)
|
||||
const [colorVisible, setColorVisible] = React.useState(false)
|
||||
const open = props.open
|
||||
|
||||
const toggleRange = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setRangeVisible(!rangeVisible)
|
||||
}, [rangeVisible, open])
|
||||
|
||||
const toggleTimeRange = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setTimeRangeVisible(!timeRangeVisible)
|
||||
}, [timeRangeVisible, open])
|
||||
|
||||
const toggleInterpolation = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setInterpolationVisible(!interpolationVisible)
|
||||
}, [interpolationVisible, open])
|
||||
|
||||
const toggleSize = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setSizeVisible(!sizeVisible)
|
||||
}, [sizeVisible, open])
|
||||
|
||||
const toggleColor = React.useCallback(() => {
|
||||
if (open) {
|
||||
props.close()
|
||||
}
|
||||
setColorVisible(!colorVisible)
|
||||
}, [colorVisible, open])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Menu id="long-menu" anchorEl={props.anchorEl.current} open={props.open} onClose={props.close}>
|
||||
<MenuItem key="range" onClick={toggleRange}>
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Y-Axis range (Values)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="timeRange" onClick={toggleTimeRange}>
|
||||
<ListItemIcon>
|
||||
<BarChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">X-Axis range (Time)</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="interpolation" onClick={toggleInterpolation}>
|
||||
<ListItemIcon>
|
||||
<MultilineChart />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Curve interpolation</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="size" onClick={toggleSize}>
|
||||
<ListItemIcon>
|
||||
<Sort />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Size</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="color" onClick={toggleColor}>
|
||||
<ListItemIcon>
|
||||
<ColorLens />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Color</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem key="clear" onClick={props.resetDataAction}>
|
||||
<ListItemIcon>
|
||||
<Clear />
|
||||
</ListItemIcon>
|
||||
<Typography variant="inherit">Clear data</Typography>
|
||||
</MenuItem>
|
||||
<MoveUp chart={props.chart} close={props.close} />
|
||||
</Menu>
|
||||
<RangeSettings chart={props.chart} anchorEl={props.anchorEl.current} open={rangeVisible} onClose={toggleRange} />
|
||||
<TimeRangeSettings
|
||||
chart={props.chart}
|
||||
anchorEl={props.anchorEl.current}
|
||||
open={timeRangeVisible}
|
||||
onClose={toggleTimeRange}
|
||||
/>
|
||||
<InterpolationSettings
|
||||
chart={props.chart}
|
||||
anchorEl={props.anchorEl.current}
|
||||
open={interpolationVisible}
|
||||
close={toggleInterpolation}
|
||||
/>
|
||||
<Size chart={props.chart} anchorEl={props.anchorEl.current} open={sizeVisible} close={toggleSize} />
|
||||
<ColorSettings chart={props.chart} anchorEl={props.anchorEl.current} open={colorVisible} close={toggleColor} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ChartSettings)
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { Typography, Theme, withStyles } from '@material-ui/core'
|
||||
|
||||
function ChartTitle(props: { parameters: ChartParameters; classes: any }) {
|
||||
const { classes, parameters } = props
|
||||
return (
|
||||
<div style={{ flexGrow: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
<Typography variant="caption" className={classes.topic}>
|
||||
{parameters.dotPath ? parameters.dotPath : parameters.topic}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" className={classes.topic}>
|
||||
{parameters.dotPath ? parameters.topic : <span dangerouslySetInnerHTML={{ __html: ' ' }}></span>}
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
topic: {
|
||||
wordBreak: 'break-all' as 'break-all',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(ChartTitle)
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React from 'react'
|
||||
import TopicChart from './TopicChart'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
|
||||
interface Props {
|
||||
tree?: q.Tree<any>
|
||||
parameters: ChartParameters
|
||||
}
|
||||
|
||||
export function ChartWithTreeNode(props: Props) {
|
||||
const { tree, parameters } = props
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const treeNode = usePollingToFetchTreeNode(tree, parameters.topic)
|
||||
return <TopicChart treeNode={treeNode} parameters={parameters} />
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import ChartTitle from './ChartTitle'
|
||||
import React, { useState, useCallback, memo, useRef } from 'react'
|
||||
import TopicPlot from '../TopicPlot'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { ChartActions } from './ChartActions'
|
||||
import { chartActions } from '../../actions'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { Paper } from '@material-ui/core'
|
||||
const throttle = require('lodash.throttle')
|
||||
|
||||
class ClearableMessageBuffer extends q.RingBuffer<q.Message> {
|
||||
public clear() {
|
||||
this.items = []
|
||||
this.start = 0
|
||||
this.end = 0
|
||||
}
|
||||
|
||||
public static fromMessageBuffer(buffer: q.RingBuffer<q.Message>): ClearableMessageBuffer {
|
||||
return new ClearableMessageBuffer(buffer.capacity, buffer.maxItems, buffer.compactionFactor, buffer)
|
||||
}
|
||||
|
||||
public clone(): ClearableMessageBuffer {
|
||||
return ClearableMessageBuffer.fromMessageBuffer(this)
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
parameters: ChartParameters
|
||||
treeNode?: q.TreeNode<any>
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to onMessages, stores more data points then the default
|
||||
*/
|
||||
function useMessageSubscriptionToUpdate(treeNode?: q.TreeNode<any>) {
|
||||
const [lastUpdated, setLastUpdate] = useState(0)
|
||||
const [messageHistory, setMessageHistory] = useState<ClearableMessageBuffer | undefined>()
|
||||
let amendMessageCallback: any
|
||||
|
||||
function subscribeToMessageUpdates() {
|
||||
const throttledUpdate = throttle(() => setLastUpdate(treeNode ? treeNode.lastUpdate : 0), 300)
|
||||
|
||||
if (treeNode) {
|
||||
const newMessageHistory = ClearableMessageBuffer.fromMessageBuffer(treeNode.messageHistory)
|
||||
newMessageHistory.setCapacity(500, 2 * 500 * 10000)
|
||||
|
||||
amendMessageCallback = (message: q.Message) => {
|
||||
newMessageHistory.add(message)
|
||||
throttledUpdate()
|
||||
}
|
||||
treeNode.onMessage.subscribe(amendMessageCallback)
|
||||
setMessageHistory(newMessageHistory)
|
||||
}
|
||||
|
||||
return function cleanup() {
|
||||
treeNode && treeNode.onMessage.unsubscribe(amendMessageCallback)
|
||||
setMessageHistory(undefined)
|
||||
}
|
||||
}
|
||||
React.useEffect(subscribeToMessageUpdates, [treeNode])
|
||||
|
||||
return messageHistory
|
||||
}
|
||||
|
||||
function useResetDataCallback(messageHistory: ClearableMessageBuffer | undefined) {
|
||||
const [lastUpdated, setLastUpdate] = useState(0)
|
||||
|
||||
return React.useCallback(() => {
|
||||
messageHistory && messageHistory.clear()
|
||||
setLastUpdate(Date.now())
|
||||
}, [messageHistory])
|
||||
}
|
||||
|
||||
function TopicChart(props: Props) {
|
||||
const { parameters, treeNode } = props
|
||||
const [frozenHistory, setFrozenHistory] = useState<q.MessageHistory | undefined>()
|
||||
const messageHistory = useMessageSubscriptionToUpdate(treeNode)
|
||||
|
||||
const togglePause = useCallback(() => {
|
||||
if (!treeNode) {
|
||||
return
|
||||
}
|
||||
|
||||
setFrozenHistory(frozenHistory ? undefined : messageHistory && messageHistory.clone())
|
||||
}, [props.treeNode, frozenHistory, messageHistory])
|
||||
|
||||
const onRemove = React.useCallback(() => {
|
||||
props.actions.chart.removeChart(props.parameters)
|
||||
}, [props.parameters])
|
||||
|
||||
const resetData = useResetDataCallback(messageHistory)
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{ padding: '8px' }}
|
||||
data-test-type="ChartPaper"
|
||||
data-test={`${props.parameters.topic}-${props.parameters.dotPath || ''}`}
|
||||
>
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ display: 'flex', flexGrow: 1, overflow: 'hidden' }}>
|
||||
<ChartTitle parameters={parameters} />
|
||||
<ChartActions
|
||||
resetDataAction={resetData}
|
||||
parameters={parameters}
|
||||
onRemove={onRemove}
|
||||
paused={Boolean(frozenHistory)}
|
||||
togglePause={togglePause}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TopicPlot
|
||||
color={props.parameters.color}
|
||||
interpolation={props.parameters.interpolation}
|
||||
timeInterval={props.parameters.timeRange ? props.parameters.timeRange.until : undefined}
|
||||
range={props.parameters.range ? [props.parameters.range.from, props.parameters.range.to] : undefined}
|
||||
history={frozenHistory || messageHistory || new ClearableMessageBuffer(1)}
|
||||
dotPath={parameters.dotPath}
|
||||
/>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(memo(TopicChart))
|
||||
@@ -0,0 +1,129 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ShowChart from '@material-ui/icons/ShowChart'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../actions'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { ChartWithTreeNode } from './ChartWithTreeNode'
|
||||
import { connect } from 'react-redux'
|
||||
import { Grid, Theme, Typography, withStyles } from '@material-ui/core'
|
||||
import { List } from 'immutable'
|
||||
const { TransitionGroup, CSSTransition } = require('react-transition-group/esm')
|
||||
|
||||
interface Props {
|
||||
charts: List<ChartParameters>
|
||||
connectionId?: string
|
||||
tree?: q.Tree<any>
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
classes: any
|
||||
}
|
||||
|
||||
function spacingForChartCount(count: number): 4 | 6 | 12 {
|
||||
if (count >= 5) {
|
||||
return 4
|
||||
} else if (count >= 2) {
|
||||
return 6
|
||||
} else {
|
||||
return 12
|
||||
}
|
||||
}
|
||||
|
||||
function mapWidth(width: 'big' | 'medium' | 'small' | undefined, calculatedSpacing: 4 | 6 | 12): 4 | 6 | 12 {
|
||||
switch (width) {
|
||||
case 'big':
|
||||
return 12
|
||||
case 'medium':
|
||||
return 6
|
||||
case 'small':
|
||||
return 4
|
||||
default:
|
||||
return calculatedSpacing
|
||||
}
|
||||
}
|
||||
|
||||
function ChartPanel(props: Props) {
|
||||
const chartsInView = props.charts.count()
|
||||
|
||||
const [spacing, setSpacing] = React.useState(spacingForChartCount(chartsInView))
|
||||
|
||||
React.useEffect(() => {
|
||||
props.actions.chart.loadCharts()
|
||||
}, [props.connectionId])
|
||||
|
||||
// Update spacing after animations have completed
|
||||
React.useEffect(() => {
|
||||
const newSpacing = spacingForChartCount(chartsInView)
|
||||
if (newSpacing > spacing) {
|
||||
setTimeout(() => setSpacing(newSpacing), 500)
|
||||
} else {
|
||||
setSpacing(newSpacing)
|
||||
}
|
||||
}, [chartsInView])
|
||||
|
||||
const charts = props.charts.map(chartParameters => (
|
||||
<CSSTransition
|
||||
key={`${chartParameters.topic}-${chartParameters.dotPath || ''}`}
|
||||
timeout={{ enter: 500, exit: 500 }}
|
||||
classNames="example"
|
||||
>
|
||||
<Grid item xs={mapWidth(chartParameters.width, spacing)}>
|
||||
<ChartWithTreeNode tree={props.tree} parameters={chartParameters} />
|
||||
</Grid>
|
||||
</CSSTransition>
|
||||
))
|
||||
|
||||
return (
|
||||
<div className={props.classes.container}>
|
||||
<Grid container spacing={1}>
|
||||
<TransitionGroup component={null} className="example">
|
||||
{charts}
|
||||
</TransitionGroup>
|
||||
{chartsInView === 0 ? <NoCharts key="noCharts" /> : null}
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoCharts() {
|
||||
return (
|
||||
<div style={{ width: '100%', textAlign: 'center' }}>
|
||||
<Typography variant="h2">No charts selected</Typography>
|
||||
<Typography>Select a numeric values from the value preview.</Typography>
|
||||
<Typography>
|
||||
Click on <ShowChart /> to add a topic / value to this panel.
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
charts: state.charts.get('charts'),
|
||||
connectionId: state.connection.connectionId,
|
||||
tree: state.connection.tree,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
flex: 1,
|
||||
overflow: 'hidden scroll',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ChartPanel))
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useRef, useCallback, memo } from 'react'
|
||||
import { ConfirmationRequest } from '../reducers/Global'
|
||||
import { Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, Button } from '@material-ui/core'
|
||||
import { KeyCodes } from '../utils/KeyCodes'
|
||||
|
||||
function ConfirmationDialog(props: { confirmationRequests: Array<ConfirmationRequest> }) {
|
||||
const request = props.confirmationRequests[0]
|
||||
const yesRef = useRef<HTMLButtonElement>()
|
||||
const noRef = useRef<HTMLButtonElement>()
|
||||
const arrowKeyHandler = useCallback((event: React.KeyboardEvent) => {
|
||||
const isArrowKey = event.keyCode === KeyCodes.arrow_left || event.keyCode === KeyCodes.arrow_right
|
||||
if (!isArrowKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.stopPropagation()
|
||||
if (document.activeElement === noRef.current) {
|
||||
yesRef.current && yesRef.current.focus()
|
||||
} else {
|
||||
noRef.current && noRef.current.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const confirm = React.useCallback(() => {
|
||||
request && request.callback(true)
|
||||
}, [request])
|
||||
const reject = React.useCallback(() => {
|
||||
request && request.callback(false)
|
||||
}, [request])
|
||||
|
||||
if (!request) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onClose={reject}
|
||||
aria-labelledby="alert-dialog-title"
|
||||
aria-describedby="alert-dialog-description"
|
||||
onKeyDown={arrowKeyHandler}
|
||||
>
|
||||
<DialogTitle id="alert-dialog-title">{request.title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{request.inquiry}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button ref={yesRef as any} variant="contained" onClick={confirm} color="primary" autoFocus>
|
||||
Yes
|
||||
</Button>
|
||||
<Button ref={noRef as any} variant="contained" onClick={reject} color="secondary" style={{ marginLeft: '8px' }}>
|
||||
No
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ConfirmationDialog)
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useCallback, memo } from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Button, Grid, TextField, Tooltip } from '@material-ui/core'
|
||||
import { QosSelect } from '../QosSelect'
|
||||
import { QoS } from '../../../../backend/src/DataSource/MqttSource'
|
||||
import Subscriptions from './Subscriptions'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
classes: any
|
||||
managerActions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
const ConnectionSettings = memo(function ConnectionSettings(props: Props) {
|
||||
const [qos, setQos] = useState<QoS>(0)
|
||||
const [topic, setTopic] = useState('')
|
||||
const { classes } = props
|
||||
|
||||
const updateSubscription = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => setTopic(event.target.value),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(name: string) => (event: any) => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
[name]: event.target.value,
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={8} className={classes.gridPadding}>
|
||||
<TextField
|
||||
className={`${classes.fullWidth} advanced-connection-settings-topic-input`}
|
||||
label="Topic"
|
||||
placeholder="example/topic"
|
||||
margin="normal"
|
||||
value={topic}
|
||||
onChange={updateSubscription}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<div className={classes.qos}>
|
||||
<QosSelect label="QoS" selected={qos} onChange={setQos} />
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<Button
|
||||
className={classes.button}
|
||||
color="secondary"
|
||||
onClick={() => props.managerActions.addSubscription({ topic, qos }, props.connection.id)}
|
||||
variant="contained"
|
||||
>
|
||||
<Add /> Add
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} style={{ padding: 0 }}>
|
||||
<Subscriptions connection={props.connection} />
|
||||
</Grid>
|
||||
<Grid item={true} xs={7} className={classes.gridPadding}>
|
||||
<TextField
|
||||
className={classes.fullWidth}
|
||||
label="MQTT Client ID"
|
||||
margin="normal"
|
||||
value={props.connection.clientId}
|
||||
onChange={handleChange('clientId')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3} className={classes.gridPadding}>
|
||||
<div>
|
||||
<Tooltip title="Manage tls connection certificates" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => props.managerActions.toggleCertificateSettings()}
|
||||
>
|
||||
<Lock /> Certificates
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
gridPadding: {
|
||||
padding: '0 12px !important',
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
float: 'right' as 'right',
|
||||
},
|
||||
qos: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Lock from '@material-ui/icons/Lock'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
|
||||
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
function CertificateFileSelection(props: {
|
||||
certificateType: CertificateTypes
|
||||
title: string
|
||||
certificate?: CertificateParameters
|
||||
classes: any
|
||||
actions: {
|
||||
connectionManager: typeof connectionManagerActions
|
||||
}
|
||||
connection: ConnectionOptions
|
||||
}) {
|
||||
const clearCertificate = React.useCallback(() => {
|
||||
props.actions.connectionManager.updateConnection(props.connection.id, {
|
||||
[props.certificateType]: undefined,
|
||||
})
|
||||
}, [props.connection, props.certificateType])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Tooltip title="Select certificate" placement="top">
|
||||
<Button
|
||||
variant="contained"
|
||||
className={props.classes.button}
|
||||
onClick={() => props.actions.connectionManager.selectCertificate(props.certificateType, props.connection.id)}
|
||||
>
|
||||
<Lock /> {props.title}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<ClearCertificate classes={props.classes} certificate={props.certificate} action={clearCertificate} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ClearCertificate(props: { classes: any; certificate?: CertificateParameters; action: () => void }) {
|
||||
if (!props.certificate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={props.certificate.name}>
|
||||
<Typography className={props.classes.certificateName}>
|
||||
<ClearAdornment action={props.action} value={props.certificate.name} />
|
||||
{props.certificate.name}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
certificateName: {
|
||||
width: '100%',
|
||||
height: 'calc(1em + 4px)',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
color: theme.palette.text.hint,
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(CertificateFileSelection))
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react'
|
||||
import CertificateFileSelection from './CertificateFileSelection'
|
||||
import Undo from '@material-ui/icons/Undo'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { Button, Grid } from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
classes: any
|
||||
managerActions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
interface State {
|
||||
subscription: string
|
||||
}
|
||||
|
||||
class Certificates extends React.PureComponent<Props, State> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { subscription: '' }
|
||||
}
|
||||
|
||||
private handleChange = (name: string) => (event: any) => {
|
||||
this.props.managerActions.updateConnection(this.props.connection.id, {
|
||||
[name]: event.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
private renderCertificateInfo() {
|
||||
if (!this.props.connection.selfSignedCertificate) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <span />
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes } = this.props
|
||||
return (
|
||||
<div>
|
||||
<form noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.selfSignedCertificate}
|
||||
title="Server Certificate (CA)"
|
||||
certificateType="selfSignedCertificate"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.clientCertificate}
|
||||
title="Client Certificate"
|
||||
certificateType="clientCertificate"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={12} className={classes.gridPadding}>
|
||||
<CertificateFileSelection
|
||||
connection={this.props.connection}
|
||||
certificate={this.props.connection.clientKey}
|
||||
title="Client Key"
|
||||
certificateType="clientKey"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={2} className={classes.gridPadding}>
|
||||
<br />
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={this.props.managerActions.toggleCertificateSettings}
|
||||
>
|
||||
<Undo /> Back
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
gridPadding: {
|
||||
padding: '0 12px !important',
|
||||
},
|
||||
button: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginRight: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Certificates))
|
||||
@@ -0,0 +1,25 @@
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import PowerSettingsNew from '@material-ui/icons/PowerSettingsNew'
|
||||
import React from 'react'
|
||||
import { Button } from '@material-ui/core'
|
||||
|
||||
function ConnectButton(props: { connecting: boolean; classes: any; toggle: () => void }) {
|
||||
const { classes, toggle, connecting } = props
|
||||
|
||||
if (connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<ConnectionHealthIndicator />
|
||||
Abort
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={toggle}>
|
||||
<PowerSettingsNew /> Connect
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConnectButton
|
||||
@@ -1,395 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Modal,
|
||||
Paper,
|
||||
Switch,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
import { connect } from 'react-redux'
|
||||
import { MqttOptions } from '../../../../backend/src/DataSource'
|
||||
import { StyleRulesCallback, Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import Notification from './Notification'
|
||||
import Visibility from '@material-ui/icons/Visibility'
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff'
|
||||
|
||||
const sha1 = require('sha1')
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connectionActions } from '../../actions'
|
||||
|
||||
interface Props {
|
||||
classes: {[s: string]: string}
|
||||
actions: typeof connectionActions,
|
||||
visible: boolean
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const protocols = [
|
||||
'mqtt://',
|
||||
'ws://',
|
||||
]
|
||||
|
||||
interface State {
|
||||
showPassword: boolean
|
||||
connectionSettings: ConnectionSettings
|
||||
}
|
||||
|
||||
interface ConnectionSettings {
|
||||
host: string
|
||||
protocol: string
|
||||
port: number
|
||||
tls: boolean
|
||||
certValidation: boolean
|
||||
clientId: string
|
||||
connectionId?: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
declare var window: any
|
||||
|
||||
class Connection extends React.Component<Props, State> {
|
||||
private randomClientId: string
|
||||
private defaultConnectionSettings: ConnectionSettings = {
|
||||
host: 'iot.eclipse.org',
|
||||
protocol: protocols[0],
|
||||
port: 1883,
|
||||
tls: false,
|
||||
certValidation: true,
|
||||
clientId: '',
|
||||
username: '',
|
||||
password: '',
|
||||
connectionId: undefined,
|
||||
}
|
||||
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
|
||||
const clientIdSha = sha1(`${Math.random()}`).slice(0, 8)
|
||||
this.randomClientId = `mqtt-explorer-${clientIdSha}`
|
||||
this.state = {
|
||||
connectionSettings: this.loadConnectionSettings(),
|
||||
showPassword: false,
|
||||
}
|
||||
}
|
||||
|
||||
private loadConnectionSettings(): ConnectionSettings {
|
||||
let storedSettings: ConnectionSettings | undefined
|
||||
|
||||
const storedSettingsString = window.localStorage.getItem('connectionSettings')
|
||||
try {
|
||||
storedSettings = storedSettingsString ? JSON.parse(storedSettingsString) : undefined
|
||||
} catch {
|
||||
window.localStorage.setItem('connectionSettings', undefined)
|
||||
}
|
||||
|
||||
return storedSettings || this.defaultConnectionSettings
|
||||
}
|
||||
|
||||
private saveConnectionSettings() {
|
||||
window.localStorage.setItem('connectionSettings', JSON.stringify(this.state.connectionSettings))
|
||||
}
|
||||
|
||||
private handleClickShowPassword = () => {
|
||||
this.setState({ showPassword: !this.state.showPassword })
|
||||
}
|
||||
|
||||
private optionsFromState(): MqttOptions {
|
||||
const protocol = this.state.connectionSettings.protocol === 'tcp://' ? 'mqtt://' : this.state.connectionSettings.protocol
|
||||
const url = `${protocol}${this.state.connectionSettings.host}:${this.state.connectionSettings.port}`
|
||||
|
||||
return {
|
||||
url,
|
||||
username: this.state.connectionSettings.username || undefined,
|
||||
password: this.state.connectionSettings.password || undefined,
|
||||
clientId: this.state.connectionSettings.clientId || this.randomClientId,
|
||||
tls: this.state.connectionSettings.tls,
|
||||
certValidation: this.state.connectionSettings.certValidation,
|
||||
}
|
||||
}
|
||||
|
||||
public static styles: StyleRulesCallback<string> = (theme: Theme) => {
|
||||
return {
|
||||
root: {
|
||||
minWidth: 550,
|
||||
maxWidth: 650,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
margin: '14vh auto auto auto',
|
||||
padding: `${2 * theme.spacing.unit}px`,
|
||||
outline: 'none',
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
paper: {
|
||||
padding: theme.spacing.unit * 2,
|
||||
textAlign: 'center',
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
textField: {
|
||||
width: '100%',
|
||||
},
|
||||
switch: {
|
||||
marginTop: `${1 * theme.spacing.unit}px`,
|
||||
},
|
||||
button: {
|
||||
margin: theme.spacing.unit,
|
||||
},
|
||||
inputFormControl: {
|
||||
marginTop: '16px',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private handleChange = (name: string) => (event: any) => {
|
||||
this.setState({
|
||||
connectionSettings: {
|
||||
...this.state.connectionSettings,
|
||||
[name]: event.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes } = this.props
|
||||
|
||||
const passwordVisibilityButton = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="Toggle password visibility"
|
||||
onClick={this.handleClickShowPassword}
|
||||
>
|
||||
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
|
||||
let renderError = null
|
||||
if (this.props.error) {
|
||||
renderError = (
|
||||
<Notification
|
||||
message={this.props.error}
|
||||
onClose={() => { this.props.actions.showError(undefined) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderError}
|
||||
<Modal open={this.props.visible} disableAutoFocus={true}>
|
||||
<Paper className={classes.root}>
|
||||
<Toolbar>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">MQTT Connection</Typography>
|
||||
</Toolbar>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={24}>
|
||||
<Grid item={true} xs={2}>
|
||||
{this.renderProtocols()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={7}>
|
||||
<TextField
|
||||
label="Host"
|
||||
className={classes.textField}
|
||||
value={this.state.connectionSettings.host}
|
||||
onChange={this.handleChange('host')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<TextField
|
||||
label="Port"
|
||||
className={classes.textField}
|
||||
value={this.state.connectionSettings.port}
|
||||
onChange={this.handleChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
label="Username"
|
||||
className={classes.textField}
|
||||
value={this.state.connectionSettings.username}
|
||||
onChange={this.handleChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={5}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
type={this.state.showPassword ? 'text' : 'password'}
|
||||
value={this.state.connectionSettings.password}
|
||||
onChange={this.handleChange('password')}
|
||||
endAdornment={passwordVisibilityButton}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item={true} xs={5}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="client-id">Client ID</InputLabel>
|
||||
<Input
|
||||
placeholder={this.randomClientId}
|
||||
className={classes.textField}
|
||||
value={this.state.connectionSettings.clientId || ''}
|
||||
onChange={this.handleChange('clientId')}
|
||||
startAdornment={<span />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
{this.renderCertValidationSwitch()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
{this.renderTlsSwitch()}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<br />
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button variant="contained" color="secondary" className={classes.button} onClick={() => this.saveConnectionSettings()}>
|
||||
Save
|
||||
</Button>
|
||||
{this.renderConnectButton()}
|
||||
</div>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private renderProtocols() {
|
||||
const { classes } = this.props
|
||||
const protocolItems = protocols.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{value}
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
return (
|
||||
<TextField
|
||||
select={true}
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={this.state.connectionSettings.protocol}
|
||||
onChange={this.handleChange('protocol')}
|
||||
margin="normal"
|
||||
>
|
||||
{protocolItems}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
private renderCertValidationSwitch() {
|
||||
const { classes } = this.props
|
||||
const certSwitch = (
|
||||
<Switch
|
||||
checked={this.state.connectionSettings.certValidation}
|
||||
onChange={this.toggleCertValidation}
|
||||
color="primary"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel
|
||||
control={certSwitch}
|
||||
label="Validate certificate"
|
||||
labelPlacement="bottom"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleCertValidation = () => this.setState({
|
||||
connectionSettings: {
|
||||
...this.state.connectionSettings,
|
||||
certValidation: !this.state.connectionSettings.certValidation,
|
||||
},
|
||||
})
|
||||
|
||||
private renderTlsSwitch() {
|
||||
const { classes } = this.props
|
||||
const tlsSwitch = (
|
||||
<Switch
|
||||
checked={this.state.connectionSettings.tls}
|
||||
onChange={this.toggleTls}
|
||||
color="primary"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel
|
||||
control={tlsSwitch}
|
||||
label="Encryption (tls)"
|
||||
labelPlacement="bottom"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleTls = () => this.setState({
|
||||
connectionSettings: {
|
||||
...this.state.connectionSettings,
|
||||
tls: !this.state.connectionSettings.tls,
|
||||
},
|
||||
})
|
||||
|
||||
private renderConnectButton() {
|
||||
const { classes, actions } = this.props
|
||||
|
||||
if (this.props.connecting) {
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={actions.disconnect}>
|
||||
<CircularProgress size={22} style={{ marginRight: '10px' }} color="secondary" /> Abort
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Button variant="contained" color="primary" className={classes.button} onClick={this.onClickConnect}>
|
||||
Connect
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
private onClickConnect = () => {
|
||||
const connectionId = String(sha1(String(Math.random())).slice(0, 8))
|
||||
const options = this.optionsFromState()
|
||||
this.props.actions.connect(options, connectionId)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
visible: !state.connection.connected,
|
||||
connected: state.connection.connected,
|
||||
connecting: state.connection.connecting,
|
||||
error: state.connection.error,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(Connection.styles)(Connection))
|
||||
@@ -0,0 +1,288 @@
|
||||
import ConnectButton from './ConnectButton'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Save from '@material-ui/icons/Save'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import Settings from '@material-ui/icons/Settings'
|
||||
import Visibility from '@material-ui/icons/Visibility'
|
||||
import VisibilityOff from '@material-ui/icons/VisibilityOff'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { ToggleSwitch } from './ToggleSwitch'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
TextField,
|
||||
} from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
connection: ConnectionOptions
|
||||
classes: { [s: string]: string }
|
||||
actions: typeof connectionActions
|
||||
managerActions: typeof connectionManagerActions
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
}
|
||||
|
||||
const protocols = ['mqtt', 'ws']
|
||||
|
||||
function ConnectionSettings(props: Props) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const toggleConnect = useCallback(() => {
|
||||
if (props.connecting) {
|
||||
props.actions.disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
const mqttOptions = toMqttConnection(props.connection)
|
||||
if (mqttOptions) {
|
||||
props.actions.connect(mqttOptions, props.connection.id)
|
||||
}
|
||||
}, [props.connection, props.connecting])
|
||||
|
||||
useGlobalKeyEventHandler(KeyCodes.escape, props.actions.disconnect)
|
||||
useGlobalKeyEventHandler(KeyCodes.enter, toggleConnect, [props.connecting])
|
||||
|
||||
const handleClickShowPassword = useCallback(() => {
|
||||
setShowPassword(!showPassword)
|
||||
}, [showPassword])
|
||||
|
||||
function requiresBasePath() {
|
||||
return props.connection.protocol !== 'mqtt'
|
||||
}
|
||||
|
||||
function renderBasePathInput() {
|
||||
return (
|
||||
<Grid item={true} xs={4}>
|
||||
<TextField
|
||||
label="Basepath"
|
||||
className={props.classes.textField}
|
||||
value={props.connection.basePath}
|
||||
onChange={handleChange('basePath')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
const handleChange = (name: string) => (event: any) => {
|
||||
if (!props.connection) {
|
||||
return
|
||||
}
|
||||
|
||||
updateConnection(name, event.target.value)
|
||||
}
|
||||
|
||||
const updateConnection = (name: string, value: any) => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
[name]: value,
|
||||
})
|
||||
}
|
||||
|
||||
const renderProtocols = () => {
|
||||
const { classes, connection } = props
|
||||
|
||||
const protocolItems = protocols.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{value}://
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
return (
|
||||
<TextField
|
||||
select={true}
|
||||
label="Protocol"
|
||||
className={classes.textField}
|
||||
value={connection.protocol}
|
||||
onChange={updateProtocol}
|
||||
margin="normal"
|
||||
>
|
||||
{protocolItems}
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
const updateProtocol = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
updateConnection('protocol', value)
|
||||
if (event.target.value === 'mqtt') {
|
||||
updateConnection('basePath', undefined)
|
||||
} else {
|
||||
updateConnection('basePath', 'ws')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleCertValidation = () => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
certValidation: !props.connection.certValidation,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleTls = () => {
|
||||
props.managerActions.updateConnection(props.connection.id, {
|
||||
encryption: !props.connection.encryption,
|
||||
})
|
||||
}
|
||||
|
||||
function PasswordVisibilityButton(props: { showPassword: boolean; toggle: () => void }) {
|
||||
return (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Toggle password visibility" onClick={props.toggle}>
|
||||
{props.showPassword ? <Visibility /> : <VisibilityOff />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}
|
||||
|
||||
const { classes, connection } = props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className={classes.container} noValidate={true} autoComplete="off">
|
||||
<Grid container={true} spacing={3}>
|
||||
<Grid item={true} xs={5}>
|
||||
<TextField
|
||||
autoFocus={true}
|
||||
label="Name"
|
||||
className={classes.textField}
|
||||
value={connection.name}
|
||||
onChange={handleChange('name')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={4}>
|
||||
<ToggleSwitch
|
||||
label="Validate certificate"
|
||||
classes={classes}
|
||||
value={connection.certValidation}
|
||||
toggle={toggleCertValidation}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<ToggleSwitch label="Encryption (tls)" classes={classes} value={connection.encryption} toggle={toggleTls} />
|
||||
</Grid>
|
||||
<Grid item={true} xs={2}>
|
||||
{renderProtocols()}
|
||||
</Grid>
|
||||
<Grid item={true} xs={7}>
|
||||
<TextField
|
||||
label="Host"
|
||||
className={classes.textField}
|
||||
value={connection.host}
|
||||
onChange={handleChange('host')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
<TextField
|
||||
label="Port"
|
||||
className={classes.textField}
|
||||
value={connection.port}
|
||||
onChange={handleChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
{requiresBasePath() ? renderBasePathInput() : null}
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<TextField
|
||||
label="Username"
|
||||
className={classes.textField}
|
||||
value={connection.username}
|
||||
onChange={handleChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={requiresBasePath() ? 4 : 6}>
|
||||
<FormControl className={`${classes.textField} ${classes.inputFormControl}`}>
|
||||
<InputLabel htmlFor="adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
id="adornment-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={connection.password}
|
||||
onChange={handleChange('password')}
|
||||
endAdornment={<PasswordVisibilityButton showPassword={showPassword} toggle={handleClickShowPassword} />}
|
||||
/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<br />
|
||||
<div>
|
||||
<div style={{ float: 'left' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={() => props.managerActions.deleteConnection(props.connection.id)}
|
||||
>
|
||||
Delete <Delete />
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.toggleAdvancedSettings}
|
||||
>
|
||||
<Settings /> Advanced
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
className={classes.button}
|
||||
onClick={props.managerActions.saveConnectionSettings}
|
||||
>
|
||||
<Save /> Save
|
||||
</Button>
|
||||
<ConnectButton toggle={toggleConnect} connecting={props.connecting} classes={classes} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
connected: state.connection.connected,
|
||||
connecting: state.connection.connecting,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionActions, dispatch),
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
textField: {
|
||||
width: '100%',
|
||||
},
|
||||
switch: {
|
||||
marginTop: 0,
|
||||
},
|
||||
button: {
|
||||
margin: theme.spacing(1),
|
||||
},
|
||||
inputFormControl: {
|
||||
marginTop: '16px',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSettings))
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from 'react'
|
||||
import ConnectionSettings from './ConnectionSettings'
|
||||
import ProfileList from './ProfileList'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions, toMqttConnection } from '../../model/ConnectionOptions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Modal, Paper, Toolbar, Typography, Collapse } from '@material-ui/core'
|
||||
import AdvancedConnectionSettings from './AdvancedConnectionSettings'
|
||||
import Certificates from './Certificates'
|
||||
|
||||
interface Props {
|
||||
actions: any
|
||||
classes: any
|
||||
connection?: ConnectionOptions
|
||||
visible: boolean
|
||||
showAdvancedSettings: boolean
|
||||
showCertificateSettings: boolean
|
||||
}
|
||||
|
||||
class ConnectionSetup extends React.PureComponent<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
private renderSettings() {
|
||||
const { connection, showAdvancedSettings, showCertificateSettings } = this.props
|
||||
if (!connection) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Collapse in={!showAdvancedSettings && !showCertificateSettings}>
|
||||
<ConnectionSettings connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showAdvancedSettings && !showCertificateSettings}>
|
||||
<AdvancedConnectionSettings connection={connection} />
|
||||
</Collapse>
|
||||
<Collapse in={showCertificateSettings}>
|
||||
<Certificates connection={connection} />
|
||||
</Collapse>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.props.actions.loadConnectionSettings()
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes, visible, connection } = this.props
|
||||
const mqttConnection = connection && toMqttConnection(connection)
|
||||
return (
|
||||
<div>
|
||||
<Modal open={visible} disableAutoFocus={true}>
|
||||
<Paper className={classes.root}>
|
||||
<div className={classes.left}>
|
||||
<ProfileList />
|
||||
</div>
|
||||
<div className={classes.right} key={connection && connection.id}>
|
||||
<Toolbar>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
MQTT Connection
|
||||
</Typography>
|
||||
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
|
||||
</Toolbar>
|
||||
{this.renderSettings()}
|
||||
</div>
|
||||
</Paper>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const connectionHeight = '440px'
|
||||
const styles = (theme: Theme) => ({
|
||||
title: {
|
||||
color: theme.palette.text.primary,
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
root: {
|
||||
margin: `calc((100vh - ${connectionHeight}) / 2) auto 0 auto`,
|
||||
minWidth: '800px',
|
||||
maxWidth: '850px',
|
||||
height: connectionHeight,
|
||||
outline: 'none' as 'none',
|
||||
display: 'flex' as 'flex',
|
||||
},
|
||||
left: {
|
||||
borderRightStyle: 'dotted' as 'dotted',
|
||||
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
|
||||
paddingTop: theme.spacing(2),
|
||||
flex: 3,
|
||||
overflow: 'hidden' as 'hidden',
|
||||
backgroundColor: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
right: {
|
||||
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
padding: theme.spacing(2),
|
||||
flex: 10,
|
||||
},
|
||||
connectionUri: {
|
||||
width: '27em',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
fontSize: '0.9em',
|
||||
marginLeft: theme.spacing(4),
|
||||
},
|
||||
})
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
visible: !state.connection.connected,
|
||||
showAdvancedSettings: state.connectionManager.showAdvancedSettings,
|
||||
showCertificateSettings: state.connectionManager.showCertificateSettings,
|
||||
connection: state.connectionManager.selected
|
||||
? state.connectionManager.connections[state.connectionManager.selected]
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ConnectionSetup))
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import { Fab } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
addButton: {
|
||||
height: theme.spacing(4),
|
||||
width: theme.spacing(4),
|
||||
minHeight: '0',
|
||||
},
|
||||
addIcon: {
|
||||
height: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export const AddButton = withStyles(styles)((props: { classes: any; action: any }) => {
|
||||
return (
|
||||
<span id="addProfileButton" style={{ marginRight: '12px' }}>
|
||||
<Fab size="small" color="secondary" aria-label="Add" className={props.classes.addButton} onClick={props.action}>
|
||||
<Add className={props.classes.addIcon} />
|
||||
</Fab>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { ListItem, Typography } from '@material-ui/core'
|
||||
import { toMqttConnection, ConnectionOptions } from '../../../model/ConnectionOptions'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connectionManagerActions } from '../../../actions'
|
||||
|
||||
export interface Props {
|
||||
connection: ConnectionOptions
|
||||
actions: any
|
||||
selected: boolean
|
||||
classes: any
|
||||
}
|
||||
|
||||
const ConnectionItem = (props: Props) => {
|
||||
const connection = props.connection.host && toMqttConnection(props.connection)
|
||||
return (
|
||||
<ListItem
|
||||
button={true}
|
||||
selected={props.selected}
|
||||
style={{ display: 'block' }}
|
||||
onClick={() => props.actions.selectConnection(props.connection.id)}
|
||||
>
|
||||
<Typography className={props.classes.name}>{props.connection.name || 'mqtt broker'}</Typography>
|
||||
<Typography className={props.classes.details}>{connection && connection.url}</Typography>
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionItemStyle = (theme: Theme) => ({
|
||||
name: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
},
|
||||
details: {
|
||||
width: '100%',
|
||||
textOverflow: 'ellipsis' as 'ellipsis',
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
overflow: 'hidden' as 'hidden',
|
||||
color: theme.palette.text.hint,
|
||||
fontSize: '0.7em',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(null, mapDispatchToProps)(withStyles(connectionItemStyle)(ConnectionItem))
|
||||
@@ -0,0 +1,80 @@
|
||||
import ConnectionItem from './ConnectionItem'
|
||||
import React from 'react'
|
||||
import { AddButton } from './AddButton'
|
||||
import { AppState } from '../../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../../actions'
|
||||
import { ConnectionOptions } from '../../../model/ConnectionOptions'
|
||||
import { KeyCodes } from '../../../utils/KeyCodes'
|
||||
import { List, ListSubheader } from '@material-ui/core'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../../effects/useGlobalKeyEventHandler'
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
selected?: string
|
||||
connections: { [s: string]: ConnectionOptions }
|
||||
actions: typeof connectionManagerActions
|
||||
}
|
||||
|
||||
function ProfileList(props: Props) {
|
||||
const { actions, classes, connections, selected } = props
|
||||
|
||||
const selectConnection = (dir: 'next' | 'previous') => (event: KeyboardEvent) => {
|
||||
if (!selected) {
|
||||
return
|
||||
}
|
||||
const indexDirection = dir === 'next' ? 1 : -1
|
||||
const connectionArray = Object.values(connections)
|
||||
const selectedIndex = connectionArray.map(connection => connection.id).indexOf(selected)
|
||||
const nextConnection = connectionArray[selectedIndex + indexDirection]
|
||||
if (nextConnection) {
|
||||
actions.selectConnection(nextConnection.id)
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
useGlobalKeyEventHandler(KeyCodes.arrow_down, selectConnection('next'))
|
||||
useGlobalKeyEventHandler(KeyCodes.arrow_up, selectConnection('previous'))
|
||||
|
||||
const createConnectionButton = (
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<AddButton action={actions.createConnection} />
|
||||
Connections
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<List style={{ height: '100%' }} component="nav" subheader={createConnectionButton}>
|
||||
<div className={classes.list}>
|
||||
{Object.values(connections).map(connection => (
|
||||
<ConnectionItem connection={connection} key={connection.id} selected={selected === connection.id} />
|
||||
))}
|
||||
</div>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
list: {
|
||||
marginTop: theme.spacing(1),
|
||||
height: `calc(100% - ${theme.spacing(6)})`,
|
||||
overflowY: 'auto' as 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
connections: state.connectionManager.connections,
|
||||
selected: state.connectionManager.selected,
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(ProfileList))
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import Delete from '@material-ui/icons/Delete'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { ConnectionOptions } from '../../model/ConnectionOptions'
|
||||
import {
|
||||
IconButton,
|
||||
TableContainer,
|
||||
Table,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableBody,
|
||||
Paper,
|
||||
Theme,
|
||||
} from '@material-ui/core'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
function Subscriptions(props: {
|
||||
classes: any
|
||||
connection: ConnectionOptions
|
||||
managerActions: typeof connectionManagerActions
|
||||
}) {
|
||||
const { classes, connection, managerActions } = props
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper} className={`${classes.topicList} advanced-connection-settings-topic-list`}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell align="left" padding="checkbox" className={classes.tableTitleCell}></TableCell>
|
||||
<TableCell className={classes.tableTitleCell}>Topic</TableCell>
|
||||
<TableCell align="right" className={classes.tableTitleCell}>
|
||||
QoS
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{connection.subscriptions.map(subscription => (
|
||||
<TableRow key={subscription.topic + '_qos_' + subscription.qos}>
|
||||
<TableCell align="right" className={classes.tableCell}>
|
||||
<IconButton
|
||||
onClick={() => managerActions.deleteSubscription(subscription, connection.id)}
|
||||
style={{ padding: '6px' }}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
|
||||
<TableCell component="th" scope="row" className={classes.tableCell}>
|
||||
{subscription.topic}
|
||||
</TableCell>
|
||||
<TableCell align="right" className={classes.tableCell}>
|
||||
{subscription.qos}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
managerActions: bindActionCreators(connectionManagerActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
tableCell: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
wordBbreak: 'break-word',
|
||||
},
|
||||
tableTitleCell: {
|
||||
paddingTop: `${theme.spacing(0.5)}px`,
|
||||
paddingBottom: `${theme.spacing(0.5)}px`,
|
||||
},
|
||||
topicList: {
|
||||
height: '196px',
|
||||
overflowY: 'scroll' as 'scroll',
|
||||
margin: `${theme.spacing(1)}px ${theme.spacing(1)}px 0 ${theme.spacing(1)}px`,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
width: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(Subscriptions))
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { FormControlLabel, Switch } from '@material-ui/core'
|
||||
|
||||
export function ToggleSwitch(props: { value: boolean; classes: any; toggle: () => void; label: string }) {
|
||||
const { classes, value, toggle, label } = props
|
||||
const toggleSwitch = <Switch checked={value} onChange={toggle} color="primary" />
|
||||
return (
|
||||
<div className={classes.switch}>
|
||||
<FormControlLabel control={toggleSwitch} label={label} labelPlacement="bottom" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Snackbar, SnackbarContent } from '@material-ui/core'
|
||||
import FileCopy from '@material-ui/icons/FileCopy'
|
||||
import Check from '@material-ui/icons/Check'
|
||||
import green from '@material-ui/core/colors/green'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
|
||||
const copy = require('copy-text-to-clipboard')
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
classes: any
|
||||
}
|
||||
|
||||
interface State {
|
||||
didCopy: boolean
|
||||
snackBarOpen: boolean
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
snackbar: {
|
||||
backgroundColor: green[600],
|
||||
color: theme.typography.button.color,
|
||||
},
|
||||
})
|
||||
|
||||
class Copy extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { didCopy: false, snackBarOpen: false }
|
||||
}
|
||||
|
||||
public render() {
|
||||
const icon = !this.state.didCopy
|
||||
? <FileCopy fontSize="inherit" style={{ cursor: 'pointer' }} onClick={this.handleClick} />
|
||||
: <Check fontSize="inherit" style={{ cursor: 'default' }} />
|
||||
|
||||
return <span>
|
||||
<span style={{ fontSize: '16px' }}>{icon}</span>
|
||||
<span>
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
open={this.state.snackBarOpen}
|
||||
autoHideDuration={2000}
|
||||
onClose={() => { this.setState({ snackBarOpen: false }) }}
|
||||
>
|
||||
<SnackbarContent
|
||||
className={this.props.classes.snackbar}
|
||||
message="Copied to clipboard"
|
||||
/>
|
||||
</Snackbar>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
private handleClick = (event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
|
||||
copy(this.props.value)
|
||||
this.setState({ didCopy: true, snackBarOpen: true })
|
||||
setTimeout(() => {
|
||||
this.setState({ didCopy: false })
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(Copy)
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
|
||||
interface Props {
|
||||
keyboardKey: string
|
||||
classes: any
|
||||
}
|
||||
|
||||
class Key extends React.Component<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { location: 'bottom' }
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<div className={this.props.classes.keyStyle}>
|
||||
<div className={this.props.classes.keyTextStyle}>{this.props.keyboardKey}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: Theme) => ({
|
||||
keyStyle: {
|
||||
display: 'inline-block' as 'inline-block',
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
backgroundColor: '#bbb',
|
||||
borderRadius: '10%',
|
||||
verticalAlign: 'middle' as 'middle',
|
||||
textAlign: 'center' as 'center',
|
||||
textShadow: '1px 1px rgba(255,255,255,0.45)',
|
||||
boxShadow: '0.08em 0.15em 0.01em 0px rgba(100,100,100,0.75)',
|
||||
},
|
||||
keyTextStyle: {
|
||||
marginTop: '0.65em',
|
||||
fontSize: '0.4em',
|
||||
fontWeight: 'bold' as 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(style)(Key)
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
const cursor = require('./cursor.png')
|
||||
|
||||
interface State {
|
||||
enabled: boolean
|
||||
target: { x: number; y: number }
|
||||
position: { x: number; y: number }
|
||||
stepSizeX: number
|
||||
stepSizeY: number
|
||||
}
|
||||
|
||||
class Demo extends React.Component<{ classes: any }, State> {
|
||||
private timer: any
|
||||
private frameInterval = 20
|
||||
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { enabled: false, target: { x: 0, y: 0 }, position: { x: 0, y: 0 }, stepSizeX: 1, stepSizeY: 1 }
|
||||
}
|
||||
|
||||
private moveCloser(steps: number = 0) {
|
||||
const steSizeX = Math.min(this.state.stepSizeX, Math.abs(this.state.position.x - this.state.target.x))
|
||||
const steSizeY = Math.min(this.state.stepSizeY, Math.abs(this.state.position.y - this.state.target.y))
|
||||
const dirX = this.state.position.x > this.state.target.x ? -1 : 1
|
||||
const dirY = this.state.position.y > this.state.target.y ? -1 : 1
|
||||
|
||||
if (steSizeX <= 0.1 && steSizeY <= 0.1) {
|
||||
this.timer && clearTimeout(this.timer)
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
position: {
|
||||
x: this.state.position.x + dirX * steSizeX,
|
||||
y: this.state.position.y + dirY * steSizeY,
|
||||
},
|
||||
})
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.moveCloser(steps + 1)
|
||||
}, this.frameInterval)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
;(window as any).demo.enableMouse = () => {
|
||||
this.setState({ enabled: true })
|
||||
}
|
||||
;(window as any).demo.moveMouse = (x: number, y: number, animationTime: number) => {
|
||||
const stepSizeX = Math.abs(this.state.position.x - x) / (animationTime / this.frameInterval)
|
||||
const stepSizeY = Math.abs(this.state.position.y - y) / (animationTime / this.frameInterval)
|
||||
this.setState({ stepSizeX, stepSizeY, enabled: true, target: { x, y } })
|
||||
this.moveCloser()
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.state.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
const cursorStyle = {
|
||||
left: this.state.position.x + 2,
|
||||
top: this.state.position.y + 2,
|
||||
}
|
||||
|
||||
return <img src={cursor} style={cursorStyle} className={this.props.classes.cursor} />
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: Theme) => ({
|
||||
cursor: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
position: 'fixed' as 'fixed',
|
||||
zIndex: 1000000,
|
||||
filter: theme.palette.type === 'light' ? undefined : 'invert(100%)',
|
||||
pointerEvents: 'none' as 'none',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(style)(Demo)
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as React from 'react'
|
||||
import { Theme, withStyles } from '@material-ui/core'
|
||||
import Key from './Key'
|
||||
|
||||
interface State {
|
||||
message?: string
|
||||
keys: Array<string>
|
||||
location: string
|
||||
}
|
||||
|
||||
class Demo extends React.Component<{ classes: any }, State> {
|
||||
private timer: any
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { location: 'bottom', keys: [] }
|
||||
}
|
||||
|
||||
private clearTimer() {
|
||||
this.timer && clearTimeout(this.timer)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
;(window as any).demo.showMessage = (
|
||||
message: string,
|
||||
location: string,
|
||||
duration: number,
|
||||
keys: Array<string> = []
|
||||
) => {
|
||||
this.clearTimer()
|
||||
this.setState({ message, location, keys })
|
||||
this.timer = setTimeout(() => this.setState({ message: undefined }), duration)
|
||||
}
|
||||
;(window as any).demo.hideMessage = () => {
|
||||
this.clearTimer()
|
||||
this.setState({ message: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const positions: { [s: string]: number } = {
|
||||
top: 0,
|
||||
bottom: -65,
|
||||
middle: -32,
|
||||
}
|
||||
const style = {
|
||||
position: 'fixed' as 'fixed',
|
||||
left: '5vw',
|
||||
zIndex: 1000000,
|
||||
margin: '30vw auto 50vw',
|
||||
right: '5vw',
|
||||
bottom: `${positions[this.state.location]}vh`,
|
||||
}
|
||||
const style2 = {
|
||||
textAlign: 'center' as 'center',
|
||||
fontSize: '4em',
|
||||
color: 'white',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
borderRadius: '16px',
|
||||
}
|
||||
|
||||
if (!this.state.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
let keys: Array<any> = []
|
||||
if (this.state.keys.length > 0) {
|
||||
keys = this.state.keys
|
||||
.map(key => [<Key key={key} keyboardKey={key} />])
|
||||
.reduce((prev, current) => {
|
||||
return [prev, '+' as any, current]
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<div style={style2}>
|
||||
<span>{this.state.message}</span>
|
||||
{keys.length > 0 ? <div className={this.props.classes.keysStyle}>{keys}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const style = (theme: Theme) => ({
|
||||
keysStyle: {
|
||||
fontSize: '1em',
|
||||
display: 'inline-block' as 'inline-block',
|
||||
transform: 'translateY(0.3em) translateX(0.8em)',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(style)(Demo)
|
||||
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import ShowText from './ShowText'
|
||||
import Mouse from './Mouse'
|
||||
let heapdump: any
|
||||
|
||||
function writeHeapdump(path?: string) {
|
||||
if (!heapdump) {
|
||||
//<heapdump = require('heapdump')
|
||||
}
|
||||
|
||||
heapdump.writeSnapshot(path || `${Date.now()}.heapsnapshot`)
|
||||
return path
|
||||
}
|
||||
|
||||
;(window as any).demo = {
|
||||
writeHeapdump,
|
||||
}
|
||||
|
||||
export default function render(props: any) {
|
||||
return (
|
||||
<span>
|
||||
<ShowText />
|
||||
<Mouse />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import * as React from 'react'
|
||||
import { electronRendererTelementry } from 'electron-telemetry'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Paper,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
|
||||
import Warning from '@material-ui/icons/Warning'
|
||||
import PersistentStorage from '../utils/PersistentStorage'
|
||||
import SentimentDissatisfied from '@material-ui/icons/SentimentDissatisfied'
|
||||
|
||||
import Warning from '@material-ui/icons/Warning'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { Button, Modal, Paper, Toolbar, Typography } from '@material-ui/core'
|
||||
|
||||
interface State {
|
||||
error?: Error
|
||||
@@ -21,30 +13,29 @@ interface Props {
|
||||
classes: any
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
class ErrorBoundary extends React.PureComponent<Props, State> {
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: any) {
|
||||
electronRendererTelementry.trackError(error)
|
||||
console.log('did catch', error)
|
||||
}
|
||||
|
||||
public static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
private restart = () => {
|
||||
window.location = window.location
|
||||
}
|
||||
|
||||
private clearStorage = () => {
|
||||
localStorage.clear()
|
||||
PersistentStorage.clear()
|
||||
window.location = window.location
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: any) {
|
||||
// electronRendererTelemetry.trackError(error)
|
||||
console.log('did catch', error)
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this.state.error) {
|
||||
return this.props.children
|
||||
@@ -55,23 +46,36 @@ class ErrorBoundary extends React.Component<Props, State> {
|
||||
<Modal open={true} disableAutoFocus={true}>
|
||||
<Paper className={classes.root}>
|
||||
<Toolbar style={{ padding: '0' }}>
|
||||
<Typography className={classes.title} variant="h6" color="inherit"><Warning /> Oooooops!</Typography>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
<Warning /> Oooooops!
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Typography className={classes.centered}>I hoped that you would never see this window, but MQTT-Explorer had an unexpected error.</Typography>
|
||||
<Typography className={classes.centered}><SentimentDissatisfied /></Typography>
|
||||
<Typography className={classes.centered}>
|
||||
I hoped that you would never see this window, but MQTT-Explorer had an unexpected error.
|
||||
</Typography>
|
||||
<Typography className={classes.centered}>
|
||||
<SentimentDissatisfied />
|
||||
</Typography>
|
||||
<pre className={classes.textColor} style={{ maxHeight: '35vh', overflow: 'scroll' }}>
|
||||
<code className={classes.textColor}>
|
||||
{this.state.error.stack}
|
||||
</code>
|
||||
<code className={classes.textColor}>{this.state.error.stack}</code>
|
||||
</pre>
|
||||
<Typography>
|
||||
Please report this issue with a short description of what happened to
|
||||
<span> <a className={classes.textColor} href="https://github.com/thomasnordquist/MQTT-Explorer/issues">https://github.com/thomasnordquist/MQTT-Explorer/issues</a></span>
|
||||
<span>
|
||||
{' '}
|
||||
<a className={classes.textColor} href="https://github.com/thomasnordquist/MQTT-Explorer/issues">
|
||||
https://github.com/thomasnordquist/MQTT-Explorer/issues
|
||||
</a>
|
||||
</span>
|
||||
</Typography>
|
||||
<div>
|
||||
<div className={classes.buttonPositioning}>
|
||||
<Button className={classes.button} variant="contained" color="secondary" onClick={this.clearStorage}>Start Fresh</Button>
|
||||
<Button className={classes.button} variant="contained" color="primary" onClick={this.restart}>Restart</Button>
|
||||
<Button className={classes.button} variant="contained" color="secondary" onClick={this.clearStorage}>
|
||||
Start Fresh
|
||||
</Button>
|
||||
<Button className={classes.button} variant="contained" color="primary" onClick={this.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
@@ -89,7 +93,7 @@ const styles = (theme: Theme) => ({
|
||||
maxWidth: 650,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
margin: '10vh auto auto auto',
|
||||
padding: `${2 * theme.spacing.unit}px`,
|
||||
padding: theme.spacing(2),
|
||||
outline: 'none',
|
||||
},
|
||||
title: {
|
||||
@@ -99,13 +103,14 @@ const styles = (theme: Theme) => ({
|
||||
},
|
||||
textColor: {
|
||||
color: theme.palette.text.primary,
|
||||
userSelect: 'all' as 'all',
|
||||
},
|
||||
centered: {
|
||||
textAlign: 'center' as 'center',
|
||||
},
|
||||
buttonPositioning: {
|
||||
textAlign: 'center' as 'center',
|
||||
marginTop: `${theme.spacing.unit * 2}px`,
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import * as React from 'react'
|
||||
import ChartPanel from '../ChartPanel'
|
||||
import ReactSplitPane from 'react-split-pane'
|
||||
import Tree from '../Tree'
|
||||
import { AppState } from '../../reducers'
|
||||
import { ChartParameters } from '../../reducers/Charts'
|
||||
import { connect } from 'react-redux'
|
||||
import { List } from 'immutable'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import ReactResizeDetector from 'react-resize-detector'
|
||||
|
||||
interface Props {
|
||||
heightProperty: any
|
||||
paneDefaults: any
|
||||
connectionId?: string
|
||||
chartPanelItems: List<ChartParameters>
|
||||
}
|
||||
|
||||
function ContentView(props: Props) {
|
||||
const [height, setHeight] = React.useState<string | number>('100%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
|
||||
const [detectedHeight, setDetectedHeight] = React.useState(0)
|
||||
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
|
||||
const detectSize = React.useCallback((width, newHeight) => {
|
||||
setDetectedHeight(newHeight)
|
||||
}, [])
|
||||
|
||||
const detectSidebarSize = React.useCallback(width => {
|
||||
setDetectedSidebarWidth(width)
|
||||
}, [])
|
||||
|
||||
const closeDrawerCompletelyIfItSitsOnTheEdge = React.useCallback(() => {
|
||||
if (detectedHeight < 30) {
|
||||
setHeight('100%')
|
||||
}
|
||||
}, [detectedHeight])
|
||||
|
||||
const closeSidebarCompletelyIfItSitsOnTheEdge = React.useCallback(() => {
|
||||
if (detectedSidebarWidth < 30) {
|
||||
setSidebarWidth('0%')
|
||||
}
|
||||
}, [detectedSidebarWidth])
|
||||
|
||||
// Open chart panel on start and when a new chart is added but the panel is closed
|
||||
React.useEffect(() => {
|
||||
const almostClosed = !isNaN(height as any) && detectedHeight < 30
|
||||
if ((!height || height === '100%' || almostClosed) && props.chartPanelItems.count() > 0) {
|
||||
setHeight('calc(100% - 250px)')
|
||||
}
|
||||
|
||||
if (props.chartPanelItems.count() === 0) {
|
||||
setHeight('100%')
|
||||
}
|
||||
}, [props.chartPanelItems])
|
||||
|
||||
return (
|
||||
<div className={props.paneDefaults}>
|
||||
<span>
|
||||
<ReactSplitPane
|
||||
step={20}
|
||||
primary="second"
|
||||
className={props.heightProperty}
|
||||
split="vertical"
|
||||
minSize={0}
|
||||
size={sidebarWidth}
|
||||
onChange={setSidebarWidth}
|
||||
onDragFinished={closeSidebarCompletelyIfItSitsOnTheEdge}
|
||||
allowResize={true}
|
||||
style={{ height: '100%' }}
|
||||
pane1Style={{ overflowX: 'hidden' }}
|
||||
resizerStyle={{ height: '100%' }}
|
||||
>
|
||||
<span>
|
||||
<ReactSplitPane
|
||||
step={10}
|
||||
split="horizontal"
|
||||
minSize={0}
|
||||
size={height}
|
||||
allowResize={true}
|
||||
style={{ height: 'calc(100vh - 64px)' }}
|
||||
pane1Style={{ maxHeight: '100%' }}
|
||||
pane2Style={{ borderTop: '1px solid #999', display: 'flex' }}
|
||||
onChange={setHeight}
|
||||
onDragFinished={closeDrawerCompletelyIfItSitsOnTheEdge}
|
||||
>
|
||||
<Tree />
|
||||
{/** Passing height constraints via flex options down */}
|
||||
<div style={{ flex: 1, display: 'flex', height: '100%', width: '100%' }}>
|
||||
{/** Resize detector must not be in the scroll zone, it needs to detect actual available size */}
|
||||
<ReactResizeDetector handleHeight={true} onResize={detectSize} />
|
||||
<ChartPanel />
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
<div style={{ height: '100%' }}>
|
||||
<ReactResizeDetector handleWidth={true} onResize={detectSidebarSize} />
|
||||
<div
|
||||
className={props.paneDefaults}
|
||||
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
</div>
|
||||
</ReactSplitPane>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
chartPanelItems: state.charts.get('charts'),
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(ContentView)
|
||||
+6
-5
@@ -6,22 +6,23 @@ import { green, red } from '@material-ui/core/colors'
|
||||
|
||||
interface Props {
|
||||
message?: string
|
||||
type: 'error' | 'notification'
|
||||
onClose: () => void
|
||||
classes: any
|
||||
}
|
||||
|
||||
class Notification extends React.Component<Props, {}> {
|
||||
class Notification extends React.PureComponent<Props, {}> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
public static styles = (theme: Theme) => ({
|
||||
success: {
|
||||
notification: {
|
||||
backgroundColor: green[600],
|
||||
color: theme.typography.button.color,
|
||||
},
|
||||
error: {
|
||||
backgroundColor: red[600],
|
||||
backgroundColor: theme.palette.error.main,
|
||||
color: theme.typography.button.color,
|
||||
},
|
||||
})
|
||||
@@ -36,11 +37,11 @@ class Notification extends React.Component<Props, {}> {
|
||||
<Snackbar
|
||||
anchorOrigin={snackbarAnchor}
|
||||
open={Boolean(this.props.message)}
|
||||
autoHideDuration={10000}
|
||||
autoHideDuration={this.props.type === 'error' ? 10000 : 2000}
|
||||
onClose={this.props.onClose}
|
||||
>
|
||||
<SnackbarContent
|
||||
className={this.props.classes.error}
|
||||
className={this.props.type === 'error' ? this.props.classes.error : this.props.classes.notification}
|
||||
message={this.props.message}
|
||||
/>
|
||||
</Snackbar>
|
||||
@@ -0,0 +1,105 @@
|
||||
import * as React from 'react'
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import CustomIconButton from '../helper/CustomIconButton'
|
||||
import Pause from '@material-ui/icons/PauseCircleFilled'
|
||||
import Resume from '@material-ui/icons/PlayArrow'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { treeActions } from '../../actions'
|
||||
import { withStyles, Theme } from '@material-ui/core/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
icon: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
verticalAlign: 'middle' as 'middle',
|
||||
},
|
||||
bufferStats: {
|
||||
minWidth: '8em',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
actions: {
|
||||
tree: typeof treeActions
|
||||
}
|
||||
paused: boolean
|
||||
tree?: q.Tree<any>
|
||||
}
|
||||
|
||||
class PauseButton extends React.PureComponent<Props, { changes: number }> {
|
||||
private timer?: any
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { changes: 0 }
|
||||
}
|
||||
|
||||
private renderBufferStats() {
|
||||
if (!this.props.tree) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={this.props.classes.bufferStats}>
|
||||
{this.state.changes} changes
|
||||
<br />
|
||||
buffer at {Math.round(this.props.tree.unmergedChanges().fillState() * 10000) / 100}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
this.timer = setInterval(() => {
|
||||
if (!this.props.paused || !this.props.tree) {
|
||||
return
|
||||
}
|
||||
|
||||
const changes = this.props.tree.unmergedChanges()
|
||||
if (this.state.changes !== changes.length) {
|
||||
this.setState({ changes: changes.length })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.timer && clearInterval(this.timer)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const message = this.props.paused
|
||||
? 'Resumes updating the tree, after applying all recorded changes'
|
||||
: 'Stops all updates, records changes until the buffer is full.'
|
||||
return (
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<span>
|
||||
<CustomIconButton onClick={this.props.actions.tree.togglePause} tooltip={message}>
|
||||
{this.props.paused ? (
|
||||
<Resume className={this.props.classes.icon} />
|
||||
) : (
|
||||
<Pause className={this.props.classes.icon} />
|
||||
)}
|
||||
</CustomIconButton>
|
||||
</span>
|
||||
{this.props.paused ? this.renderBufferStats() : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
paused: state.tree.get('paused'),
|
||||
tree: state.tree.get('tree'),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
tree: bindActionCreators(treeActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(PauseButton))
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useState, useRef } from 'react'
|
||||
import ClearAdornment from '../helper/ClearAdornment'
|
||||
import Search from '@material-ui/icons/Search'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { InputBase } from '@material-ui/core'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { fade, Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { useGlobalKeyEventHandler } from '../../effects/useGlobalKeyEventHandler'
|
||||
import { KeyCodes } from '../../utils/KeyCodes'
|
||||
|
||||
function SearchBar(props: {
|
||||
classes: any
|
||||
topicFilter?: string
|
||||
hasConnection: boolean
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
}
|
||||
}) {
|
||||
const { actions, classes, hasConnection, topicFilter } = props
|
||||
|
||||
const [hasFocus, setHasFocus] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>()
|
||||
const onFocus = useCallback(() => setHasFocus(true), [])
|
||||
const onBlur = useCallback(() => setHasFocus(false), [])
|
||||
|
||||
const clearFilter = useCallback(() => {
|
||||
actions.settings.filterTopics('')
|
||||
}, [])
|
||||
|
||||
const onFilterChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.settings.filterTopics(event.target.value)
|
||||
}, [])
|
||||
|
||||
useGlobalKeyEventHandler(undefined, event => {
|
||||
const isCharacter = event.key.length === 1
|
||||
const isModifierKey = event.metaKey || event.ctrlKey
|
||||
const isAllowedControlCharacter = event.keyCode === KeyCodes.backspace || event.keyCode === KeyCodes.delete
|
||||
const tagNameBlacklist = ['INPUT', 'TEXTAREA', 'RADIO', 'CHECKBOX', 'OPTION', 'FORM']
|
||||
|
||||
const tagElementIsNotBlacklisted =
|
||||
document.activeElement && tagNameBlacklist.indexOf(document.activeElement.tagName) === -1
|
||||
|
||||
if (
|
||||
(isCharacter || isAllowedControlCharacter) &&
|
||||
!isModifierKey &&
|
||||
!event.defaultPrevented &&
|
||||
!hasFocus &&
|
||||
tagElementIsNotBlacklisted &&
|
||||
hasConnection
|
||||
) {
|
||||
// Focus input field, no preventDefault the event will reach the input element after it has been focussed
|
||||
inputRef.current && inputRef.current.focus()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className={classes.search}>
|
||||
<div className={classes.searchIcon}>
|
||||
<Search />
|
||||
</div>
|
||||
<InputBase
|
||||
value={topicFilter}
|
||||
inputProps={{
|
||||
onFocus,
|
||||
onBlur,
|
||||
ref: inputRef,
|
||||
}}
|
||||
onChange={onFilterChange}
|
||||
placeholder="Search…"
|
||||
endAdornment={
|
||||
<div style={{ width: '24px', paddingRight: '8px' }}>
|
||||
<ClearAdornment variant="primary" action={clearFilter} value={topicFilter} />
|
||||
</div>
|
||||
}
|
||||
classes={{ root: classes.inputRoot, input: classes.inputInput }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
topicFilter: state.settings.get('topicFilter'),
|
||||
hasConnection: Boolean(state.connection.connectionId),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
search: {
|
||||
position: 'relative' as 'relative',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
backgroundColor: fade(theme.palette.common.white, 0.15),
|
||||
'&:hover': {
|
||||
backgroundColor: fade(theme.palette.common.white, 0.25),
|
||||
},
|
||||
marginRight: theme.spacing(2),
|
||||
marginLeft: 0,
|
||||
flexGrow: 1,
|
||||
maxWidth: '60%',
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
maxWidth: '30%',
|
||||
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
[theme.breakpoints.up(750)]: {
|
||||
marginLeft: theme.spacing(4),
|
||||
width: 'auto' as 'auto',
|
||||
},
|
||||
},
|
||||
searchIcon: {
|
||||
width: theme.spacing(6),
|
||||
height: '100%',
|
||||
position: 'absolute' as 'absolute',
|
||||
pointerEvents: 'none' as 'none',
|
||||
display: 'flex' as 'flex',
|
||||
alignItems: 'center' as 'center',
|
||||
justifyContent: 'center' as 'center',
|
||||
},
|
||||
inputRoot: {
|
||||
color: 'inherit' as 'inherit',
|
||||
width: '100%',
|
||||
},
|
||||
inputInput: {
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingRight: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(6),
|
||||
transition: theme.transitions.create('width'),
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(SearchBar))
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as React from 'react'
|
||||
import CloudOff from '@material-ui/icons/CloudOff'
|
||||
import ConnectionHealthIndicator from '../helper/ConnectionHealthIndicator'
|
||||
import Menu from '@material-ui/icons/Menu'
|
||||
import PauseButton from './PauseButton'
|
||||
import SearchBar from './SearchBar'
|
||||
import { AppBar, Button, IconButton, Toolbar, Typography } from '@material-ui/core'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionActions, globalActions, settingsActions } from '../../actions'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
title: {
|
||||
display: 'none' as 'none',
|
||||
[theme.breakpoints.up(750)]: {
|
||||
display: 'block' as 'block',
|
||||
},
|
||||
whiteSpace: 'nowrap' as 'nowrap',
|
||||
},
|
||||
disconnectIcon: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
marginRight: '8px',
|
||||
paddingLeft: '8px',
|
||||
},
|
||||
menuButton: {
|
||||
marginLeft: -12,
|
||||
marginRight: 20,
|
||||
},
|
||||
disconnect: {
|
||||
margin: 'auto 8px auto auto',
|
||||
},
|
||||
disconnectLabel: {
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
connection: typeof connectionActions
|
||||
global: typeof globalActions
|
||||
}
|
||||
topicFilter?: string
|
||||
}
|
||||
|
||||
class TitleBar extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { actions, classes } = this.props
|
||||
|
||||
return (
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
className={classes.menuButton}
|
||||
color="inherit"
|
||||
aria-label="Menu"
|
||||
onClick={actions.global.toggleSettingsVisibility}
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
MQTT Explorer
|
||||
</Typography>
|
||||
<SearchBar />
|
||||
<PauseButton />
|
||||
<Button
|
||||
className={classes.disconnect}
|
||||
classes={{ label: classes.disconnectLabel }}
|
||||
onClick={actions.connection.disconnect}
|
||||
>
|
||||
Disconnect <CloudOff className={classes.disconnectIcon} />
|
||||
</Button>
|
||||
<ConnectionHealthIndicator withBackground={true} />
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
topicFilter: state.settings.get('topicFilter'),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
global: bindActionCreators(globalActions, dispatch),
|
||||
connection: bindActionCreators(connectionActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(TitleBar))
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import { TextField, MenuItem, Tooltip } from '@material-ui/core'
|
||||
import { QoS } from '../../../backend/src/DataSource/MqttSource'
|
||||
|
||||
export function QosSelect(props: { selected: QoS; onChange: (value: QoS) => void; label?: string }) {
|
||||
const tooltipStyle = { textAlign: 'center' as 'center', width: '100%' }
|
||||
const itemStyle = { padding: '0' }
|
||||
|
||||
const onChangeQos = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(event.target.value, 10)
|
||||
if (value !== 0 && value !== 1 && value !== 2) {
|
||||
return
|
||||
}
|
||||
props.onChange(value)
|
||||
},
|
||||
[props.onChange]
|
||||
)
|
||||
|
||||
return (
|
||||
<TextField
|
||||
select={true}
|
||||
label={props.label}
|
||||
value={props.selected}
|
||||
margin="normal"
|
||||
style={{ margin: '8px 0 8px 8px' }}
|
||||
onChange={onChangeQos}
|
||||
>
|
||||
<MenuItem key={0} value={0} style={itemStyle}>
|
||||
<Tooltip title="At most once">
|
||||
<div style={tooltipStyle}>0</div>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
<MenuItem key={1} value={1} style={itemStyle}>
|
||||
<Tooltip title="At least once">
|
||||
<div style={tooltipStyle}>1</div>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
<MenuItem key={2} value={2} style={itemStyle}>
|
||||
<Tooltip title="Exactly once">
|
||||
<div style={tooltipStyle}>2</div>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
</TextField>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(QosSelect)
|
||||
@@ -1,172 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { AppState } from '../reducers'
|
||||
import {
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Input,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@material-ui/core'
|
||||
import { StyleRulesCallback, withStyles } from '@material-ui/core/styles'
|
||||
|
||||
import ChevronRight from '@material-ui/icons/ChevronRight'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { settingsActions, treeActions } from '../actions'
|
||||
import { TopicOrder } from '../reducers/Settings'
|
||||
import BrokerStatistics from './BrokerStatistics'
|
||||
|
||||
export const autoExpandLimitSet = [{
|
||||
limit: 0,
|
||||
name: 'Collapsed',
|
||||
}, {
|
||||
limit: 2,
|
||||
name: 'Few',
|
||||
}, {
|
||||
limit: 3,
|
||||
name: 'Some',
|
||||
}, {
|
||||
limit: 10,
|
||||
name: 'Most',
|
||||
}, {
|
||||
limit: 1E6,
|
||||
name: 'All',
|
||||
}]
|
||||
|
||||
const styles: StyleRulesCallback = theme => ({
|
||||
drawer: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
flexShrink: 0,
|
||||
},
|
||||
paper: {
|
||||
width: '300px',
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.text.primary,
|
||||
paddingTop: `${theme.spacing.unit}px`,
|
||||
...theme.mixins.toolbar,
|
||||
},
|
||||
input: {
|
||||
minWidth: '150px',
|
||||
margin: `auto ${theme.spacing.unit}px auto ${2 * theme.spacing.unit}px`,
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
autoExpandLimit: number
|
||||
visible: boolean
|
||||
store?: any
|
||||
topicOrder: TopicOrder
|
||||
classes: any
|
||||
actions: typeof settingsActions
|
||||
}
|
||||
|
||||
class Settings extends React.Component<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes, actions, visible } = this.props
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
className={classes.drawer}
|
||||
open={visible}
|
||||
variant="persistent"
|
||||
>
|
||||
<div
|
||||
className={classes.paper}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
<IconButton onClick={actions.toggleSettingsVisibility}>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
Settings
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
{this.renderAutoExpand()}
|
||||
{this.renderNodeOrder()}
|
||||
</div>
|
||||
<BrokerStatistics />
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
private renderAutoExpand() {
|
||||
const { classes, autoExpandLimit } = this.props
|
||||
|
||||
const limits = autoExpandLimitSet.map(limit => <MenuItem key={limit.limit} value={limit.limit}>{limit.name}</MenuItem>)
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Auto Expand</InputLabel>
|
||||
<Select
|
||||
value={autoExpandLimit}
|
||||
onChange={this.onChangeAutoExpand}
|
||||
input={<Input name="auto-expand" id="auto-expand-label-placeholder" />}
|
||||
name="auto-expand"
|
||||
className={classes.input}
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
{limits}
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeAutoExpand = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
this.props.actions.setAutoExpandLimit(parseInt(e.target.value, 10))
|
||||
}
|
||||
|
||||
private renderNodeOrder() {
|
||||
const { classes, topicOrder } = this.props
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>Topic order</InputLabel>
|
||||
<Select
|
||||
value={topicOrder}
|
||||
onChange={this.onChangeSorting}
|
||||
input={<Input name="node-order" id="node-order-label-placeholder" />}
|
||||
displayEmpty={true}
|
||||
name="node-order"
|
||||
className={classes.input}
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
<MenuItem value={TopicOrder.none}><em>default</em></MenuItem>
|
||||
<MenuItem value={TopicOrder.abc}>a-z</MenuItem>
|
||||
<MenuItem value={TopicOrder.messages}>{TopicOrder.messages}</MenuItem>
|
||||
<MenuItem value={TopicOrder.topics}>{TopicOrder.topics}</MenuItem>
|
||||
</Select>
|
||||
</div>)
|
||||
}
|
||||
|
||||
private onChangeSorting = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
this.props.actions.setTopicOrder(e.target.value as TopicOrder)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
autoExpandLimit: state.settings.autoExpandLimit,
|
||||
topicOrder: state.settings.topicOrder,
|
||||
visible: state.settings.visible,
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: bindActionCreators(settingsActions, dispatch),
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Settings))
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import { InputLabel, Switch, Theme, Tooltip } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
const sha1 = require('sha1')
|
||||
|
||||
function BooleanSwitch(props: { title: string; value: boolean; tooltip: string; action: () => void; classes: any }) {
|
||||
const { tooltip, value, action, title, classes } = props
|
||||
|
||||
const clickHandler = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
action()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<Tooltip title={tooltip}>
|
||||
<InputLabel htmlFor={`toggle-${sha1(title)}`} onClick={clickHandler} className={classes.label}>
|
||||
{title}
|
||||
</InputLabel>
|
||||
</Tooltip>
|
||||
<Tooltip title={tooltip}>
|
||||
<Switch name={`toggle-${sha1(title)}`} checked={value} onChange={action} color="primary" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
label: {
|
||||
flex: '1',
|
||||
paddingTop: theme.spacing(1.5),
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(BooleanSwitch)
|
||||
@@ -0,0 +1,140 @@
|
||||
import * as q from '../../../../backend/src/Model'
|
||||
import React, { useMemo } from 'react'
|
||||
import { AppState } from '../../reducers'
|
||||
import { Base64Message } from '../../../../backend/src/Model/Base64Message'
|
||||
import { connect } from 'react-redux'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicViewModel } from '../../model/TopicViewModel'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { usePollingToFetchTreeNode } from '../helper/usePollingToFetchTreeNode'
|
||||
import { useUpdateComponentWhenNodeUpdates } from '../helper/useUpdateComponentWhenNodeUpdates'
|
||||
const abbreviate = require('number-abbreviate')
|
||||
|
||||
interface Stats {
|
||||
topic: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
container: {
|
||||
width: '100%',
|
||||
height: '224px',
|
||||
backgroundColor: theme.palette.type === 'dark' ? 'rebeccapurple' : '#ebebeb',
|
||||
marginBottom: 0,
|
||||
padding: '8px',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
classes: any
|
||||
tree?: q.Tree<TopicViewModel>
|
||||
}
|
||||
|
||||
function BrokerStatistics(props: Props) {
|
||||
const { tree, classes } = props
|
||||
const sysTopic = usePollingToFetchTreeNode(props.tree, '$SYS')
|
||||
useUpdateComponentWhenNodeUpdates(sysTopic)
|
||||
|
||||
return useMemo(() => {
|
||||
if (!Boolean(sysTopic)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stats: any = {
|
||||
broker: {
|
||||
topic: '$SYS/broker/version',
|
||||
title: 'Broker',
|
||||
},
|
||||
clients: {
|
||||
topic: '$SYS/broker/clients/total',
|
||||
title: 'Clients',
|
||||
},
|
||||
subscriptions: {
|
||||
topic: '$SYS/broker/subscriptions/count',
|
||||
title: 'Subscriptions',
|
||||
},
|
||||
received: {
|
||||
topic: '$SYS/broker/messages/received',
|
||||
title: 'Received',
|
||||
},
|
||||
sent: {
|
||||
topic: '$SYS/broker/messages/sent',
|
||||
title: 'Sent',
|
||||
},
|
||||
received5m: {
|
||||
topic: '$SYS/broker/load/messages/received/5min',
|
||||
title: 'Received last 5min',
|
||||
},
|
||||
sent5m: {
|
||||
topic: '$SYS/broker/load/messages/sent/5min',
|
||||
title: 'Sent 5m',
|
||||
},
|
||||
heap: {
|
||||
topic: '$SYS/broker/heap/current',
|
||||
title: 'Memory',
|
||||
},
|
||||
heapMax: {
|
||||
topic: '$SYS/broker/heap/maximum',
|
||||
title: 'Memory (max)',
|
||||
},
|
||||
}
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
{renderStat(tree, stats.broker)}
|
||||
{renderPair(tree, stats.sent, stats.received)}
|
||||
{renderPair(tree, stats.clients, stats.subscriptions)}
|
||||
{renderPair(tree, stats.sent5m, stats.received5m)}
|
||||
{renderPair(tree, stats.heap, stats.heapMax)}
|
||||
</div>
|
||||
)
|
||||
}, [sysTopic && sysTopic.lastUpdate, props.classes])
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
tree: state.connection.tree,
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps)(BrokerStatistics))
|
||||
|
||||
function renderPair(tree: q.Tree<TopicViewModel>, a: Stats, b: Stats) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>{renderStat(tree, a)}</div>
|
||||
<div style={{ flex: 1 }}>{renderStat(tree, b)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderStat(tree: q.Tree<TopicViewModel>, stat: Stats) {
|
||||
const node = tree.findNode(stat.topic)
|
||||
if (!node || !node.message) {
|
||||
return null
|
||||
}
|
||||
|
||||
const str = node.message.payload ? Base64Message.toUnicodeString(node.message.payload) : ''
|
||||
let value = node.message && node.message.payload ? parseFloat(str) : NaN
|
||||
value = !isNaN(value) ? abbreviate(value) : str
|
||||
|
||||
return (
|
||||
<div key={stat.title}>
|
||||
<Typography>
|
||||
<b>{stat.title}</b>
|
||||
</Typography>
|
||||
<Typography style={{ paddingLeft: '8px' }}>
|
||||
<i>{value}</i>
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import * as React from 'react'
|
||||
import BooleanSwitch from './BooleanSwitch'
|
||||
import BrokerStatistics from './BrokerStatistics'
|
||||
import ChevronRight from '@material-ui/icons/ChevronRight'
|
||||
import TimeLocale from './TimeLocale'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions } from '../../actions'
|
||||
import { shell } from 'electron'
|
||||
import { Theme, withStyles } from '@material-ui/core/styles'
|
||||
import { TopicOrder } from '../../reducers/Settings'
|
||||
|
||||
import {
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Input,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography,
|
||||
Tooltip,
|
||||
} from '@material-ui/core'
|
||||
|
||||
export const autoExpandLimitSet = [
|
||||
{
|
||||
limit: 0,
|
||||
name: 'Collapsed',
|
||||
},
|
||||
{
|
||||
limit: 2,
|
||||
name: 'Few',
|
||||
},
|
||||
{
|
||||
limit: 5,
|
||||
name: 'Some',
|
||||
},
|
||||
{
|
||||
limit: 15,
|
||||
name: 'Most',
|
||||
},
|
||||
{
|
||||
limit: 30,
|
||||
name: 'Most',
|
||||
},
|
||||
{
|
||||
limit: 1e6,
|
||||
name: 'All',
|
||||
},
|
||||
]
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
drawer: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
flexShrink: 0,
|
||||
userSelect: 'none' as 'none',
|
||||
},
|
||||
paper: {
|
||||
width: '300px',
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.text.primary,
|
||||
paddingTop: theme.spacing(1),
|
||||
...theme.mixins.toolbar,
|
||||
},
|
||||
input: {
|
||||
minWidth: '150px',
|
||||
margin: `auto ${theme.spacing(1)} auto ${theme.spacing(2)}px`,
|
||||
},
|
||||
author: {
|
||||
margin: 'auto 8px 8px auto',
|
||||
color: theme.palette.text.hint,
|
||||
cursor: 'pointer' as 'pointer',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
global: typeof globalActions
|
||||
}
|
||||
autoExpandLimit: number
|
||||
classes: any
|
||||
highlightTopicUpdates: boolean
|
||||
selectTopicWithMouseOver: boolean
|
||||
store?: any
|
||||
topicOrder: TopicOrder
|
||||
visible: boolean
|
||||
theme: 'light' | 'dark'
|
||||
}
|
||||
|
||||
class Settings extends React.PureComponent<Props, {}> {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private openGithubPage = () => {
|
||||
shell.openExternal('https://github.com/thomasnordquist')
|
||||
}
|
||||
|
||||
private renderHighlightTopicUpdates() {
|
||||
const { highlightTopicUpdates, actions } = this.props
|
||||
|
||||
return (
|
||||
<BooleanSwitch
|
||||
title="Show Activity"
|
||||
tooltip="Topics blink when a new message arrives"
|
||||
value={highlightTopicUpdates}
|
||||
action={actions.settings.toggleHighlightTopicUpdates}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private selectTopicsOnMouseOver() {
|
||||
const { actions, selectTopicWithMouseOver } = this.props
|
||||
const toggle = () => actions.settings.selectTopicWithMouseOver(!selectTopicWithMouseOver)
|
||||
|
||||
return (
|
||||
<BooleanSwitch
|
||||
title="Quick Preview"
|
||||
tooltip="Select topics on mouse over"
|
||||
value={selectTopicWithMouseOver}
|
||||
action={toggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private toggleTheme() {
|
||||
const { actions, theme } = this.props
|
||||
|
||||
return (
|
||||
<BooleanSwitch
|
||||
title="Dark Mode"
|
||||
tooltip="Enable dark theme"
|
||||
value={theme === 'dark'}
|
||||
action={actions.settings.toggleTheme}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
private renderAutoExpand() {
|
||||
const { classes, autoExpandLimit } = this.props
|
||||
|
||||
const limits = autoExpandLimitSet.map(limit => (
|
||||
<MenuItem key={limit.limit} value={limit.limit}>
|
||||
{limit.limit < 10000 && limit.limit > 0 ? `≤ ${limit.limit} topics` : limit.name}
|
||||
</MenuItem>
|
||||
))
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>
|
||||
Auto Expand
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={autoExpandLimit}
|
||||
onChange={this.onChangeAutoExpand}
|
||||
input={<Input name="auto-expand" id="auto-expand-label-placeholder" />}
|
||||
name="auto-expand"
|
||||
className={classes.input}
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
{limits}
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeAutoExpand = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
this.props.actions.settings.setAutoExpandLimit(parseInt(String(e.target.value), 10))
|
||||
}
|
||||
|
||||
private renderNodeOrder() {
|
||||
const { classes, topicOrder } = this.props
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<InputLabel htmlFor="auto-expand" style={{ flex: '1', marginTop: '8px' }}>
|
||||
Topic Order
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={topicOrder}
|
||||
onChange={this.onChangeSorting}
|
||||
input={<Input name="node-order" id="node-order-label-placeholder" />}
|
||||
displayEmpty={true}
|
||||
name="node-order"
|
||||
className={classes.input}
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
<MenuItem value={TopicOrder.none}>
|
||||
<em>default</em>
|
||||
</MenuItem>
|
||||
<MenuItem value={TopicOrder.abc}>a-z</MenuItem>
|
||||
<MenuItem value={TopicOrder.messages}>{TopicOrder.messages}</MenuItem>
|
||||
<MenuItem value={TopicOrder.topics}>{TopicOrder.topics}</MenuItem>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onChangeSorting = (e: React.ChangeEvent<{ value: unknown }>) => {
|
||||
this.props.actions.settings.setTopicOrder(e.target.value as TopicOrder)
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { classes, actions, visible } = this.props
|
||||
return (
|
||||
<Drawer anchor="left" className={classes.drawer} open={visible} variant="persistent">
|
||||
<div className={classes.paper} tabIndex={0} role="button">
|
||||
<Typography className={classes.title} variant="h6" color="inherit">
|
||||
<IconButton onClick={actions.global.toggleSettingsVisibility}>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
Settings
|
||||
</Typography>
|
||||
<Divider style={{ userSelect: 'none' }} />
|
||||
</div>
|
||||
<div>
|
||||
{this.renderAutoExpand()}
|
||||
{this.renderNodeOrder()}
|
||||
<TimeLocale />
|
||||
{this.renderHighlightTopicUpdates()}
|
||||
{this.selectTopicsOnMouseOver()}
|
||||
{this.toggleTheme()}
|
||||
</div>
|
||||
<Tooltip placement="top" title="App Author">
|
||||
<Typography className={classes.author} onClick={this.openGithubPage}>
|
||||
by Thomas Nordquist
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
<BrokerStatistics />
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
autoExpandLimit: state.settings.get('autoExpandLimit'),
|
||||
topicOrder: state.settings.get('topicOrder'),
|
||||
visible: state.globalState.get('settingsVisible'),
|
||||
highlightTopicUpdates: state.settings.get('highlightTopicUpdates'),
|
||||
selectTopicWithMouseOver: state.settings.get('selectTopicWithMouseOver'),
|
||||
theme: state.settings.get('theme'),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
global: bindActionCreators(globalActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(Settings))
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react'
|
||||
import DateFormatter from '../helper/DateFormatter'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { Input, InputLabel, MenuItem, Select, Theme } from '@material-ui/core'
|
||||
import { settingsActions } from '../../actions'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
function importAll(r: any) {
|
||||
r.keys().forEach(r)
|
||||
}
|
||||
// @ts-expect-error -- webpack require
|
||||
importAll(require.context('moment/locale', true, /\.js$/))
|
||||
|
||||
const moment = require('moment')
|
||||
|
||||
interface Props {
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
}
|
||||
timeLocale: string
|
||||
classes: any
|
||||
}
|
||||
|
||||
function TimeLocaleSettings(props: Props) {
|
||||
const { classes, timeLocale, actions } = props
|
||||
const locales = moment.locales()
|
||||
const date = new Date()
|
||||
const localeMenuItems = locales.map((l: string) => (
|
||||
<MenuItem key={l} value={l}>
|
||||
<div>
|
||||
Locale: <b>{l}</b>, Format:{' '}
|
||||
<b>
|
||||
<DateFormatter date={date} overrideLocale={l} />
|
||||
</b>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
|
||||
function updateLocale(e: React.ChangeEvent<{ value: unknown }>) {
|
||||
const locale = e.target.value ? String(e.target.value) : ''
|
||||
actions.settings.setTimeLocale(locale)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px', display: 'flex' }}>
|
||||
<InputLabel htmlFor="time-locale" style={{ flex: '1', marginTop: '8px' }}>
|
||||
Time Locale
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={timeLocale}
|
||||
onChange={updateLocale}
|
||||
input={<Input name="time-locale" id="time-locale-label-placeholder" />}
|
||||
name="time-locale"
|
||||
className={classes.input}
|
||||
renderValue={v => <span>{String(v)}</span>}
|
||||
style={{ flex: '1' }}
|
||||
>
|
||||
{localeMenuItems}
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
timeLocale: state.settings.get('timeLocale'),
|
||||
}
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
input: {
|
||||
minWidth: '150px',
|
||||
margin: `auto ${theme.spacing(1)} auto ${theme.spacing(2)}px`,
|
||||
},
|
||||
selected: {
|
||||
'& div': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(connect(mapStateToProps, mapDispatchToProps)(TimeLocaleSettings))
|
||||
@@ -0,0 +1,19 @@
|
||||
export enum CodeBlockColors {
|
||||
text = '#080808',
|
||||
background = '#F9F9F9',
|
||||
numeric = '#811F24',
|
||||
boolean = '#811F24',
|
||||
string = '#0B6125',
|
||||
variable = '#234A97',
|
||||
gutters = '#ebebeb',
|
||||
}
|
||||
|
||||
export enum CodeBlockColorsBraceMonokai {
|
||||
text = '#F8F8F2',
|
||||
background = '#272822',
|
||||
numeric = '#AE81FF',
|
||||
boolean = '#AE81FF',
|
||||
string = '#E6DB74',
|
||||
variable = '#A6E22E',
|
||||
gutters = '#2F3129',
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import ShowChart from '@material-ui/icons/ShowChart'
|
||||
import TopicPlot from '../../TopicPlot'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { chartActions } from '../../../actions'
|
||||
import { connect } from 'react-redux'
|
||||
import { Fade, Paper, Popper, Tooltip } from '@material-ui/core'
|
||||
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
|
||||
|
||||
interface Props {
|
||||
treeNode: q.TreeNode<any>
|
||||
classes: any
|
||||
literal: JsonPropertyLocation
|
||||
actions: {
|
||||
chart: typeof chartActions
|
||||
}
|
||||
}
|
||||
|
||||
function ChartPreview(props: Props) {
|
||||
const chartIconRef = React.useRef(null)
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const onClick = React.useCallback(() => {
|
||||
props.actions.chart.addChart({
|
||||
topic: props.treeNode.path(),
|
||||
dotPath: props.literal.path !== '' ? props.literal.path : undefined,
|
||||
})
|
||||
setOpen(false)
|
||||
}, [props.literal.path, props.treeNode])
|
||||
|
||||
const mouseOver = React.useCallback(() => {
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const mouseOut = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const hasEnoughDataToDisplayDiagrams = props.treeNode.messageHistory.count() > 1
|
||||
|
||||
const addChartToPanelButton = hasEnoughDataToDisplayDiagrams ? (
|
||||
<Tooltip title="Add to chart panel">
|
||||
<ShowChart
|
||||
ref={chartIconRef}
|
||||
className={props.classes.icon}
|
||||
onMouseEnter={mouseOver}
|
||||
onMouseLeave={mouseOut}
|
||||
onClick={onClick}
|
||||
data-test-type="ShowChart"
|
||||
data-test={props.literal.path}
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="Add to chart panel, not enough data for preview">
|
||||
<ShowChart
|
||||
onClick={onClick}
|
||||
className={props.classes.icon}
|
||||
style={{ color: '#aaa' }}
|
||||
data-test-type="ShowChart"
|
||||
data-test={props.literal.path}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<span>
|
||||
{addChartToPanelButton}
|
||||
<Popper open={open} anchorEl={chartIconRef.current} placement="left-end">
|
||||
<Fade in={open} timeout={300}>
|
||||
<Paper style={{ width: '300px' }}>
|
||||
{open ? <TopicPlot history={props.treeNode.messageHistory} dotPath={props.literal.path} /> : <span />}
|
||||
</Paper>
|
||||
</Fade>
|
||||
</Popper>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const mapDispatchToProps = (dispatch: any) => {
|
||||
return {
|
||||
actions: {
|
||||
chart: bindActionCreators(chartActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(ChartPreview)
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
interface Props {
|
||||
changes: Array<Diff.Change>
|
||||
classes: { [s: string]: any }
|
||||
nameOfCompareMessage: string
|
||||
}
|
||||
|
||||
function changeAmount(props: Props) {
|
||||
const additions = props.changes.map(change => (change.added === true ? change.count || 0 : 0)).reduce((a, b) => a + b)
|
||||
const deletions = props.changes
|
||||
.map(change => (change.removed === true ? change.count || 0 : 0))
|
||||
.reduce((a, b) => a + b)
|
||||
if (additions === 0 && deletions === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span style={{ display: 'block', textAlign: 'right' }}>
|
||||
<span>
|
||||
Comparing with <b>{props.nameOfCompareMessage}</b> message:
|
||||
<span className={props.classes.additions}>
|
||||
+ {additions} line{additions === 1 ? '' : 's'}
|
||||
</span>
|
||||
,{' '}
|
||||
<span className={props.classes.deletions}>
|
||||
- {deletions} line{deletions === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const style = (theme: Theme) => ({
|
||||
additions: {
|
||||
color: 'rgb(10, 255, 10)',
|
||||
},
|
||||
deletions: {
|
||||
color: 'rgb(255, 10, 10)',
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(style)(changeAmount)
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as diff from 'diff'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import Add from '@material-ui/icons/Add'
|
||||
import ChartPreview from './ChartPreview'
|
||||
import Remove from '@material-ui/icons/Remove'
|
||||
import { JsonPropertyLocation } from '../../../../../backend/src/JsonAstParser'
|
||||
import { lineChangeStyle, trimNewlineRight } from './util'
|
||||
import { Theme } from '@material-ui/core'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
|
||||
interface Props {
|
||||
changes: Array<diff.Change>
|
||||
literalPositions: Array<JsonPropertyLocation>
|
||||
classes: any
|
||||
className: string
|
||||
treeNode: q.TreeNode<any>
|
||||
}
|
||||
|
||||
const style = (theme: Theme) => {
|
||||
const icon = {
|
||||
verticalAlign: 'top',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
marginTop: '2px',
|
||||
borderRadius: '50%',
|
||||
}
|
||||
|
||||
return {
|
||||
icon,
|
||||
iconButton: {
|
||||
...icon,
|
||||
marginTop: '0px',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
padding: '2px',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.contrastText,
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
gutterLine: {
|
||||
textAlign: 'right' as 'right',
|
||||
paddingRight: theme.spacing(0.5),
|
||||
height: '16px',
|
||||
width: '100%',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function tokensForLine(change: diff.Change, line: number, props: Props) {
|
||||
const { classes, literalPositions } = props
|
||||
const literal = literalPositions[line]
|
||||
|
||||
const chartPreview = Boolean(literal) ? (
|
||||
<ChartPreview
|
||||
key="chartPreview"
|
||||
treeNode={props.treeNode}
|
||||
classes={{ icon: props.classes.iconButton }}
|
||||
literal={literal}
|
||||
/>
|
||||
) : null
|
||||
|
||||
if (change.added) {
|
||||
return [chartPreview, <Add key="add" className={classes.icon} />]
|
||||
} else if (change.removed) {
|
||||
return [<Remove key="remove" className={classes.icon} />]
|
||||
} else {
|
||||
return [
|
||||
chartPreview,
|
||||
<div
|
||||
key="placeholder"
|
||||
style={{ width: '12px', display: 'inline-block' }}
|
||||
dangerouslySetInnerHTML={{ __html: ' ' }}
|
||||
/>,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function Gutters(props: Props) {
|
||||
let currentLine = -1
|
||||
const gutters = props.changes
|
||||
.map((change, key) => {
|
||||
return trimNewlineRight(change.value)
|
||||
.split('\n')
|
||||
.map((_, idx) => {
|
||||
currentLine = !change.removed ? currentLine + 1 : currentLine
|
||||
return (
|
||||
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={props.classes.gutterLine}>
|
||||
{tokensForLine(change, currentLine, props)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
|
||||
return (
|
||||
<span>
|
||||
<pre className={props.className}>{gutters}</pre>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(style)(Gutters)
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as diff from 'diff'
|
||||
import * as Prism from 'prismjs'
|
||||
import * as q from '../../../../../backend/src/Model'
|
||||
import * as React from 'react'
|
||||
import DiffCount from './DiffCount'
|
||||
import Gutters from './Gutters'
|
||||
import { isPlottable, lineChangeStyle, trimNewlineRight } from './util'
|
||||
import { JsonPropertyLocation, literalsMappedByLines } from '../../../../../backend/src/JsonAstParser'
|
||||
import { selectTextWithCtrlA } from '../../../utils/handleTextSelectWithCtrlA'
|
||||
import { style } from './style'
|
||||
import { withStyles, Typography } from '@material-ui/core'
|
||||
import 'prismjs/components/prism-json'
|
||||
|
||||
interface Props {
|
||||
treeNode: q.TreeNode<any>
|
||||
previous: string
|
||||
current: string
|
||||
nameOfCompareMessage: string
|
||||
language?: 'json'
|
||||
title?: string
|
||||
classes: any
|
||||
}
|
||||
|
||||
interface State {}
|
||||
|
||||
class CodeDiff extends React.PureComponent<Props, State> {
|
||||
private handleCtrlA = selectTextWithCtrlA({ targetSelector: 'pre ~ pre' })
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
private isValidJson(str: string) {
|
||||
try {
|
||||
JSON.parse(str)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private plottableLiteralsIndexedWithLineNumbers() {
|
||||
const allLiterals = this.isValidJson(this.props.current) ? literalsMappedByLines(this.props.current) || [] : []
|
||||
|
||||
return allLiterals.map((l: JsonPropertyLocation) =>
|
||||
isPlottable(l.value) ? l : undefined
|
||||
) as Array<JsonPropertyLocation>
|
||||
}
|
||||
|
||||
private renderStyledCodeLines(changes: Array<Diff.Change>) {
|
||||
const styledLines = Prism.highlight(this.props.current, Prism.languages.json, 'json').split('\n')
|
||||
let lineNumber = 0
|
||||
|
||||
return changes
|
||||
.map((change, key) => {
|
||||
const hasStyledCode = change.removed !== true
|
||||
const changedLines = change.count || 0
|
||||
if (hasStyledCode && this.props.language === 'json') {
|
||||
const currentLines = styledLines.slice(lineNumber, lineNumber + changedLines)
|
||||
const lines = currentLines.map((html: string, idx: number) => {
|
||||
return (
|
||||
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
|
||||
<span dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
lineNumber += changedLines
|
||||
|
||||
return [<div key={key}>{lines}</div>]
|
||||
}
|
||||
|
||||
return trimNewlineRight(change.value)
|
||||
.split('\n')
|
||||
.map((line, idx) => {
|
||||
return (
|
||||
<div key={`${key}-${idx}`} style={lineChangeStyle(change)} className={this.props.classes.line}>
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})
|
||||
.reduce((a, b) => a.concat(b), [])
|
||||
}
|
||||
|
||||
public render() {
|
||||
const changes = diff.diffLines(this.props.previous, this.props.current)
|
||||
const literalPositions = this.plottableLiteralsIndexedWithLineNumbers()
|
||||
const code = this.renderStyledCodeLines(changes)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: '8px' }}>
|
||||
{this.props.title ? <Typography className={this.props.classes.title}>{this.props.title}</Typography> : null}
|
||||
<div tabIndex={0} onKeyDown={this.handleCtrlA} className={this.props.classes.codeWrapper}>
|
||||
<Gutters
|
||||
className={this.props.classes.gutters}
|
||||
changes={changes}
|
||||
treeNode={this.props.treeNode}
|
||||
literalPositions={literalPositions}
|
||||
/>
|
||||
<pre className={this.props.classes.codeBlock}>{code}</pre>
|
||||
</div>
|
||||
<DiffCount changes={changes} nameOfCompareMessage={this.props.nameOfCompareMessage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(style)(CodeDiff)
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CodeBlockColors, CodeBlockColorsBraceMonokai } from '../CodeBlockColors'
|
||||
import { Theme } from '@material-ui/core'
|
||||
|
||||
export const style = (theme: Theme) => {
|
||||
const codeBlockColors = theme.palette.type === 'light' ? CodeBlockColors : CodeBlockColorsBraceMonokai
|
||||
const codeBaseStyle = {
|
||||
font: "12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace",
|
||||
display: 'inline-grid' as 'inline-grid',
|
||||
margin: '0',
|
||||
padding: '1px 0 0 0',
|
||||
}
|
||||
|
||||
return {
|
||||
title: {
|
||||
font: codeBaseStyle.font,
|
||||
paddingLeft: '4px',
|
||||
backgroundColor: codeBlockColors.gutters,
|
||||
},
|
||||
line: {
|
||||
lineHeight: 'normal' as 'normal',
|
||||
paddingLeft: '4px',
|
||||
width: '100%',
|
||||
height: '16px',
|
||||
},
|
||||
codeWrapper: {
|
||||
display: 'flex',
|
||||
maxHeight: '15em',
|
||||
overflow: 'auto',
|
||||
backgroundColor: `${codeBlockColors.background}`,
|
||||
},
|
||||
gutters: {
|
||||
...codeBaseStyle,
|
||||
width: '33px',
|
||||
backgroundColor: codeBlockColors.gutters,
|
||||
userSelect: 'none' as 'none',
|
||||
},
|
||||
codeBlock: {
|
||||
...codeBaseStyle,
|
||||
width: 'calc(100% - 33px)',
|
||||
backgroundColor: 'inherit !important',
|
||||
'& span': {
|
||||
color: codeBlockColors.text,
|
||||
},
|
||||
'& .token.number': {
|
||||
color: codeBlockColors.numeric,
|
||||
},
|
||||
'& .token.boolean': {
|
||||
color: codeBlockColors.numeric,
|
||||
},
|
||||
'& .token.property': {
|
||||
color: codeBlockColors.variable,
|
||||
},
|
||||
'& .token.string': {
|
||||
color: codeBlockColors.string,
|
||||
},
|
||||
'& .token': {
|
||||
color: codeBlockColors.text,
|
||||
},
|
||||
'& .token.operator': {
|
||||
color: codeBlockColors.text,
|
||||
},
|
||||
'& .token.punctuation': {
|
||||
color: codeBlockColors.text,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export function trimNewlineRight(str: string) {
|
||||
if (str.slice(-1) === '\n') {
|
||||
return str.slice(0, -1)
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
const gutterBaseStyle = {}
|
||||
|
||||
const additionStyle = {
|
||||
...gutterBaseStyle,
|
||||
backgroundColor: 'rgba(10, 255, 10, 0.3)',
|
||||
}
|
||||
|
||||
const deletionStyle = {
|
||||
...gutterBaseStyle,
|
||||
backgroundColor: 'rgba(255, 10, 10, 0.3)',
|
||||
}
|
||||
|
||||
export function lineChangeStyle(change: Diff.Change) {
|
||||
if (change.added === true) {
|
||||
return additionStyle
|
||||
}
|
||||
|
||||
if (change.removed === true) {
|
||||
return deletionStyle
|
||||
}
|
||||
|
||||
return gutterBaseStyle
|
||||
}
|
||||
|
||||
export function toPlottableValue(value: any): number | undefined {
|
||||
if (typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
|
||||
const isNumber = !isNaN(value)
|
||||
const floatVal = parseFloat(value)
|
||||
if (isNumber && !isNaN(floatVal)) {
|
||||
return floatVal
|
||||
}
|
||||
|
||||
const intVal = parseInt(value, undefined)
|
||||
if (isNumber && !isNaN(intVal)) {
|
||||
return intVal
|
||||
}
|
||||
|
||||
// Convert "on" / "off" to int 1 / 0
|
||||
if (typeof value === 'string') {
|
||||
if (/^(on$)|(off$)/i.test(value)) {
|
||||
return value.toLowerCase() === 'on' ? 1 : 0
|
||||
}
|
||||
if (/^[0-9]*,[0-9]+$/.test(value)) {
|
||||
let parsedFloat = parseFloat(value.replace(',', '.'))
|
||||
if (!isNaN(parsedFloat)) {
|
||||
return parsedFloat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlottable(value: any) {
|
||||
return !isNaN(toPlottableValue(value) as any)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user