Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db91637b0a | ||
|
|
7cf2fc886c | ||
|
|
950c9d31ea | ||
|
|
9514f0c2f4 | ||
|
|
e83a4c934e | ||
|
|
0bbd11018b | ||
|
|
62c7b40c3f | ||
|
|
9385312886 | ||
|
|
add74090d3 | ||
|
|
b31754519c | ||
|
|
3e07d35639 | ||
|
|
4c98c7c0e9 | ||
|
|
b25eb9c1ba | ||
|
|
f57c60dbdb | ||
|
|
aace617d87 | ||
|
|
9958208dfe | ||
|
|
90efd23f38 | ||
|
|
3e7bb4883e | ||
|
|
e0698d4936 | ||
|
|
6477dad87e | ||
|
|
5b0591571c | ||
|
|
fad1af30b3 | ||
|
|
27af463511 | ||
|
|
26bf07e40c | ||
|
|
c84fbf98be | ||
|
|
f06b3ed2d1 | ||
|
|
524cd0cf7e | ||
|
|
9515273776 | ||
|
|
b9fc1a5985 | ||
|
|
d898c74f73 | ||
|
|
a9add7f1f6 | ||
|
|
c6b10cd01e | ||
|
|
8af9386594 | ||
|
|
6bc0a79cc5 | ||
|
|
8154414836 | ||
|
|
9078551d57 | ||
|
|
92764064c9 | ||
|
|
fb3d15ba8e | ||
|
|
33b5fc92be | ||
|
|
0aebae8cdb | ||
|
|
5f0b080911 | ||
|
|
3550ace673 | ||
|
|
ea53eafc1f | ||
|
|
e1bc3f6d29 | ||
|
|
724492b217 | ||
|
|
84f5cdea4d | ||
|
|
0a76e2a5d5 | ||
|
|
9ea8c11931 | ||
|
|
45e4e858d7 | ||
|
|
6ec1790267 | ||
|
|
b432c75858 | ||
|
|
073edfb0b7 | ||
|
|
7683d55761 | ||
|
|
e0e9e7a388 | ||
|
|
50f82239de | ||
|
|
84da7a0207 | ||
|
|
33428beed4 | ||
|
|
5c60aef214 | ||
|
|
25f1892382 | ||
|
|
a014f24049 | ||
|
|
35c5eba2ad | ||
|
|
2b160c8d0b | ||
|
|
aa6aa32220 | ||
|
|
685ebf539f | ||
|
|
43e5059f65 | ||
|
|
faeece282c | ||
|
|
2a5dbd5aec | ||
|
|
c60852106e | ||
|
|
291a9ec225 | ||
|
|
0afc6d11cf | ||
|
|
09c0830059 | ||
|
|
75435638f9 | ||
|
|
156492e0ae | ||
|
|
8f8272d48e | ||
|
|
81845f89ea | ||
|
|
82d86d03d8 | ||
|
|
580edf7afb | ||
|
|
8b3c1a38e8 | ||
|
|
7632e39bdd | ||
|
|
3a602a6c36 | ||
|
|
3d964ed575 | ||
|
|
74ec235945 | ||
|
|
1b138ca314 | ||
|
|
bb19be6b79 | ||
|
|
25e6dc2d3c | ||
|
|
7f242e1c2b | ||
|
|
2433088b54 | ||
|
|
9d6246c5f2 | ||
|
|
5d0ef7491b | ||
|
|
ecc6b8e426 | ||
|
|
ae35bdf8dc | ||
|
|
7e159f2c9e | ||
|
|
ffbdf8bd0d | ||
|
|
cf2e553b0e | ||
|
|
b307204305 | ||
|
|
1f019ba17c | ||
|
|
427620d4a8 | ||
|
|
eee698a7a6 | ||
|
|
f23ca2d594 | ||
|
|
135e0bff6b | ||
|
|
c9cc40b80d | ||
|
|
71f8e2052b | ||
|
|
5ee2a93812 | ||
|
|
f6f147eb97 | ||
|
|
f7f69ea8da | ||
|
|
ed381b1399 | ||
|
|
58c0b7864f | ||
|
|
faab721355 | ||
|
|
4e244934c8 | ||
|
|
f7d8f8c36c | ||
|
|
a64bdb2ad3 | ||
|
|
62fe662910 | ||
|
|
2077c8027b | ||
|
|
b782df6e41 | ||
|
|
2b89e20ffd | ||
|
|
c7f4f5e2e7 | ||
|
|
1610a12612 | ||
|
|
e34ee878af | ||
|
|
83099dd0c1 | ||
|
|
a66e3be57f | ||
|
|
df93cc075b | ||
|
|
b0c4559cea | ||
|
|
8af7821321 | ||
|
|
3f8bc556ce | ||
|
|
3218a7bcff | ||
|
|
04499b715e | ||
|
|
d8307ef5fd | ||
|
|
ff2e22684c | ||
|
|
7db3cdab1e | ||
|
|
1d8389eaf8 | ||
|
|
f7ce9ef5ef | ||
|
|
5ca7e141ba | ||
|
|
20a478d324 | ||
|
|
c5dcd00f39 | ||
|
|
c7f52776af | ||
|
|
d60be637bd | ||
|
|
8a25e8c9d3 | ||
|
|
80836e42fe | ||
|
|
75fbf79b63 | ||
|
|
1f5f8bd29a | ||
|
|
2c8d12086b | ||
|
|
dde4c68f91 | ||
|
|
03164bf307 | ||
|
|
9e6b710dc3 | ||
|
|
f8dc580612 | ||
|
|
dd27da8afa | ||
|
|
e7cc1fca42 | ||
|
|
2ee5528123 | ||
|
|
e9f4da5483 | ||
|
|
21bc6c0bb0 | ||
|
|
778d75a911 | ||
|
|
e856626eab | ||
|
|
424c3bc0b6 | ||
|
|
d3950bf00c | ||
|
|
59ddcbfd39 | ||
|
|
876003657d | ||
|
|
e9529d1286 | ||
|
|
7db80934c0 | ||
|
|
06f85b7d16 | ||
|
|
61b1275526 | ||
|
|
5d1c01b6d1 | ||
|
|
7239a577e4 | ||
|
|
2cfc5b2f53 | ||
|
|
895db29800 | ||
|
|
d955242e1c | ||
|
|
b04737205d | ||
|
|
d5c1da842d | ||
|
|
b160fa9875 | ||
|
|
a993faf4da | ||
|
|
e37e057a89 | ||
|
|
d4d2efc654 | ||
|
|
6bb0a119f0 | ||
|
|
98d8163400 | ||
|
|
4f8c7d2935 | ||
|
|
d5acf79e33 | ||
|
|
836780df90 | ||
|
|
f7bbdec0ee | ||
|
|
03ed56c2c5 | ||
|
|
efbfb4fc33 | ||
|
|
48bea58d21 | ||
|
|
ac5977bdab | ||
|
|
ee8008b8ba | ||
|
|
d213d7dd66 | ||
|
|
b766e08e0e | ||
|
|
16f4c76a07 | ||
|
|
d0f38e1fdb | ||
|
|
f64ccca874 | ||
|
|
4488cfac94 | ||
|
|
796d4937ba | ||
|
|
738c80b049 | ||
|
|
4e6af3a864 | ||
|
|
f04095a837 | ||
|
|
9999e163ee | ||
|
|
33ec001541 | ||
|
|
c943e0fc65 | ||
|
|
b52c332e9b | ||
|
|
54cf000a4c | ||
|
|
541704f4c7 | ||
|
|
16c8cb0ff7 | ||
|
|
ae441b2a3d | ||
|
|
c507b99118 | ||
|
|
33a46d7db7 | ||
|
|
540aec0f00 | ||
|
|
be39f4762b | ||
|
|
7e17721d2d | ||
|
|
935b1b023c | ||
|
|
58cecc3f86 | ||
|
|
ba68d1aafa | ||
|
|
f4f9f10a6a | ||
|
|
9c8dab4c4a | ||
|
|
3baef93ed1 | ||
|
|
fba421517a | ||
|
|
3006594a35 | ||
|
|
07c65b9d65 | ||
|
|
2099875c21 | ||
|
|
ce7a962d44 | ||
|
|
2074f51409 | ||
|
|
c1cefea79c | ||
|
|
acdd89ca45 | ||
|
|
3a90a99259 | ||
|
|
b261de26d1 | ||
|
|
cea78411f5 | ||
|
|
6f1e4eac10 | ||
|
|
4f95465278 | ||
|
|
0a6f30b7da | ||
|
|
28feedd2bc | ||
|
|
0f2ea3d9cc | ||
|
|
38f201caeb | ||
|
|
bab9e55ac4 | ||
|
|
239882474a | ||
|
|
43c1ca8429 | ||
|
|
c57a8fe524 | ||
|
|
08f84fed4e | ||
|
|
1674f13b1e | ||
|
|
8ae176c49e | ||
|
|
a332a2332c | ||
|
|
01b0c7068e | ||
|
|
b5f982534d | ||
|
|
90860793ed | ||
|
|
68e502ab1c | ||
|
|
bd5fa6e0be | ||
|
|
21d9368940 | ||
|
|
862cf8a552 | ||
|
|
0225bc7220 | ||
|
|
fc1944afc9 | ||
|
|
c77b71c23a | ||
|
|
15bf19a796 | ||
|
|
cde31af97d | ||
|
|
a4a9f141c8 | ||
|
|
79c625b7df | ||
|
|
00be046ec3 | ||
|
|
c14933b6b8 | ||
|
|
e9f70d68ab | ||
|
|
e2545f89b7 | ||
|
|
a6aeb0a922 | ||
|
|
e623c4fbf2 | ||
|
|
ec345eb4eb | ||
|
|
8ce36830e0 | ||
|
|
0fccd5a66d | ||
|
|
99ee871383 | ||
|
|
b7a5060558 | ||
|
|
e89b402b7a | ||
|
|
f1328a58c4 | ||
|
|
af8fb3c79e | ||
|
|
bf33e1fc3a | ||
|
|
bfe3d6a732 | ||
|
|
25795ab9a0 | ||
|
|
64c8fa37ba | ||
|
|
5c277e7d3c | ||
|
|
f1c7601280 | ||
|
|
269dc0a41f | ||
|
|
db23b47074 | ||
|
|
132b0416ee | ||
|
|
a8729ff6f2 | ||
|
|
cfb31788f2 | ||
|
|
5a20b2d86f | ||
|
|
7d52dbebcd | ||
|
|
df392eb4e0 | ||
|
|
57b6bd5ee8 | ||
|
|
3ac9761b39 | ||
|
|
0927c67734 | ||
|
|
c9d7392d29 | ||
|
|
c369aec44c | ||
|
|
0c8c558a05 | ||
|
|
5c61fb9c2e | ||
|
|
5606d077d5 | ||
|
|
4e10fb6f50 | ||
|
|
11376d1ad2 | ||
|
|
ef6cfa2644 | ||
|
|
437d5f58f9 | ||
|
|
29be09eef4 | ||
|
|
a5a1a972d7 | ||
|
|
82648e8241 | ||
|
|
e4da2c887f | ||
|
|
39f44f7dec | ||
|
|
0f51584493 | ||
|
|
dc50ecc0dc | ||
|
|
59211a2fbf | ||
|
|
f8d355a29c | ||
|
|
1da8fdcd2a | ||
|
|
2dcc3b5699 | ||
|
|
8deec59270 | ||
|
|
071abeaf74 | ||
|
|
6eb53da6b5 | ||
|
|
d605a2f82c | ||
|
|
31db999945 | ||
|
|
9ae193be76 | ||
|
|
fc731af819 | ||
|
|
d21dbf293b | ||
|
|
b6d98b1794 | ||
|
|
448a57b82b | ||
|
|
1d54d5ea0c | ||
|
|
4d793d5998 | ||
|
|
60455c26cb | ||
|
|
183f0605c2 | ||
|
|
37d3cc8b1b | ||
|
|
9532b96f7a | ||
|
|
e1806f0fd5 | ||
|
|
fa235b6378 | ||
|
|
b87219b1e9 | ||
|
|
b5459b56c8 | ||
|
|
ca6d7d2ce0 | ||
|
|
6148c5f0fb | ||
|
|
6e14b2a472 | ||
|
|
307df57df8 | ||
|
|
4a4d95f1ca | ||
|
|
ffc5041349 | ||
|
|
ecda245963 | ||
|
|
6ea67c69b9 | ||
|
|
64d32eb116 | ||
|
|
9f7a1ce770 | ||
|
|
4a3818988c | ||
|
|
1ec6318b62 | ||
|
|
60902a6e66 | ||
|
|
ddda1a266c | ||
|
|
8dc9f351d9 | ||
|
|
d7b4d51186 | ||
|
|
d2334b79de | ||
|
|
96df4db36e | ||
|
|
a72dd1b086 | ||
|
|
89bee21894 | ||
|
|
26a4410f0a | ||
|
|
36fb4c58bf | ||
|
|
cfdf0c537d | ||
|
|
f23e123a56 | ||
|
|
5b4c210e6f | ||
|
|
3dee8797da | ||
|
|
33f48baab4 | ||
|
|
3198f304c3 | ||
|
|
09ac733d45 | ||
|
|
3e611494cc | ||
|
|
8be3c084e5 | ||
|
|
3796640835 | ||
|
|
6e23b29f22 | ||
|
|
22e5c489b2 | ||
|
|
8cdf34c3e7 | ||
|
|
b285558fce | ||
|
|
e536f59347 | ||
|
|
4c472f7e3a | ||
|
|
cc77f2b1a3 | ||
|
|
1c18703e96 | ||
|
|
f017661b3d | ||
|
|
98dd30d984 | ||
|
|
eb3cf1053b | ||
|
|
58e2c6de5f | ||
|
|
7dc66c43aa | ||
|
|
9c6afe2d8c | ||
|
|
6c5b1d2a24 | ||
|
|
fc8239eb85 | ||
|
|
180c6e1914 | ||
|
|
59c97d791f | ||
|
|
d9e1d5e6bd | ||
|
|
0f5746b4a3 | ||
|
|
71c03251fb | ||
|
|
f742ab3d0f | ||
|
|
d3ae12a220 | ||
|
|
d9a41572ac | ||
|
|
e454531999 | ||
|
|
45cf6a9cd8 | ||
|
|
5011314ba5 | ||
|
|
af9d3c40e6 | ||
|
|
03e27abdb6 | ||
|
|
e7fde3fcde | ||
|
|
51c0eeb5df | ||
|
|
0f259d1f7d | ||
|
|
d86f0d6227 | ||
|
|
3bb93efa72 | ||
|
|
652234d7e1 | ||
|
|
44d8fd168e | ||
|
|
2e418bbb4d | ||
|
|
87249b2891 | ||
|
|
dbb15fdbb3 | ||
|
|
04ebb6aba7 | ||
|
|
8a2e42c48c | ||
|
|
db682d5020 | ||
|
|
6649279e3e | ||
|
|
6d1778bb3b | ||
|
|
0484154eae | ||
|
|
99f2b6cb2f | ||
|
|
303741ee28 | ||
|
|
37a3f66d68 | ||
|
|
8e43275c27 | ||
|
|
6dfe40b274 | ||
|
|
49e1c62502 | ||
|
|
a739837c95 | ||
|
|
063f70a55d | ||
|
|
fb6c3d8368 | ||
|
|
10f0eb9e7a | ||
|
|
afdd0ec9cc | ||
|
|
2391244baa | ||
|
|
9e0fbcfa3a | ||
|
|
54e6cc5d32 | ||
|
|
c56d92ee52 | ||
|
|
706e8d3ce7 | ||
|
|
29e57f1384 | ||
|
|
bf7de9ad22 | ||
|
|
e3cbdd35e7 | ||
|
|
2ca4ad507e | ||
|
|
43a8a8580c | ||
|
|
fd02fe2c9e | ||
|
|
3a6d9c4842 | ||
|
|
31e297a37f | ||
|
|
b40aed02b8 | ||
|
|
5e031f64b2 | ||
|
|
f6532b127d | ||
|
|
a383fcf8c5 | ||
|
|
307eb0dd72 | ||
|
|
29db86be7c | ||
|
|
04be0065f3 | ||
|
|
8601357379 | ||
|
|
bb3a4b2294 | ||
|
|
44149bdf2b | ||
|
|
1a2bf56c8c | ||
|
|
63867bc27a | ||
|
|
42a7869750 | ||
|
|
8479a2d091 | ||
|
|
c7357c6fee | ||
|
|
e6fd09b069 | ||
|
|
8cc47d518e | ||
|
|
ae2ed75801 | ||
|
|
7f225e51f1 | ||
|
|
c173f083dd | ||
|
|
3e3356e586 | ||
|
|
70a4ca26ec | ||
|
|
64fd83b999 | ||
|
|
180a81111d | ||
|
|
767881fd6c | ||
|
|
4240722b3a | ||
|
|
36ceaaacba | ||
|
|
bf22f85661 | ||
|
|
e7405bd62c | ||
|
|
2ab6e20162 | ||
|
|
274e41f1ee | ||
|
|
3748d68f2e | ||
|
|
e8efb8acea | ||
|
|
438bbf1b3d | ||
|
|
5812391061 | ||
|
|
07e6bedf71 | ||
|
|
1966848577 | ||
|
|
3953850496 | ||
|
|
aa1de9ef14 | ||
|
|
7dd545fb9d | ||
|
|
c997ab3a8b | ||
|
|
7e95ae879d | ||
|
|
5d4c173099 | ||
|
|
8095eee496 | ||
|
|
9ed4a8ee49 | ||
|
|
520e3a94ca | ||
|
|
9a44032528 | ||
|
|
df8b903757 | ||
|
|
3a814eca14 | ||
|
|
d169ee834e | ||
|
|
c7dab9a565 | ||
|
|
1e0202eaba | ||
|
|
405f58c68e | ||
|
|
ba593a8f94 | ||
|
|
ebf6425fd9 | ||
|
|
8903cf243a | ||
|
|
6333fef797 | ||
|
|
cf557df672 | ||
|
|
c2bbc061bf | ||
|
|
af5f4416a6 | ||
|
|
0692cad336 | ||
|
|
e244f9fcd1 | ||
|
|
dd8c548242 | ||
|
|
137b5cc4a1 | ||
|
|
92f3ee46b6 | ||
|
|
8979bd1f42 | ||
|
|
46abe5a032 | ||
|
|
b294c8ff53 | ||
|
|
11ab25a74e | ||
|
|
33d0e0b7a3 | ||
|
|
48c77ed20b | ||
|
|
64828ca26e | ||
|
|
96231b6f5e | ||
|
|
f1c1ceca91 | ||
|
|
2826b39c90 | ||
|
|
14e5a10a9a | ||
|
|
675827ecce | ||
|
|
599cb57682 | ||
|
|
2bd5da93ff | ||
|
|
4896194b0c | ||
|
|
fae956bec4 | ||
|
|
09e0b011f1 | ||
|
|
62c60601c3 | ||
|
|
461c6c689e | ||
|
|
b7d113753f | ||
|
|
1f274b0697 | ||
|
|
c52a6c66e9 | ||
|
|
74864c1803 | ||
|
|
364f87a6f2 | ||
|
|
a1094f6076 | ||
|
|
f898a09c83 | ||
|
|
c416292714 | ||
|
|
759ab426b6 | ||
|
|
cd3e5ed774 | ||
|
|
ffafcd86b9 | ||
|
|
7e9394e070 | ||
|
|
2a6bd6e50b | ||
|
|
c0f7b94b2f | ||
|
|
62c1151c47 | ||
|
|
8deb1d9913 | ||
|
|
084ab87593 | ||
|
|
977f796cb1 | ||
|
|
07722b7de5 | ||
|
|
b5cac5d3f1 | ||
|
|
da84bf5a47 | ||
|
|
ab7f539ed2 | ||
|
|
ae2d6e8dbd | ||
|
|
1b7fd846a2 | ||
|
|
1e34aeaacc | ||
|
|
8e9ca5b14a | ||
|
|
5338775d02 | ||
|
|
81c260d4da | ||
|
|
62f3c4c9c6 | ||
|
|
ee7441c599 | ||
|
|
3363e3d2f2 | ||
|
|
765018ab74 | ||
|
|
d36774e0b8 | ||
|
|
56bcba9b8d | ||
|
|
ae8f98cf0d | ||
|
|
09871f8ba3 | ||
|
|
02db11e522 | ||
|
|
db090b63ce | ||
|
|
4769c29ea1 | ||
|
|
a6cb5064c5 | ||
|
|
a725e36443 | ||
|
|
0ffbe0f0a5 | ||
|
|
dc146b8988 | ||
|
|
77166f400b | ||
|
|
7dd02aedb4 | ||
|
|
6fbf73ceb7 | ||
|
|
a13b8c88c4 | ||
|
|
b837684a79 | ||
|
|
de2d6080e9 | ||
|
|
775156efdc | ||
|
|
5d5e40369b | ||
|
|
ac353c3cca | ||
|
|
a4d61b5e0e | ||
|
|
2b4eb73bd5 | ||
|
|
b527bf7e0c | ||
|
|
4e8eecfe25 | ||
|
|
faf32c60f1 | ||
|
|
0fcd35a039 | ||
|
|
367f954050 | ||
|
|
49101478cb | ||
|
|
68630fe297 | ||
|
|
dbdd60c6fb | ||
|
|
c841189f00 | ||
|
|
714aec37cc | ||
|
|
121695b5f1 | ||
|
|
fd64fa2ba4 | ||
|
|
bef32bc01b | ||
|
|
f75cb4f853 | ||
|
|
f8e71c493b | ||
|
|
471d74de44 | ||
|
|
8cdcd3168b | ||
|
|
677ccdcc14 | ||
|
|
23e857b940 | ||
|
|
612eca9061 | ||
|
|
c16b4d0db3 | ||
|
|
d6f454374a | ||
|
|
4649197c17 | ||
|
|
c3d2bdc328 | ||
|
|
8f980dcf51 | ||
|
|
a21deb9d41 | ||
|
|
f6535dd91e | ||
|
|
b62ea98c10 | ||
|
|
301efc044e | ||
|
|
5d0b3d1a94 | ||
|
|
25799523d0 | ||
|
|
83ef0f2b3a | ||
|
|
dba92abbc1 | ||
|
|
ec51eb4f90 | ||
|
|
fcc8398d55 | ||
|
|
ba75470a64 | ||
|
|
caa23d3927 | ||
|
|
7e4603ddec | ||
|
|
5004a5a043 | ||
|
|
e67b1c7339 | ||
|
|
6a4c154d75 | ||
|
|
d717a58082 | ||
|
|
982d7e1391 | ||
|
|
8b788ccc8c | ||
|
|
bb280887c6 | ||
|
|
7d402c4245 | ||
|
|
0ddfa50ce4 | ||
|
|
a694353648 | ||
|
|
fbab9d91f6 | ||
|
|
0c95773d21 | ||
|
|
1168f79794 | ||
|
|
6188c0fd1e | ||
|
|
ba1d98dae6 | ||
|
|
8e5b89f06c | ||
|
|
5927919dc4 | ||
|
|
7c7a545e86 | ||
|
|
d1443be757 | ||
|
|
ac49ee1d89 | ||
|
|
463167427f | ||
|
|
57085f500b | ||
|
|
8365c9e0bf | ||
|
|
31115f18fc | ||
|
|
83cf83edea | ||
|
|
b800a1dcb0 | ||
|
|
846a181bb4 | ||
|
|
a0517b6a52 | ||
|
|
c47d16f0d0 | ||
|
|
208d580d96 | ||
|
|
8ead31a150 | ||
|
|
fef2234bed | ||
|
|
9299cf9ef9 | ||
|
|
26decb421f | ||
|
|
4044c1346c | ||
|
|
0c887f4acf | ||
|
|
81934665ef | ||
|
|
5c48a51507 | ||
|
|
b295ebd3ae | ||
|
|
ab699c5685 | ||
|
|
dd9bef6daf | ||
|
|
19bb380f12 | ||
|
|
5aa4d3453f | ||
|
|
59862978de | ||
|
|
48e1ddb9a5 | ||
|
|
9d92b1644b | ||
|
|
04992aa1bc | ||
|
|
98eced6f79 | ||
|
|
ef6fb08cba | ||
|
|
bc9f3287b7 | ||
|
|
16fdecee28 | ||
|
|
4fad02e3c9 | ||
|
|
d3d6d4a2fb | ||
|
|
ce586d6980 | ||
|
|
69feb548e1 | ||
|
|
9290ca16af | ||
|
|
30e5c69dc4 | ||
|
|
ef7f39f19d | ||
|
|
6361d93672 | ||
|
|
855f633e74 | ||
|
|
26afca9280 | ||
|
|
a1d225612e | ||
|
|
80ecc5cfe2 | ||
|
|
b3dcc7d1fc | ||
|
|
166869f6c5 | ||
|
|
520995fab9 | ||
|
|
5878378db7 | ||
|
|
a819b51404 | ||
|
|
a906c1afcf | ||
|
|
b72ec32a92 | ||
|
|
e68be3891a | ||
|
|
340ecb1625 | ||
|
|
37fcbd0728 | ||
|
|
ef4637e5ed | ||
|
|
bbfcb19366 | ||
|
|
86c6488a89 | ||
|
|
1366ebed6c | ||
|
|
68c2005ff3 | ||
|
|
5f9c16099e | ||
|
|
71ca8d9122 | ||
|
|
2fb43a3314 | ||
|
|
0831e8c4e5 | ||
|
|
9d2e4524eb | ||
|
|
1390b4ead6 | ||
|
|
734eaba613 | ||
|
|
26c64c9763 | ||
|
|
86fb7ebcaa | ||
|
|
b2eebeea4e | ||
|
|
794b947c1e | ||
|
|
ec58b1293d | ||
|
|
695948aac9 | ||
|
|
9741c7a66b | ||
|
|
f363544542 | ||
|
|
2f0c7d7c54 | ||
|
|
dddca779c6 | ||
|
|
6fd06f8f5c | ||
|
|
a694d3dd20 | ||
|
|
1f27126215 | ||
|
|
343cefcfec | ||
|
|
fa245e66e9 | ||
|
|
1559d5b924 | ||
|
|
7be2931d09 | ||
|
|
83aa363aa0 | ||
|
|
f5f34448be | ||
|
|
14986933d6 | ||
|
|
8e35c4d922 | ||
|
|
d6608d687c | ||
|
|
436641bb07 | ||
|
|
554128d8e3 | ||
|
|
3c934d9758 | ||
|
|
1e17d90c5d | ||
|
|
fe3e788b89 | ||
|
|
2772b6c520 | ||
|
|
90ec9e2496 | ||
|
|
78eb2edba3 | ||
|
|
a88a1f79d4 | ||
|
|
1255a2560e | ||
|
|
156bca4692 | ||
|
|
e22274760c | ||
|
|
6ded9fa3f7 | ||
|
|
191191e538 | ||
|
|
8d63fadb2a | ||
|
|
2a43f18c20 | ||
|
|
20779914d4 | ||
|
|
fa4b57dfd8 | ||
|
|
90128d0b28 | ||
|
|
81a19552d7 | ||
|
|
801a2dad26 | ||
|
|
5efdde0944 | ||
|
|
2a8b5d2711 | ||
|
|
e43295e2dd | ||
|
|
0e942f09bc | ||
|
|
062504298b | ||
|
|
d30efca930 | ||
|
|
82b67d65e0 | ||
|
|
85efc505d5 | ||
|
|
af15bcb113 | ||
|
|
f4306629db | ||
|
|
173cd294e9 | ||
|
|
076d8f762f | ||
|
|
eddbd55141 | ||
|
|
2991f5339e | ||
|
|
e5b7dbfe8e | ||
|
|
1630f03209 | ||
|
|
9cdd6c669c | ||
|
|
3c87e7be9e | ||
|
|
4b704f5d99 | ||
|
|
31a0f3b642 | ||
|
|
554a135370 | ||
|
|
9f79f4d7fe | ||
|
|
64a08d92ff | ||
|
|
42b62058a3 | ||
|
|
2d35ea1eec | ||
|
|
331b95805d | ||
|
|
b4aa736586 | ||
|
|
ec2078d339 | ||
|
|
16b6d33526 | ||
|
|
96b05c9333 | ||
|
|
83f81c9dca | ||
|
|
ab5c5a9768 | ||
|
|
56ef65276c | ||
|
|
467d948ac1 | ||
|
|
82b335cded | ||
|
|
f9417645d2 | ||
|
|
c8d6e9fa9e | ||
|
|
8f7625c883 | ||
|
|
2752e5fece | ||
|
|
8a96739120 | ||
|
|
99f1e7a7a3 | ||
|
|
688b2ca946 | ||
|
|
71ba45c2f9 | ||
|
|
b2443d1ca2 | ||
|
|
ade07381c1 | ||
|
|
228c0ab57b | ||
|
|
43a081c82d | ||
|
|
c51b900777 | ||
|
|
0cfb6b0ca7 | ||
|
|
51cf1c9ff5 | ||
|
|
05ed8b14f1 | ||
|
|
55c95cba5b | ||
|
|
5fae8eeb7d | ||
|
|
8da0ed6ace | ||
|
|
ab63e3862e | ||
|
|
6683e9853f | ||
|
|
fe59952384 | ||
|
|
96fe2e8a49 | ||
|
|
badb562ebe | ||
|
|
4d95dc8d36 | ||
|
|
6b2de0c719 | ||
|
|
e1c37ba5ca | ||
|
|
ef79c90d75 | ||
|
|
319a71598a | ||
|
|
ce5057e58d | ||
|
|
e8737b4223 | ||
|
|
767f82aa3d | ||
|
|
5f85d9e716 | ||
|
|
74536d4948 | ||
|
|
5979d236bc | ||
|
|
641c8e1d60 | ||
|
|
208611543c | ||
|
|
dfad0dea3d | ||
|
|
f7cd34d9da | ||
|
|
db37b43465 | ||
|
|
617d927472 | ||
|
|
63a6d5e106 | ||
|
|
e58eead1fd | ||
|
|
52d33aef7a | ||
|
|
2aa45ebb3a | ||
|
|
eb730689b0 | ||
|
|
7369449c49 | ||
|
|
22550d9022 | ||
|
|
150209dc8f | ||
|
|
4702b6a00c | ||
|
|
122fe892af | ||
|
|
faaab0d3a1 | ||
|
|
eaccded888 | ||
|
|
273f9a5c71 | ||
|
|
3b58f30f3d | ||
|
|
a4533926a3 | ||
|
|
c4284e5f10 | ||
|
|
c2d386f8ec | ||
|
|
2c52598d1c | ||
|
|
13abd19cf5 | ||
|
|
dc3caff0b5 | ||
|
|
efa88a329d | ||
|
|
605cbf8fc3 | ||
|
|
5bc4c0a993 | ||
|
|
033ce4f6f6 | ||
|
|
bbf0324cb1 | ||
|
|
e0618a487b | ||
|
|
0b539dfafb | ||
|
|
e83bd2e67a | ||
|
|
f09c3695f4 | ||
|
|
3cd5ff5bd7 | ||
|
|
07fac1c321 | ||
|
|
39fde7b75a | ||
|
|
9032a6f70b | ||
|
|
fcb1dd53b1 | ||
|
|
9852d5d06c | ||
|
|
11bfff6736 | ||
|
|
1f9581bc65 | ||
|
|
3c8243ed21 | ||
|
|
d223507785 | ||
|
|
ed03ae279b | ||
|
|
59b9003552 | ||
|
|
c8a1437880 | ||
|
|
937fcbdb17 | ||
|
|
bde3dbfa66 | ||
|
|
3146b8a76b | ||
|
|
bec5422883 | ||
|
|
85f617a0ef | ||
|
|
c55e41a816 | ||
|
|
3c66f77454 | ||
|
|
f82f8fb738 | ||
|
|
3fb69aa921 | ||
|
|
9da13680e5 | ||
|
|
6daf1cf211 | ||
|
|
f4494f379a | ||
|
|
fef7336509 | ||
|
|
5c17b50ba5 | ||
|
|
e8f48ce5ff | ||
|
|
5fdf8f11fc | ||
|
|
cde711ab3e | ||
|
|
981414297c | ||
|
|
a6b5cd1db0 | ||
|
|
477f7cf125 | ||
|
|
9b9469622f | ||
|
|
346d0dd6d4 | ||
|
|
9ff3015e15 | ||
|
|
3ac8a64cdd | ||
|
|
9be2fa335a | ||
|
|
89d336e455 | ||
|
|
9b4b9d8b6b | ||
|
|
e0ae20697b | ||
|
|
7b87af9d4d | ||
|
|
5bb16bb39f | ||
|
|
e64deac9bb | ||
|
|
bbac3fc253 | ||
|
|
48105e7b45 | ||
|
|
65e32fc687 | ||
|
|
f82a81dc9a | ||
|
|
ac76b0088a | ||
|
|
f03e694a92 | ||
|
|
1e0f1d6865 | ||
|
|
3da1dda00f | ||
|
|
3a792024eb | ||
|
|
596c6f799b | ||
|
|
d3d5e5c913 | ||
|
|
86ec7b2757 | ||
|
|
7586b27de3 | ||
|
|
4bc0e5974e | ||
|
|
3be20a22ee | ||
|
|
653e5cb3ec | ||
|
|
5756718433 | ||
|
|
2df48e0d23 | ||
|
|
1a321aaad8 | ||
|
|
1072fcfe9a | ||
|
|
5ad5baa800 | ||
|
|
e6ada84bef | ||
|
|
d59df9ab1f | ||
|
|
25d3150f6f | ||
|
|
d1f1730d77 | ||
|
|
eea163dcfe | ||
|
|
1396906d96 | ||
|
|
3443536d7c | ||
|
|
11ded6c52c | ||
|
|
c79fb57a2d | ||
|
|
5902a6c22a | ||
|
|
25c183ac6e | ||
|
|
5b04b61bb0 | ||
|
|
96c61c7dfb | ||
|
|
69e714c96e | ||
|
|
cf40b2bfda | ||
|
|
6dcca28fae | ||
|
|
c0b2709580 | ||
|
|
cdb7a0cd63 | ||
|
|
0c9b0c5fbe | ||
|
|
dca9444fc6 | ||
|
|
271e4236a0 | ||
|
|
03757a4c4e | ||
|
|
cc404823cc | ||
|
|
e0eab278dd | ||
|
|
46be2e2a9c | ||
|
|
16aeafbfb6 | ||
|
|
1f368b4b37 | ||
|
|
ffb5a4f927 | ||
|
|
8098cee611 | ||
|
|
7ae5995de6 | ||
|
|
c42d91edc6 | ||
|
|
9aee9a3452 | ||
|
|
e32028964b | ||
|
|
7583b78d70 | ||
|
|
28d87c5898 | ||
|
|
7cc98709cc | ||
|
|
7c3db07789 | ||
|
|
967bdbdb98 | ||
|
|
a325ec1f88 | ||
|
|
85f9c10617 | ||
|
|
d80ca96dfa | ||
|
|
b28c918aad | ||
|
|
5cf2486f7f | ||
|
|
a563e0c78b | ||
|
|
cc7222f7ac | ||
|
|
9c47d55de9 | ||
|
|
399b9800c3 | ||
|
|
115ab27f56 | ||
|
|
5c6aacb95c | ||
|
|
bdc4d2f79b | ||
|
|
32d9d76faa | ||
|
|
07bb9f43f1 | ||
|
|
23d9ecb4dc | ||
|
|
a75420a272 | ||
|
|
9ecf431cd8 | ||
|
|
f557670077 | ||
|
|
c355318b03 | ||
|
|
29e0aeee5d | ||
|
|
a2135e93ba | ||
|
|
2203c84e12 | ||
|
|
abf69f6e97 | ||
|
|
eea88d8860 | ||
|
|
b77077b06d | ||
|
|
5df6d0470f | ||
|
|
8dbbd028da | ||
|
|
e6c4792151 | ||
|
|
4c142b8791 | ||
|
|
2079bd7ff0 | ||
|
|
20199e5dc3 | ||
|
|
6a43c2439b | ||
|
|
918f9e2e93 | ||
|
|
9d8a361c48 | ||
|
|
5a34ffc7c0 | ||
|
|
1d2f6a5a61 | ||
|
|
4ed5172b30 | ||
|
|
f7a002edb7 | ||
|
|
8bfc904dc6 | ||
|
|
f923eb17a2 | ||
|
|
7535e55317 | ||
|
|
282cd32d78 | ||
|
|
08c808ea33 | ||
|
|
7c0dfacba9 | ||
|
|
4cf6d9d8fa | ||
|
|
d2786bab86 | ||
|
|
7349b29e71 | ||
|
|
bb0d9044ab | ||
|
|
1cf8948320 | ||
|
|
d7c42e739f | ||
|
|
2cee0f7cba | ||
|
|
08cdbdb312 | ||
|
|
6936e5a3d6 | ||
|
|
af0dcbec44 | ||
|
|
dfd3715409 | ||
|
|
df457ae7fb | ||
|
|
bfac6643d4 | ||
|
|
19ebe8a78c | ||
|
|
b830bd247a | ||
|
|
0343bcabb0 | ||
|
|
b589855d06 | ||
|
|
a24d7f6be5 | ||
|
|
644f6e96be | ||
|
|
f3564a8ad4 | ||
|
|
448285f49b | ||
|
|
d108790325 | ||
|
|
23e5c2f8b1 | ||
|
|
5b67741a0f | ||
|
|
e204be77e5 | ||
|
|
a8b8bfb76c | ||
|
|
56846d2278 | ||
|
|
2068749e81 | ||
|
|
2970729709 | ||
|
|
287e95990b | ||
|
|
43f11e5a4e | ||
|
|
84a68d94ad | ||
|
|
bfcf5c2a7c | ||
|
|
105fd5c36a | ||
|
|
b43b2b3fb7 | ||
|
|
59e9a9efc4 | ||
|
|
ad82245016 | ||
|
|
f941848f60 | ||
|
|
85b1cdf5ae | ||
|
|
29b3c89948 | ||
|
|
21f799de67 | ||
|
|
c3cf3f986f | ||
|
|
d11cdecf48 | ||
|
|
b6f60329a1 | ||
|
|
d130595be7 | ||
|
|
28f6acbc93 | ||
|
|
5dda200b1f | ||
|
|
0a69f83969 | ||
|
|
9af5b64770 | ||
|
|
b5cfc05dae | ||
|
|
fd71b1c752 | ||
|
|
02e3f5f53a | ||
|
|
c9c2aaccc2 | ||
|
|
76c929d244 | ||
|
|
0a51910ce0 | ||
|
|
4c5efe84b2 | ||
|
|
5b112635c9 | ||
|
|
643aaf472b | ||
|
|
d40e3a57b5 | ||
|
|
e7e00b4b7f | ||
|
|
a706381ca0 | ||
|
|
7ee5676630 | ||
|
|
ce2843b801 | ||
|
|
d6c32d0681 | ||
|
|
dcebe2686a | ||
|
|
0bd89aca4b | ||
|
|
d7924f966d | ||
|
|
774ea60b78 | ||
|
|
ed464263cb | ||
|
|
63c3af046d | ||
|
|
cc9169d91a | ||
|
|
c9b9f94b75 | ||
|
|
d71fe73fee | ||
|
|
4c1c28da66 | ||
|
|
81b298793d | ||
|
|
3ee407b1d9 | ||
|
|
c23998affc | ||
|
|
f5890049fa | ||
|
|
c3d443ea91 | ||
|
|
25afd25cc0 | ||
|
|
59de20dac7 | ||
|
|
af58bc9dbc | ||
|
|
2aa0c39888 | ||
|
|
93cec09443 | ||
|
|
29758798d1 | ||
|
|
eadb076f0b | ||
|
|
7e27b82f9f | ||
|
|
43ccf85cbe | ||
|
|
1c903809ea | ||
|
|
ebef87de60 | ||
|
|
34ab89cc99 | ||
|
|
bab908ccf1 | ||
|
|
c855cb2e0e | ||
|
|
3a8ed471e9 | ||
|
|
e73ab8ee56 | ||
|
|
e90a5559be | ||
|
|
5240ad3f6a | ||
|
|
96563b2978 | ||
|
|
30856d7d4e | ||
|
|
3f574ee907 | ||
|
|
16c1441933 | ||
|
|
d55dffcb77 | ||
|
|
05d4fb1b2e | ||
|
|
7777a42d02 | ||
|
|
08832392db | ||
|
|
e718cb2849 | ||
|
|
935280d89b | ||
|
|
92761feba3 | ||
|
|
d5b274850a | ||
|
|
6b49430d11 | ||
|
|
d324ecd02e | ||
|
|
ae72af73fc | ||
|
|
6d7107fc47 | ||
|
|
d473e1dde7 | ||
|
|
a0cff4aa18 | ||
|
|
a98b87b0fa | ||
|
|
a7bddedaa5 | ||
|
|
cd8a936652 | ||
|
|
988f0ec586 | ||
|
|
4fc9312ceb | ||
|
|
c44cebdc8a | ||
|
|
1b498d7efa | ||
|
|
7764a64f2d | ||
|
|
3993a4789b | ||
|
|
c35bc0bc7e | ||
|
|
9ac8b9fdbb | ||
|
|
068db9aaa6 | ||
|
|
ca81cc2676 | ||
|
|
8d12221e74 | ||
|
|
9224f66570 | ||
|
|
6467625f71 | ||
|
|
247b502eb5 | ||
|
|
bea2477e83 | ||
|
|
6d43ebe1b2 | ||
|
|
7a3792046d | ||
|
|
6614779ff0 | ||
|
|
9e74ef10a1 | ||
|
|
ea2f978ff6 | ||
|
|
7134d24564 | ||
|
|
92fb9c913f | ||
|
|
e215a2685f | ||
|
|
2f52bb066f | ||
|
|
1e9fabc823 | ||
|
|
1c5d46f76d | ||
|
|
e942f27d57 | ||
|
|
22a835e714 | ||
|
|
0328ea93ef | ||
|
|
2a32829539 | ||
|
|
65778fbeb5 | ||
|
|
a23ef50ec5 | ||
|
|
8ba09a3b08 | ||
|
|
9785b2fa2c | ||
|
|
b263553054 | ||
|
|
12ae0b8007 | ||
|
|
f5836274d6 | ||
|
|
61ff6bb4cf | ||
|
|
6a1d731e46 | ||
|
|
09a0a425a0 | ||
|
|
e0195ff459 | ||
|
|
9d5839377f | ||
|
|
cbe246259b | ||
|
|
eb4f900ec9 | ||
|
|
802e08a3c1 | ||
|
|
41e4444752 | ||
|
|
0b6bf750f7 | ||
|
|
92c81d2b9d | ||
|
|
ab41e274e3 | ||
|
|
bba777eeb6 | ||
|
|
f3eb9e0409 | ||
|
|
6958287365 | ||
|
|
506423b454 | ||
|
|
4ea4873fd0 | ||
|
|
7e6bd56627 | ||
|
|
8e8e8b9847 | ||
|
|
61ed98b5e8 | ||
|
|
7312e83d9e | ||
|
|
8fecceefc1 | ||
|
|
c58f881e32 | ||
|
|
41334987b1 | ||
|
|
b64e82af36 | ||
|
|
3fd5d13e9e | ||
|
|
23e8ec7177 | ||
|
|
cdbdfeee9e | ||
|
|
b42a3a858e | ||
|
|
295656d024 | ||
|
|
f1ff005a71 | ||
|
|
d9de7df078 | ||
|
|
e51f6a0120 | ||
|
|
52d7a5fe92 | ||
|
|
bcf52da14f | ||
|
|
879f2e0255 | ||
|
|
08bed7e519 | ||
|
|
07e9504a9e | ||
|
|
996b67a4eb | ||
|
|
814fd240da | ||
|
|
535763f829 | ||
|
|
0b65728111 | ||
|
|
84e6c6eecc | ||
|
|
59479bc5ed | ||
|
|
d9924e9a29 | ||
|
|
c38f804ec7 | ||
|
|
29705ad192 | ||
|
|
9560f418cb | ||
|
|
9210c1fce8 | ||
|
|
a825f80d58 | ||
|
|
d3af1c339e | ||
|
|
a3e4255c43 | ||
|
|
a73cf37c44 | ||
|
|
34ddcc6c55 | ||
|
|
1a9ffa0b69 | ||
|
|
2c1421fc59 | ||
|
|
0f9ed1d3e5 | ||
|
|
518f5e9e96 | ||
|
|
b6318c66eb | ||
|
|
0f9c74e966 | ||
|
|
53633be81a | ||
|
|
22c965f8d9 | ||
|
|
6b24b4122a | ||
|
|
4ef90f1796 | ||
|
|
e80fe95256 | ||
|
|
fdcfe27137 | ||
|
|
6e4d6a74d0 | ||
|
|
360d954263 | ||
|
|
7f19d4bfe9 | ||
|
|
cb1cc78f6a | ||
|
|
900a3006ea | ||
|
|
ea3cf4762e | ||
|
|
444a7ceae2 | ||
|
|
84852f1df9 | ||
|
|
ccc9472243 | ||
|
|
83e424911f | ||
|
|
7ef188098a | ||
|
|
2b2e811267 | ||
|
|
a5e22a3dea | ||
|
|
965cd36140 | ||
|
|
bbaadb99d8 | ||
|
|
26e6b478ba | ||
|
|
9c26a92c5c | ||
|
|
bd191693d6 | ||
|
|
199de9e71c | ||
|
|
cb1440120c | ||
|
|
a738d14d89 | ||
|
|
361f3d41c6 | ||
|
|
0a12bc54fc | ||
|
|
78c250d78c | ||
|
|
1c16d2f166 | ||
|
|
91ec135d61 | ||
|
|
42a9fbc4b7 | ||
|
|
a720967889 | ||
|
|
5ac5ff5ae2 | ||
|
|
52ac9306f6 | ||
|
|
12759e8b88 | ||
|
|
82a9c3f459 | ||
|
|
36ccd7f263 | ||
|
|
cae3db6f4a | ||
|
|
970a66d618 | ||
|
|
35c158ad60 | ||
|
|
5be01669fe | ||
|
|
d9a4ea0efc | ||
|
|
3adbcd0e43 | ||
|
|
cf01b04356 | ||
|
|
e84aadb0cf | ||
|
|
a5992d7902 | ||
|
|
cbcac99abe | ||
|
|
cd71a2fe20 | ||
|
|
71c8eca780 | ||
|
|
3d07c8f942 | ||
|
|
ed362c683f | ||
|
|
1746386e06 | ||
|
|
58ef1d25a5 | ||
|
|
9962d7376c | ||
|
|
36c1ad3b29 | ||
|
|
00b7b635fb | ||
|
|
5bf1cd8425 | ||
|
|
aef8d03791 | ||
|
|
2aca5db28b | ||
|
|
0fe689086e | ||
|
|
59a905cee2 | ||
|
|
c8158902e1 | ||
|
|
51a12c2c48 | ||
|
|
569676046f | ||
|
|
8881994004 | ||
|
|
ab167c0f49 | ||
|
|
3c99f2cdf3 | ||
|
|
08e24272df | ||
|
|
94806a633d | ||
|
|
ea31698a62 | ||
|
|
3e1c71672e | ||
|
|
d6d05b11d2 | ||
|
|
0c034fbfd2 | ||
|
|
6bac1e65c2 | ||
|
|
16511efca6 | ||
|
|
a13cb6ed64 | ||
|
|
29a240a1f1 | ||
|
|
c613be1d6a | ||
|
|
860bfb1635 | ||
|
|
57626f6c0b | ||
|
|
e4b0773b81 | ||
|
|
00dec99a23 | ||
|
|
fcc150984f | ||
|
|
be6232e718 | ||
|
|
79b1ea5180 | ||
|
|
2655a81676 | ||
|
|
7e68ace90f | ||
|
|
a31556be11 | ||
|
|
e740d7c750 | ||
|
|
783894ebb7 | ||
|
|
d7c4a12b80 | ||
|
|
6c290f85f3 | ||
|
|
2d2a5cb164 | ||
|
|
257ea4c2e6 | ||
|
|
46e7a31014 | ||
|
|
01660debc2 | ||
|
|
ca96083715 | ||
|
|
bca58b2c5b | ||
|
|
2cd41fb986 | ||
|
|
fd599144ef | ||
|
|
301c772a53 | ||
|
|
c427a1f5c2 | ||
|
|
cd56243581 | ||
|
|
58e4536b36 | ||
|
|
1940005b52 | ||
|
|
2e951b158f | ||
|
|
7bed629304 | ||
|
|
2807e322fb | ||
|
|
0d14a41351 | ||
|
|
9add701f1a | ||
|
|
319f3f56ab | ||
|
|
4699c55a70 | ||
|
|
27b3f10c63 | ||
|
|
14f02c471c | ||
|
|
fcd25c4ce3 | ||
|
|
bd15ad557c | ||
|
|
2b1ebeafe8 | ||
|
|
be223d4534 | ||
|
|
76a9d43ed7 | ||
|
|
a044e90d80 | ||
|
|
9c64ad6443 | ||
|
|
c6ccbd3ff7 | ||
|
|
c47d0dfde5 | ||
|
|
2dc0914ac6 | ||
|
|
c6da795ee0 | ||
|
|
94db2025e6 | ||
|
|
626042cf03 | ||
|
|
51611c98ef | ||
|
|
38e9adab45 | ||
|
|
48d5043dc2 | ||
|
|
1a738ee1e8 | ||
|
|
40e479f55d | ||
|
|
4febe4abd7 | ||
|
|
a5f8654216 | ||
|
|
0cfb3e8010 | ||
|
|
634d654df4 | ||
|
|
16201fee33 | ||
|
|
b0571500c4 | ||
|
|
b6cdebb5c2 | ||
|
|
962a18036e | ||
|
|
359a28f016 | ||
|
|
fc1472e9a4 | ||
|
|
88fc10d1ef | ||
|
|
2c38fca30a | ||
|
|
995b1e3998 | ||
|
|
6745497690 | ||
|
|
81cdb1e152 | ||
|
|
4bed14acb9 | ||
|
|
95b0a8653b | ||
|
|
819ce29a54 | ||
|
|
1a48141442 | ||
|
|
a954efd27a | ||
|
|
29239dfa1a | ||
|
|
8a8f1f292c | ||
|
|
df26c33632 | ||
|
|
ee5548e249 | ||
|
|
742e3883de | ||
|
|
d35bfba84f | ||
|
|
518e270a2c | ||
|
|
ac419c7323 | ||
|
|
6a4ae214d5 | ||
|
|
69854ee85e | ||
|
|
e42020c1be | ||
|
|
b8e1631450 | ||
|
|
8760f40dc9 | ||
|
|
746580fe0d | ||
|
|
caada94f3d | ||
|
|
2ad30b6070 | ||
|
|
8bceda1014 | ||
|
|
2edaef58e8 | ||
|
|
dfb8068877 |
No files matched your search
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
target-branch: "Mythic-v4.0.0"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
target-branch: "Mythic-v4.0.0"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,571 @@
|
||||
# Pulled from Thanatos (https://github.com/MythicAgents/thanatos/blob/rewrite/.github/workflows/image.yml) - MEhrn00
|
||||
|
||||
# Name for the Github actions workflow
|
||||
name: Build and push container images
|
||||
|
||||
on:
|
||||
# Only run workflow when there is a new release published in Github
|
||||
#release:
|
||||
# types: [published]
|
||||
push:
|
||||
branches:
|
||||
- 'Mythic-v4.0.0'
|
||||
- 'master'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
# Variables holding configuration settings
|
||||
env:
|
||||
# Container registry the built container image will be pushed to
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
# Set the container image name to the Github repository name. (its-a-feature/Mythic)
|
||||
MYTHIC_SERVER_IMAGE_NAME: ${{ github.repository }}_server
|
||||
MYTHIC_CLI_IMAGE_NAME: ${{ github.repository }}_cli
|
||||
MYTHIC_POSTGRES_IMAGE_NAME: ${{ github.repository }}_postgres
|
||||
MYTHIC_RABBITMQ_IMAGE_NAME: ${{ github.repository }}_rabbitmq
|
||||
MYTHIC_DOCUMENTATION_IMAGE_NAME: ${{ github.repository }}_documentation
|
||||
MYTHIC_JUPYTER_IMAGE_NAME: ${{ github.repository }}_jupyter
|
||||
MYTHIC_GRAPHQL_IMAGE_NAME: ${{ github.repository }}_graphql
|
||||
MYTHIC_NGINX_IMAGE_NAME: ${{ github.repository }}_nginx
|
||||
MYTHIC_REACT_IMAGE_NAME: ${{ github.repository }}_react
|
||||
|
||||
# Description label for the package in Github
|
||||
IMAGE_DESCRIPTION: "test containers for Mythic development. Do not use this version!"
|
||||
|
||||
# Source URL for the package in Github. This links the Github repository packages list
|
||||
# to this container image
|
||||
IMAGE_SOURCE: ${{ github.server_url }}/${{ github.repository }}
|
||||
|
||||
# License for the container image
|
||||
IMAGE_LICENSE: BSD-3-Clause
|
||||
|
||||
# Set the container image version to the Github release tag
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
# Branch for pushing release changes
|
||||
RELEASE_BRANCH: "Mythic-v4.0.0"
|
||||
|
||||
jobs:
|
||||
# Builds the base container image and pushes it to the container registry
|
||||
mythic_server:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# the following are unique to this job
|
||||
- name: Lowercase the server container image name
|
||||
run: echo "MYTHIC_SERVER_IMAGE_NAME=${MYTHIC_SERVER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Copy version file over
|
||||
run: cp VERSION mythic-docker/src/VERSION
|
||||
- name: Build and push the server container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: mythic-docker
|
||||
file: mythic-docker/.docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_SERVER_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_SERVER_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_postgres:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# the following are unique to this job
|
||||
- name: Lowercase the postgres container image name
|
||||
run: echo "MYTHIC_POSTGRES_IMAGE_NAME=${MYTHIC_POSTGRES_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the postgres container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: postgres-docker
|
||||
file: postgres-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_POSTGRES_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_POSTGRES_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_rabbitmq:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# the following are unique to this job
|
||||
- name: Lowercase the rabbitmq container image name
|
||||
run: echo "MYTHIC_RABBITMQ_IMAGE_NAME=${MYTHIC_RABBITMQ_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the rabbitmq container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: rabbitmq-docker
|
||||
file: rabbitmq-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_RABBITMQ_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_RABBITMQ_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_documentation:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# the following are unique to this job
|
||||
- name: Lowercase the documentation container image name
|
||||
run: echo "MYTHIC_DOCUMENTATION_IMAGE_NAME=${MYTHIC_DOCUMENTATION_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the documentation container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: documentation-docker
|
||||
file: documentation-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_DOCUMENTATION_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_DOCUMENTATION_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_jupyter:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# these two are unique
|
||||
|
||||
- name: Lowercase the jupyter container image name
|
||||
run: echo "MYTHIC_JUPYTER_IMAGE_NAME=${MYTHIC_JUPYTER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the jupyter container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: jupyter-docker
|
||||
file: jupyter-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_JUPYTER_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_JUPYTER_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_graphql:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# these two are unique
|
||||
|
||||
- name: Lowercase the graphql container image name
|
||||
run: echo "MYTHIC_GRAPHQL_IMAGE_NAME=${MYTHIC_GRAPHQL_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the graphql container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: hasura-docker
|
||||
file: hasura-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_GRAPHQL_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_GRAPHQL_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_nginx:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# these two are unique
|
||||
|
||||
- name: Lowercase the nginx container image name
|
||||
run: echo "MYTHIC_NGINX_IMAGE_NAME=${MYTHIC_NGINX_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the nginx container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: nginx-docker
|
||||
file: nginx-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_NGINX_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_NGINX_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
mythic_react:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# these two are unique
|
||||
|
||||
- name: Lowercase the react container image name
|
||||
run: echo "MYTHIC_REACT_IMAGE_NAME=${MYTHIC_REACT_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Build and push the react container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: .
|
||||
file: mythic-react-docker/.docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# update mythic-cli latest after we're sure all the others build properly
|
||||
mythic_cli:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mythic_server
|
||||
- mythic_postgres
|
||||
- mythic_rabbitmq
|
||||
- mythic_documentation
|
||||
- mythic_jupyter
|
||||
- mythic_graphql
|
||||
- mythic_nginx
|
||||
- mythic_react
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
# the following are unique to this job
|
||||
- name: Lowercase the cli container image name
|
||||
run: echo "MYTHIC_CLI_IMAGE_NAME=${MYTHIC_CLI_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Fix the mythic_docker_latest reference to reference the new release tag
|
||||
working-directory: Mythic_CLI/src/cmd/config
|
||||
run: |
|
||||
sed -i "s|^const MythicDockerLatest =.*$|const MythicDockerLatest = \"${VERSION}\"|" constants.go
|
||||
|
||||
- name: Build and push the cli container image
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: Mythic_CLI
|
||||
file: Mythic_CLI/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_CLI_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_CLI_IMAGE_NAME }}:latest
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
# These container metadata labels allow configuring the package in Github
|
||||
# packages. The source will link the package to this Github repository
|
||||
labels: |
|
||||
org.opencontainers.image.source=${{ env.IMAGE_SOURCE }}
|
||||
org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
outputs: type=image,name=target,annotation-index.org.opencontainers.image.description=${{ env.IMAGE_DESCRIPTION }}
|
||||
|
||||
update_files:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- mythic_server
|
||||
- mythic_cli
|
||||
- mythic_postgres
|
||||
- mythic_rabbitmq
|
||||
- mythic_documentation
|
||||
- mythic_jupyter
|
||||
- mythic_graphql
|
||||
- mythic_nginx
|
||||
- mythic_react
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
|
||||
# update names to lowercase
|
||||
- name: Lowercase the server container image name
|
||||
run: echo "MYTHIC_SERVER_IMAGE_NAME=${MYTHIC_SERVER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the cli container image name
|
||||
run: echo "MYTHIC_CLI_IMAGE_NAME=${MYTHIC_CLI_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the postgres container image name
|
||||
run: echo "MYTHIC_POSTGRES_IMAGE_NAME=${MYTHIC_POSTGRES_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the rabbitmq container image name
|
||||
run: echo "MYTHIC_RABBITMQ_IMAGE_NAME=${MYTHIC_RABBITMQ_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the documentation container image name
|
||||
run: echo "MYTHIC_DOCUMENTATION_IMAGE_NAME=${MYTHIC_DOCUMENTATION_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the jupyter container image name
|
||||
run: echo "MYTHIC_JUPYTER_IMAGE_NAME=${MYTHIC_JUPYTER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the graphql container image name
|
||||
run: echo "MYTHIC_GRAPHQL_IMAGE_NAME=${MYTHIC_GRAPHQL_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the nginx container image name
|
||||
run: echo "MYTHIC_NGINX_IMAGE_NAME=${MYTHIC_NGINX_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
- name: Lowercase the react container image name
|
||||
run: echo "MYTHIC_REACT_IMAGE_NAME=${MYTHIC_REACT_IMAGE_NAME,,}" >> ${GITHUB_ENV}
|
||||
# The Dockerfile which Mythic uses to pull in the base container image needs to be
|
||||
# updated to reference the newly built container image
|
||||
- name: Fix the server Dockerfile reference to reference the new release tag
|
||||
working-directory: mythic-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_SERVER_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
|
||||
- name: Fix the cli Dockerfile reference to reference the new release tag
|
||||
working-directory: Mythic_CLI
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_CLI_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
|
||||
- name: Fix the cli Makefile reference to reference the new release tag
|
||||
working-directory: Mythic_CLI
|
||||
run: |
|
||||
sed -i "s|^BUILDER_IMAGE=ghcr\.io.*$|BUILDER_IMAGE=${REGISTRY}/${MYTHIC_CLI_IMAGE_NAME}:${VERSION}|" Makefile
|
||||
|
||||
- name: Fix the mythic_docker_latest reference to reference the new release tag
|
||||
working-directory: Mythic_CLI/src/cmd/config
|
||||
run: |
|
||||
sed -i "s|^const MythicDockerLatest =.*$|const MythicDockerLatest = \"${VERSION}\"|" constants.go
|
||||
|
||||
- name: Fix the postgres Dockerfile reference to reference the new release tag
|
||||
working-directory: postgres-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_POSTGRES_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
|
||||
- name: Fix the rabbitmq Dockerfile reference to reference the new release tag
|
||||
working-directory: rabbitmq-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_RABBITMQ_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
|
||||
- name: Fix the documentation Dockerfile reference to reference the new release tag
|
||||
working-directory: documentation-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_DOCUMENTATION_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
|
||||
- name: Fix the jupyter Dockerfile reference to reference the new release tag
|
||||
working-directory: jupyter-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_JUPYTER_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
- name: Fix the graphql Dockerfile reference to reference the new release tag
|
||||
working-directory: hasura-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_GRAPHQL_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
- name: Fix the nginx Dockerfile reference to reference the new release tag
|
||||
working-directory: nginx-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_NGINX_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
- name: Fix the react Dockerfile reference to reference the new release tag
|
||||
working-directory: mythic-react-docker
|
||||
run: |
|
||||
sed -i "s|^FROM ghcr\.io.*$|FROM ${REGISTRY}/${MYTHIC_REACT_IMAGE_NAME}:${VERSION}|" Dockerfile
|
||||
# Push the changes to the Dockerfile
|
||||
- name: Push the updated base Dockerfile image reference changes
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
uses: EndBug/add-and-commit@v9 # ref: https://github.com/marketplace/actions/add-commit
|
||||
with:
|
||||
# Only add the Dockerfile changes. Nothing else should have been modified
|
||||
add: "['Mythic_CLI/Makefile', 'Mythic_CLI/src/cmd/config/constants.go']"
|
||||
# Use the Github actions bot for the commit author
|
||||
default_author: github_actions
|
||||
committer_email: github-actions[bot]@users.noreply.github.com
|
||||
|
||||
# Set the commit message
|
||||
message: "Bump Mythic Dockerfile tag to match release '${{ env.VERSION }}'"
|
||||
|
||||
# Overwrite the current git tag with the new changes
|
||||
tag: '${{ env.VERSION }} --force'
|
||||
|
||||
# Push the new changes with the tag overwriting the current one
|
||||
tag_push: '--force'
|
||||
|
||||
# Push the commits to the branch marked as the release branch
|
||||
push: origin HEAD:${{ env.RELEASE_BRANCH }} --set-upstream
|
||||
|
||||
# Have the workflow fail in case there are pathspec issues
|
||||
pathspec_error_handling: exitImmediately
|
||||
@@ -25,6 +25,8 @@ venv.bak/
|
||||
# ssl certs
|
||||
ssl/
|
||||
# Mythic files
|
||||
nginx-docker/config/blockips.conf
|
||||
mythic-docker/src/Mythic
|
||||
mythic_access.*
|
||||
mythic_sync/
|
||||
postgres-docker/database/
|
||||
@@ -36,6 +38,7 @@ Docker_Templates/
|
||||
documentation-docker/content/
|
||||
documentation-docker/public/
|
||||
display_output.txt
|
||||
full_attack.json
|
||||
nginx-docker/config/conf.d/services.conf
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
@@ -162,3 +165,13 @@ _ReSharper*/
|
||||
.vscode/
|
||||
|
||||
jupyter-docker/jupyter/MythicExamples/.ipynb_checkpoints/
|
||||
|
||||
nginx-docker/config/blockips.conf
|
||||
/MythicReactUI/build/
|
||||
/MythicReactUI/node_modules/
|
||||
/mythic-react-docker/mythic/public/
|
||||
/.gocache/
|
||||
|
||||
.gocache/
|
||||
MythicReactUI/build
|
||||
MythicReactUI/node_modules
|
||||
@@ -29,7 +29,7 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
## Licenses for other projects used:
|
||||
# Licenses for other projects used:
|
||||
|
||||
## arrgv - https://github.com/astur/arrgv
|
||||
MIT License
|
||||
@@ -55,6 +55,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## https://pkg.go.dev/golang.org/x/mod/semver?tab=licenses
|
||||
|
||||
Copyright (c) 2009 The Go Authors. All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -81,4 +82,97 @@ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
## https://github.com/IonicaBizau/anser/blob/master/LICENSE
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2012-22 Ionică Bizău <[email protected]> (https://ionicabizau.net)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## https://github.com/rubenv/sql-migrate/blob/master/LICENSE
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2014-2021 by Ruben Vermeersch <[email protected]>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
MIT license
|
||||
===========
|
||||
|
||||
Copyright (c) 2017 sql.js authors (see AUTHORS)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
# Some portions of the Makefile taken from:
|
||||
Copyright 2017 Ryusei Yamaguchi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -2,10 +2,16 @@
|
||||
default: linux ;
|
||||
|
||||
linux:
|
||||
cd Mythic_CLI && make && mv mythic-cli ../
|
||||
cd Mythic_CLI && make build_linux && mv mythic-cli ../
|
||||
|
||||
macos:
|
||||
cd Mythic_CLI && make build_binary_macos && mv mythic-cli ../
|
||||
cd Mythic_CLI && make build_macos && mv mythic-cli ../
|
||||
|
||||
macos_local:
|
||||
cd Mythic_CLI && make build_binary_macos_local
|
||||
local:
|
||||
cd Mythic_CLI && make build_local
|
||||
|
||||
linux_docker:
|
||||
cd Mythic_CLI && make build_linux_docker && mv mythic-cli ../
|
||||
|
||||
macos_docker:
|
||||
cd Mythic_CLI && make build_macos_docker && mv mythic-cli ../
|
||||
@@ -0,0 +1,20 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!package.json
|
||||
!package-lock.json
|
||||
!config-overrides.js
|
||||
!public/
|
||||
!public/**
|
||||
!src/
|
||||
!src/**
|
||||
!.env
|
||||
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
node_modules/
|
||||
build/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.npm/
|
||||
.cache/
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:19-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,7 +6,12 @@ COPY . .
|
||||
|
||||
# ==== BUILD =====
|
||||
# Install dependencies (npm ci makes sure the exact versions in the lockfile gets installed)
|
||||
RUN npm ci
|
||||
|
||||
#RUN npm install --legacy-peer-deps
|
||||
RUN npm ci --legacy-peer-deps
|
||||
ENV CHOKIDAR_USEPOLLING=true
|
||||
ENV WATCHPACK_POLLING=true
|
||||
ENV FAST_REFRESH=true
|
||||
ENV WDS_POLLING=true
|
||||
ENV WDS_SOCKET_PATH=ws
|
||||
# ==== RUN =====
|
||||
CMD npm run react-start
|
||||
CMD CHOKIDAR_USEPOLLING=true WATCHPACK_POLLING=true FAST_REFRESH=true WDS_POLLING=true WDS_SOCKET_PATH=ws npm run react-start
|
||||
@@ -0,0 +1,26 @@
|
||||
const webpack = require('webpack');
|
||||
module.exports = function override(config, env) {
|
||||
config.resolve.fallback = {
|
||||
//url: require.resolve('url'),
|
||||
fs: false,
|
||||
assert: require.resolve('assert'),
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
path: require.resolve('path-browserify'),
|
||||
//http: require.resolve('stream-http'),
|
||||
//https: require.resolve('https-browserify'),
|
||||
//os: require.resolve('os-browserify/browser'),
|
||||
//buffer: require.resolve('buffer'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
};
|
||||
/*
|
||||
config.plugins.push(
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
);
|
||||
|
||||
*/
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -5,98 +5,75 @@
|
||||
"homepage": "/new",
|
||||
"author": "@its_a_feature_",
|
||||
"description": "React UI for Mythic C2 Framework",
|
||||
"build": {
|
||||
"appId": "com.itsafeature.mythic",
|
||||
"compression": "normal",
|
||||
"files": [
|
||||
"build/**"
|
||||
],
|
||||
"directories": {
|
||||
"buildResources": "public",
|
||||
"output": "dist"
|
||||
},
|
||||
"mac": {
|
||||
"icon": "build/mythic.svg",
|
||||
"type": "distribution",
|
||||
"target": [
|
||||
"pkg",
|
||||
"dmg"
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"target": "exe"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "public/mythic.svg",
|
||||
"target": [
|
||||
"snap",
|
||||
"AppImage"
|
||||
],
|
||||
"category": "C2"
|
||||
}
|
||||
},
|
||||
"main": "public/electron.js",
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.0.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "^17.0.0",
|
||||
"electron-builder": "^22.14.13",
|
||||
"react-scripts": "^5.0.0"
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"react-app-rewired": "^2.2.1",
|
||||
"react-scripts": "^5.0.1",
|
||||
"stream-browserify": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.3.6",
|
||||
"@date-io/date-fns": "^1.3.13",
|
||||
"@emotion/react": "^11.7.1",
|
||||
"@emotion/styled": "^11.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.15",
|
||||
"@mui/icons-material": "^5.4.1",
|
||||
"@mui/lab": "^5.0.0-alpha.68",
|
||||
"@mui/material": "^5.4.1",
|
||||
"@mui/styles": "^5.4.1",
|
||||
"ace-builds": "^1.4.12",
|
||||
"arrgv": "^1.0.2",
|
||||
"babel-plugin-macros": "^2.8.0",
|
||||
"d3": "^5.16.0",
|
||||
"dagre-d3": "^0.6.4",
|
||||
"date-fns": "^2.22.1",
|
||||
"electron-is-dev": "^2.0.0",
|
||||
"graphql": "^15.4.0",
|
||||
"graphql-ws": "^3.2.0",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"notistack": "^2.0.5",
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/lab": "^7.0.0-beta.11",
|
||||
"@mui/material": "^7.0.2",
|
||||
"@mui/utils": "^7.0.2",
|
||||
"@mui/x-charts": "^8.1.0",
|
||||
"@mui/x-date-pickers": "^8.1.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.6.0",
|
||||
"ace-builds": "^1.40.1",
|
||||
"anser": "^2.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.4.17",
|
||||
"elkjs": "^0.10.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-ws": "^6.0.4",
|
||||
"html-to-image": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"ip6": "^0.2.10",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"randexp": "^0.5.3",
|
||||
"react": "^17.0.1",
|
||||
"react-ace": "^9.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-draggable": "^4.4.4",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-scrollbar-size": "^4.0.0",
|
||||
"react-toastify": "^9.1.1",
|
||||
"react-virtualized": "^9.22.3",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-vtree": "^3.0.0-beta.1",
|
||||
"react-window": "^1.8.6",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-scrollbar-size": "^5.0.0",
|
||||
"react-split": "^2.0.14",
|
||||
"react-toastify": "^11.0.5",
|
||||
"react-tooltip": "^5.28.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-window": "^1.8.11",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"save-svg-as-png": "^1.4.17",
|
||||
"styled-components": "^5.2.1",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
"util": "^0.12.4",
|
||||
"wait-on": "^6.0.0",
|
||||
"ws": "^7.4.1",
|
||||
"yargs-parser": "^18.1.3"
|
||||
"semver": "^7.6.2",
|
||||
"sql.js": "^1.13.0",
|
||||
"styled-components": "^6.1.17",
|
||||
"uuid": "^9.0.1",
|
||||
"webpack": "^5.105.0",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"scripts": {
|
||||
"react-start": "BROWSER=none PORT=3000 WDS_SOCKET_PORT=7443 react-scripts start",
|
||||
"react-build": "react-scripts build",
|
||||
"react-test": "react-scripts test",
|
||||
"react-start": "CHOKIDAR_USEPOLLING=true WATCHPACK_POLLING=true FAST_REFRESH=true WDS_POLLING=true BROWSER=none PORT=3000 WDS_SOCKET_PORT=7443 WDS_SOCKET_PATH=ws react-app-rewired start",
|
||||
"react-build": "react-app-rewired build",
|
||||
"react-test": "react-app-rewired test",
|
||||
"react-eject": "react-scripts eject",
|
||||
"electron-build": "electron-builder build --publish never",
|
||||
"build": "npm run react-build && npm run electron-build",
|
||||
"start": "concurrently \"cross-env BROWSER=none npm run react-start\" \"wait-on http://localhost:3000 && electron .\""
|
||||
"build": "npm run react-build",
|
||||
"start": "BROWSER=none CHOKIDAR_USEPOLLING=true WATCHPACK_POLLING=true FAST_REFRESH=true WDS_POLLING=true PORT=3000 WDS_SOCKET_PORT=7443 WDS_SOCKET_PATH=ws npm run react-start"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
@@ -106,7 +83,12 @@
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"react-app"
|
||||
[
|
||||
"react-app",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
@@ -121,4 +103,4 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
let browserScript;
|
||||
let browserScriptSource;
|
||||
let activeRequestID;
|
||||
|
||||
const serializeError = (error) => ({
|
||||
name: (error && error.name) || "Error",
|
||||
message: (error && error.message) || String(error),
|
||||
stack: error && error.stack,
|
||||
});
|
||||
|
||||
const postLog = (...args) => {
|
||||
try {
|
||||
self.postMessage({type: "log", requestID: activeRequestID, args});
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: "log",
|
||||
requestID: activeRequestID,
|
||||
args: args.map((arg) => String(arg)),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
console.log = postLog;
|
||||
|
||||
self.onmessage = ({data}) => {
|
||||
if(!data || data.type !== "render"){
|
||||
return;
|
||||
}
|
||||
|
||||
const {requestID, script, task, responses} = data;
|
||||
activeRequestID = requestID;
|
||||
let phase = "compile";
|
||||
|
||||
try {
|
||||
if(typeof script === "string" && script !== browserScriptSource){
|
||||
browserScript = undefined;
|
||||
browserScriptSource = undefined;
|
||||
browserScript = Function(`"use strict";return(${script})`)();
|
||||
if(typeof browserScript !== "function"){
|
||||
throw new TypeError("Browser script must evaluate to a function");
|
||||
}
|
||||
browserScriptSource = script;
|
||||
}
|
||||
if(typeof browserScript !== "function"){
|
||||
throw new Error("No browser script is loaded");
|
||||
}
|
||||
|
||||
phase = "execute";
|
||||
const result = browserScript(task, responses);
|
||||
if(result && typeof result.then === "function"){
|
||||
throw new TypeError("Browser scripts must return their display data synchronously");
|
||||
}
|
||||
|
||||
phase = "serialize";
|
||||
self.postMessage({type: "result", requestID, result});
|
||||
} catch (error) {
|
||||
self.postMessage({type: "error", requestID, phase, error: serializeError(error)});
|
||||
} finally {
|
||||
activeRequestID = undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
const electron = require("MythicReactUI/public/electron");
|
||||
const app = electron.app;
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
const path = require("path");
|
||||
const isDev = require("electron-is-dev");
|
||||
let mainWindow;
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 900,
|
||||
height: 680,
|
||||
titleBarStyle: "hidden"
|
||||
});
|
||||
console.log(`file://${path.join(__dirname, "index.html")}`)
|
||||
mainWindow.loadURL(
|
||||
isDev ? "https://192.168.53.139:7443" : `file://${path.join(__dirname, "index.html")}`
|
||||
);
|
||||
mainWindow.on("closed", () => (mainWindow = null));
|
||||
}
|
||||
app.commandLine.appendSwitch('ignore-certificate-errors')
|
||||
app.on("ready", createWindow);
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
app.on("activate", () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
@@ -21,6 +21,31 @@
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Mythic</title>
|
||||
<script>
|
||||
(function () {
|
||||
var resizeObserverLoopErrors = [
|
||||
"ResizeObserver loop limit exceeded",
|
||||
"ResizeObserver loop completed with undelivered notifications."
|
||||
];
|
||||
function isResizeObserverLoopError(message) {
|
||||
return resizeObserverLoopErrors.indexOf(message) !== -1;
|
||||
}
|
||||
window.addEventListener("error", function (event) {
|
||||
if (isResizeObserverLoopError(event.message)) {
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}, true);
|
||||
window.addEventListener("unhandledrejection", function (event) {
|
||||
var reason = event.reason;
|
||||
var message = reason && reason.message ? reason.message : reason;
|
||||
if (isResizeObserverLoopError(message)) {
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.7.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Isolation_Mode" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" viewBox="0 0 720 720" style="enable-background:new 0 0 720 720;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#E01F2F;}
|
||||
</style>
|
||||
<g>
|
||||
<path class="st0" d="M631.71,544.4c-2.42-0.54-7.34-1.83-13.64-4.16c0.33,0.12,0.65,0.26,0.97,0.39
|
||||
C624.37,542.61,628.45,543.68,631.71,544.4z"/>
|
||||
<path class="st0" d="M633.4,544.75L633.4,544.75c-0.53-0.11-1.11-0.23-1.69-0.35C632.8,544.65,633.4,544.75,633.4,544.75z"/>
|
||||
<path class="st0" d="M141.27,535.72C123.48,559.12,99,591.36,97.89,593.2c-1.18,1.97-7.99,13.54-20.39,18.46
|
||||
c-17.32,5.6-21.78-2.86-21.78-2.86s-20.56,0.4-19.7,17.05h34.95v-0.02h33.31c0,0,0.58-14.58,15.16-17.49l60.92-48.82
|
||||
C156.9,555.69,146.15,544.52,141.27,535.72z"/>
|
||||
<g>
|
||||
<path class="st0" d="M487.03,525.19l-76.29,72.09l72.21-76.16c-0.7-0.7-1.42-1.4-2.16-2.12c-9.15-8.9-17.68-13.07-26.83-13.07
|
||||
c-10.91,0-22.71,5.71-37.09,13.4c2.02,14.66,2.11,33.84-7.43,46.78c-0.07,0.09-0.14,0.19-0.21,0.28
|
||||
c-10.71,14.14-19.46,25.74-20.11,26.82c-1.19,1.98-8.11,13.75-20.72,18.61c-0.46,0.18-0.9,0.3-1.33,0.44
|
||||
c-0.51,0.18-1.04,0.33-1.58,0.49c-0.7,0.19-1.4,0.37-2.07,0.49c-0.02,0-0.02,0-0.04,0c-11.13,2.07-16.38-4.44-16.38-4.44
|
||||
s-20.56,0.4-19.7,17.05h34.95v-0.02h33.28c0,0,0.58-14.58,15.16-17.49c0,0,0,0,0.02-0.02c0.05-0.05,0.25-0.19,0.54-0.44l0.44-0.35
|
||||
c6.99-5.78,49.59-41.09,73.3-66.94c3-3.27,5.69-6.39,7.96-9.27c-0.25-0.25-0.49-0.51-0.74-0.76
|
||||
C490.59,528.89,488.89,527.1,487.03,525.19z"/>
|
||||
<path class="st0" d="M384.77,207c0,0-0.02,20.99,7.18,27.11c7.2,6.15,22.41,1.46,32.15-9.76c11.84-13.64,18.61,5.09,18.61,5.09
|
||||
c0.37-14.56-12.78-15.8-12.78-15.8c14.72-7.18,20.65,8.89,20.65,8.89c-0.67-7.2-4.21-15.58-16.17-15.56
|
||||
c-16.51,0.04-18.79,17.6-33.3,20.07C386.6,229.51,384.77,207,384.77,207z"/>
|
||||
<path class="st0" d="M681.85,338.39c-2.6-12.08-7.18-23.27-13-33.56c-5.83-10.29-13.01-19.69-21.41-28.04
|
||||
c-8.38-8.34-18.05-15.77-29.27-21.3c-11.13-5.51-23.25-8.78-35.14-10.08c-11.92-1.3-23.62-0.74-34.79,0.84
|
||||
c-11.15,1.6-21.88,4.23-31.96,7.08c-2.55,0.72-4.85,1.35-7.64,2.23c-1.35,0.42-2.67,0.79-4.04,1.26l-4.07,1.4
|
||||
c-5.44,1.98-11.05,4.44-16.19,7.24c-1.32,0.68-2.53,1.39-3.76,2.09c-1.25,0.7-2.37,1.39-3.53,2.07l-6.46,3.88
|
||||
c-4.11,2.46-8.08,4.64-12.19,6.71c-4.16,2.02-8.38,4-12.78,5.81l-3.28,1.37l-3.49,1.4l-6.92,2.74
|
||||
c-9.15,3.56-18.16,6.76-27.22,9.32c-9.04,2.58-18.12,4.51-27.2,5.64c-9.08,1.12-18.14,1.4-27.13,0.74
|
||||
c-1.12-0.09-2.25-0.14-3.37-0.23l-3.34-0.42l-3.35-0.42c-0.53-0.05-1.18-0.18-1.79-0.26l-1.81-0.28
|
||||
c-4.78-0.77-9.4-1.62-13.96-2.63c-9.11-2.02-17.91-4.69-25.94-8.2c-8.03-3.51-15.24-7.92-20.67-13.21
|
||||
c-5.46-5.3-8.94-11.31-10.55-18.25c-1.6-7.01-1.3-15.05,0.76-22.79c1-3.88,2.42-7.69,4.23-11.31c1.79-3.64,3.93-7.09,6.43-10.24
|
||||
c2.48-3.18,5.25-6.08,8.31-8.57c3.04-2.51,6.32-4.69,9.82-6.39c1.72-0.91,3.58-1.51,5.36-2.28c1.86-0.53,3.69-1.23,5.62-1.56
|
||||
c3.81-0.86,7.76-1.32,11.82-1.26c0.02,0,0.04,0,0.05,0c2.83,0.02,5.62,0.49,8.34,1.21c1.46,0.39,2.97,0.74,4.5,1.04
|
||||
c3.93,0.77,7.39,1.58,10.47,2.44c0.23,0.09,0.46,0.18,0.68,0.26l0.04-0.07c3,0.84,5.62,1.72,7.87,2.58v-0.11
|
||||
c0.86,0.37,1.69,0.76,2.46,1.12c0.02,0,0.02,0,0.04,0.02c10.78,5.22,12.05,9.69,12.05,9.69c0.04,0.09,0.09,0.12,0.12,0.21
|
||||
c2.05,2.79,3.48,5.57,4.95,7.88c5.87,8.99,8.34,11.03,8.34,11.03l1.86-3.83c-7.55-8.34-9.78-32.65-9.78-32.65
|
||||
s15.95-3.3,23.13,0.12c6.95,3.35,0.95,16.12,0.6,16.74c5.65-2.35,11.43-13.79,11.43-13.79c4.74-2.69,3.53-11.17,3.53-11.17
|
||||
c-18.95-22.83-44.53-26.03-65.87-22.14c-2.48,0.04-4.92,0.14-7.34,0.33c-4.5,0.33-9.01,0.4-13.5,0.11
|
||||
c-1.18-0.07-2.35-0.12-3.53-0.16c-6.09-0.18-12.29,0.39-18.37,1.65c-3.07,0.54-6.04,1.51-9.04,2.39
|
||||
c-2.92,1.11-5.88,2.12-8.68,3.51c-5.65,2.65-10.94,6.02-15.77,9.87c-4.85,3.85-9.2,8.24-13.03,13c-3.86,4.74-7.18,9.89-9.96,15.3
|
||||
c-2.79,5.41-5.04,11.1-6.67,17c-3.28,11.78-4.27,24.53-1.62,37.37c1.39,6.41,3.69,12.78,6.92,18.56
|
||||
c3.21,5.8,7.27,11.01,11.73,15.51c4.48,4.51,9.34,8.34,14.36,11.63c5.02,3.28,10.2,6.06,15.44,8.45
|
||||
c10.47,4.78,21.13,8.1,31.77,10.62c5.3,1.26,10.64,2.32,15.86,3.23l1.93,0.33c0.65,0.11,1.23,0.23,1.97,0.32l4.18,0.6l4.2,0.58
|
||||
c1.4,0.14,2.81,0.25,4.21,0.37c11.22,0.97,22.5,0.76,33.56-0.44c22.14-2.37,43.22-8.82,62.78-16.17l7.25-2.76l3.58-1.39l3.74-1.49
|
||||
c4.99-1.97,9.99-4.21,14.96-6.55c4.95-2.41,9.9-5.06,14.54-7.74c4.65-2.69,8.78-5.22,12.57-7.16c3.79-1.98,7.52-3.55,11.61-4.99
|
||||
l3.13-1.02c1.04-0.35,2.16-0.63,3.23-0.95c2.05-0.61,4.65-1.3,6.97-1.93c9.45-2.55,18.49-4.58,27.41-5.74
|
||||
c8.9-1.12,17.61-1.39,25.83-0.39c8.22,1,15.93,3.25,22.93,6.81c6.94,3.51,13.54,8.6,19.44,14.61
|
||||
c5.88,6.01,11.06,12.92,15.12,20.32c4.07,7.38,7.09,15.21,8.68,22.99c1.55,7.76,1.6,15.38,0.02,22.23
|
||||
c-1.6,6.85-4.71,12.98-9.52,18.32c-4.74,5.36-11.08,9.92-18.28,13.4c-7.2,3.48-15.26,5.9-23.39,7.11
|
||||
c-4.06,0.61-8.15,0.91-12.12,0.88c-1,0.02-1.97-0.05-2.95-0.09l-2.81-0.28c-1.91-0.07-3.65-0.51-5.44-0.81
|
||||
c-0.91-0.11-1.74-0.4-2.6-0.65c-0.86-0.26-1.72-0.44-2.58-0.74c-1.69-0.68-3.39-1.18-5.11-2.05l-1.3-0.58l-0.65-0.3l-0.67-0.37
|
||||
l-2.67-1.44c-0.9-0.49-1.86-1.11-2.79-1.63l-1.4-0.84l-1.49-0.95c-3.86-2.42-8.29-5.37-13.01-8.25l-3.65-2.18
|
||||
c-1.21-0.72-2.56-1.42-3.85-2.12c-2.55-1.44-5.36-2.72-8.1-4.02c-0.68-0.33-1.39-0.63-2.11-0.91l-2.14-0.86
|
||||
c-1.44-0.56-2.86-1.18-4.32-1.67l-4.43-1.42c-1.48-0.47-2.95-0.95-4.46-1.26l-4.48-1.07c-0.76-0.18-1.49-0.35-2.25-0.51
|
||||
l-2.27-0.39c-3-0.56-6.04-0.9-9.06-1.25c-6.08-0.54-12.15-0.67-18.18-0.37c-1.49,0.09-2.99,0.25-4.48,0.35l-2.25,0.19l-2.21,0.3
|
||||
l-4.43,0.6l-4.36,0.81c-11.61,2.21-22.62,5.88-32.96,10.41c-5.18,2.25-10.19,4.76-15.08,7.39c-2,1.12-4.02,2.23-6.01,3.39
|
||||
c-36.46,10.5-75.81,5.09-107.56-16.07c-0.84-0.56-1.72-1.16-2.6-1.76c-1.09-0.09-2.04-0.25-3.14-0.35
|
||||
c-3.74-0.49-7.38-0.65-11.06-0.65c-4.23,0-8.38,0.4-12.42,1.09l-2.9,0.51l-2.99,0.74c-0.95,0.19-1.84,0.49-2.84,0.76
|
||||
c-0.95,0.35-1.93,0.54-2.79,0.95c-3.85,1.3-7.48,3.09-11.12,4.99c2.9-2.95,6.18-5.55,9.87-7.57c3.74-2,7.64-3.76,11.71-4.85
|
||||
c4.11-1.14,8.39-1.84,12.57-2.09c2.9-0.16,5.69-0.16,8.62,0c-6.22-7.73-19.79-23.18-31.14-27.08c0,0-39.74-30.17-52.4-39.72
|
||||
c-12.71-9.68-27.17-27.13-24.94-32.86c0,0,9.57-13.96,17.89-16.54c8.27-2.7,10.47-12.28,10.47-12.28s-12.28,3.49-27.57,3.93
|
||||
s-22.23,4.39-22.23,4.39s-8.78-7.04-18.81-8.34c0,0,0.84-30.15,40.64-49.4c39.78-19.18,86.49-44.5,104.42-98.2
|
||||
c-1.49,2.35-24.97,39.6-53.28,50.1c-29.26,10.92-66.43,23.18-78.2,30.64c-11.85,7.43-34.56,32.35-47.7,79.13l-49.31,57.72
|
||||
c0,0,2.58,6.97,6.94,6.53c0,0,1.3,9.57,10.01,5.62c0,0,2.23,10.08,0.46,14.01c-1.76,3.95,4.43,16.16,12.66,18.35l-0.79-4.79
|
||||
c0,0,2.16,5.23,9.57,7.43c0,0-7.83-10.06-4.85-19.69c2.56-7.92,1.53-11.77,1.05-12.91l3.79,7.67c0,0,1.28-10.47-4.04-18.68
|
||||
c1.93,2.49,11.36,12.92,31.26,5.94c1.79-1.62,3.49-3.28,5.04-4.99l2.18-2.53c0.76-0.81,1.32-1.76,2-2.65
|
||||
c1.39-1.7,2.49-3.58,3.83-5.43c0.6-0.95,1.16-1.93,1.7-2.95c0.6-0.95,1.19-1.93,1.9-2.88c1.11-2,2.25-3.99,3.6-6.04
|
||||
c-0.16,2.39-0.49,4.74-0.9,7.08c-0.39,1.19-0.65,2.35-0.98,3.49l-0.65,1.69c-0.21,0.61-0.3,1.16-0.6,1.7
|
||||
c-0.49,1.09-0.98,2.23-1.55,3.28c-0.49,1.14-1.09,2.12-1.84,3.2c-0.6,0.98-1.23,2.09-1.93,3.04l-2.35,2.83
|
||||
c-1.69,1.84-3.58,3.6-5.57,5.15c-0.46,4.23-1.7,10.22-4.39,15.73c-0.39,0.91-0.79,1.76-1.14,2.55c1.19-1,24.15-19.55,60.08-3.06
|
||||
l1.9,0.79l-0.98,1.84l-2.65,4.58c4.79-1.14,9.68-1.19,14.47-0.11c6.41,1.55,11.77,6.09,15.3,11.27c3.69,5.2,5.74,11.22,7.48,17
|
||||
c0.84,2.9,1.79,5.83,3.42,8.24c1.65,2.44,4.39,4.04,7.48,4.5c-3.09-0.05-6.23-1.35-8.18-3.9c-2.05-2.49-3.2-5.43-4.34-8.24
|
||||
c-2.2-5.72-4.39-11.41-8.1-16.03c-3.58-4.6-8.38-8.24-13.91-9.54c-5.53-1.3-11.66-0.49-16.74,1.7l-4.95,2.09l2.55-4.74l3.79-6.83
|
||||
c-7.43-3.04-15.4-4.64-23.32-3.9c-8.62,0.76-16.95,3.99-24.43,8.57c-0.05,0.05-0.11,0.09-0.16,0.16
|
||||
c-9.71,5.83-17.4,14.45-22.93,24.32c-6.08,10.82-13.91,25.13-19.14,35.6c-9.68,19.19-18.35,22.74-18,31.45
|
||||
c0.46,8.73-4.34,38.88-7.78,45.03c0,0,0.79,9.68,10.87,8.71c0,0,0,7.48,10.01,6.57c0,0-6.13-9.17,2.69-13.56
|
||||
c8.78-4.34,14.4-0.39,16.56,4.79c2.2,5.27-4.79,12.85-10.22,8.82l-2.44-3.99c0,0-6.97,11.36,6.53,14.42c0,0,1.35,7.92,15.35,7.48
|
||||
c0,0-7.87-3.93-1.69-10.08c0,0,10.41-3.93,8.32-23.62c0,0,0.25-1.4,1.19-2.25c0.14-1.88,0.32-3.78,0.65-5.62
|
||||
c0.6-3.99,1.33-7.88,3.23-11.82c0.86-1.93,2.05-3.99,4.25-5.57l0.65-0.46l0.54-0.35c0.3-0.25,0.65-0.46,1.04-0.79
|
||||
c0.56-0.49,1.35-1.05,1.84-1.69c2.49-2.55,4.69-5.58,6.48-8.87c1.98-3.2,3.69-6.69,5.18-10.19c-0.3,3.9-1.28,7.64-2.6,11.38
|
||||
c-1.28,3.74-3.04,7.38-5.62,10.66c-0.65,0.84-1.35,1.63-2.11,2.39c-0.39,0.39-0.79,0.68-1.23,1.11l-0.6,0.49l-0.35,0.25
|
||||
l-0.19,0.16c-0.81,0.74-1.62,2.12-2.25,3.74c-1.25,3.14-2.05,6.78-2.55,10.41c-0.35,2.2-0.6,4.34-0.79,6.48
|
||||
c0.76,0.7,1.49,1.6,2.35,2.74c7.13,9.78,0.79,25.18,23.72,36.46c-0.84-2.09-1.69-4.28-2.42-6.48c-2.3-7.08-3.85-14.56-4.23-22.02
|
||||
c-0.26-3.79-0.21-7.57,0-11.38c0.19-3.74,0.79-7.48,1.39-11.22c0.44-1.84,0.74-3.74,1.19-5.53l0.68-2.79l0.81-2.69
|
||||
c0.54-1.79,1.19-3.6,1.88-5.37l0.79-2.55c0.37-0.79,0.56-1.6,0.81-2.49c1.93-6.73,2.83-13.77,1.9-20.63
|
||||
c-0.9-6.78-3.49-13.4-7.78-18.79c-0.98-1.35-2.14-2.65-3.42-3.79c-1.25-1.14-2.65-2.16-4.09-3.09c-1.46-0.9-2.99-1.6-4.64-2.14
|
||||
c-1.55-0.51-3.2-0.79-4.9-0.84c-3.55-0.16-6.94,0.49-10.03,2.35c-1.49,0.9-2.9,2.04-4.04,3.39c-1.25,1.3-2.35,2.84-3.06,4.48
|
||||
c0.91-3.53,3.06-6.88,6.2-9.27c2.93-2.42,6.92-3.74,10.85-3.93c1.95-0.09,3.93,0,5.9,0.46c1.93,0.46,3.88,1.11,5.62,2.09
|
||||
c1.76,0.84,3.44,2,4.93,3.2c1.55,1.23,2.99,2.6,4.28,4.04c2.65,2.95,4.83,6.29,6.34,9.87c1.69,3.58,2.9,7.38,3.34,11.26
|
||||
c1.14,7.78,0.39,15.66-1.95,23.04c-0.25,0.9-0.54,1.83-0.84,2.74l-0.98,2.65c-0.61,1.7-1.25,3.39-1.76,5.09
|
||||
c-1.16,3.39-2.09,6.88-2.9,10.38c-0.65,3.49-1.39,7.02-1.74,10.62c-0.3,3.53-0.6,7.18-0.49,10.76c0,3.6,0.19,7.18,0.54,10.76
|
||||
c0.39,3.6,1.09,7.13,1.83,10.71c0.65,2.79,1.51,5.57,2.35,8.32c0,0.05,0.05,0.05,0.05,0.05s0,0.05,0.05,0.16
|
||||
c1.33,4.13,3.09,8.11,5.13,11.98c2.79,3.88,6.94,8.08,13.26,11.56c6.34,3.42,14.82,6.27,26.18,7.32
|
||||
c41.97,3.93,41.06,13.12,46.77,20.55c0,0,3.93-15.26-4.79-22.74c0,0,6.08-0.46,21.39,14.86c15.3,15.31,25.41,7.88,25.41,7.88
|
||||
s-11.45,1.3-16.6-7.88c-5.27-9.17-7.52-30.56-14.51-34.93v-1.79c-1.09-1.25-2.2-2.55-3.35-3.9c-2.55-3.14-4.83-6.43-7.13-9.87
|
||||
c-2.12-3.39-4.23-6.92-5.83-10.85c3.49,2.39,6.53,5.2,9.48,8.01c2.35,2.41,4.53,4.85,6.73,7.38c2.2,2.6,4.28,5.23,6.23,8.04
|
||||
c3.14,4.53,5.9,9.11,8.13,14.36h0.05c13.72,17.44,32.15,43.62,28.57,55.07c-1.55,4.74-3.79,7.64-6.18,9.38
|
||||
c-6.78,4.88-15.26,0.65-15.26,0.65s-20.58,0.44-19.69,17.05h62.5c0,0,7.43-10.52,16.26-13.96c8.62-3.51-19.37-72.54-19.74-73.48
|
||||
c0.67,0.39,26.39,15.45,73.86-6.53c48.12-22.28,72.19-48.89,104.89-17.03c32.75,31.91,21.83,34.09,121.54,53.79
|
||||
c0,0,34.1,13.12,19.65,38.88c0,0,0.11,0.44,0,1.09v-1.09c-10.22,9.15-16.4,1.3-16.4,1.3s-20.55,0.44-19.63,17.05h67.59
|
||||
c0,0-1.84-10.52,7.32-13.96c9.24-3.53-10.06-27.97-16.16-50.29c-4.58-16.89-4.53-14.75-15.35-16.86c0,0-0.02,0-0.04-0.02
|
||||
c-0.02,0-0.04-0.02-0.07-0.04c-0.05,0-0.16,0-0.25-0.05h-0.25c-0.25-0.05-0.6-0.16-1.09-0.25h-0.05c-0.21-0.05-0.4-0.09-0.61-0.16
|
||||
c-0.11-0.02-0.19-0.05-0.3-0.07s-0.23-0.05-0.35-0.07c-0.05,0-0.11,0-0.19-0.05c-0.11,0-0.21-0.05-0.25-0.05
|
||||
c-0.05-0.04-0.09-0.04-0.16-0.05c-0.02,0-0.04-0.02-0.05-0.02s-0.04-0.02-0.04-0.02c-0.04-0.02-0.04-0.02-0.07-0.04
|
||||
c-0.05,0-0.09-0.05-0.19-0.05h-0.11c-0.09-0.05-0.19-0.05-0.3-0.05c-0.7-0.21-1.55-0.4-2.41-0.65c-0.09,0-0.16-0.05-0.25-0.05
|
||||
c-0.11,0-0.16-0.05-0.25-0.09c-0.26-0.09-0.53-0.18-0.81-0.26c-0.19-0.05-0.39-0.11-0.56-0.18c-0.18-0.07-0.37-0.11-0.54-0.18
|
||||
c-0.18-0.05-0.37-0.11-0.54-0.18c-0.3-0.11-0.6-0.19-0.91-0.26c-1.14-0.4-2.46-0.79-3.74-1.3c-0.16-0.11-0.33-0.16-0.49-0.25
|
||||
c0-0.02-0.02-0.02-0.02-0.02h-0.02c-0.11-0.04-0.18-0.05-0.23-0.09c-0.04-0.02-0.07-0.04-0.11-0.05c0,0,0,0-0.02-0.02
|
||||
s-0.05-0.04-0.09-0.05c-8.9-3.34-20.58-8.85-31.89-17.65c0,0-0.04-0.04-0.11-0.09c-3.9-3.04-7.76-6.44-11.43-10.29
|
||||
c-0.04-0.04-0.07-0.07-0.11-0.11c-13.26-12.8-34.72-36.3-47.31-64.59c-0.35-0.79-0.74-1.58-1.12-2.35c0-0.02-0.02-0.02-0.02-0.02
|
||||
c-3.25-6.37-7.92-12.15-13.49-17.37c-0.3-0.28-0.6-0.56-0.9-0.83c0.02,0,0.04,0.02,0.05,0.02c-4.79-4.69-10.1-8.9-15.61-12.66
|
||||
l2.81,0.84c0.93,0.3,1.84,0.7,2.77,1.05l1.39,0.53c0.46,0.16,0.93,0.35,1.39,0.58c1.83,0.88,3.67,1.63,5.53,2.69
|
||||
c0.93,0.51,1.84,0.95,2.81,1.51l2.88,1.69c0.95,0.53,2,1.21,2.99,1.83l3.09,1.91l3.21,2.02l3.44,2.12
|
||||
c9.15,5.71,21.35,11.94,34.28,14.31l4.81,0.83c1.62,0.32,3.18,0.35,4.78,0.51l4.76,0.37c1.56,0.04,3.13,0.11,4.65,0.07
|
||||
c6.16-0.02,12.2-0.56,18.16-1.53c11.87-1.93,23.43-5.51,34.4-10.98c5.5-2.72,10.82-5.97,15.89-9.75
|
||||
c5.08-3.76,9.85-8.15,14.14-13.1c4.3-4.93,8.08-10.5,11.08-16.47c3-5.99,5.25-12.35,6.64-18.83
|
||||
C684.92,363.52,684.41,350.44,681.85,338.39z M379.22,175.86l15,10.55l-23.46-8.52L379.22,175.86z M85.99,279.4l11.61-13.42h8.43
|
||||
L85.99,279.4z M150.4,270.47l5.6-12.15l29.31-10.52l-23.99,11.36L150.4,270.47z M70.99,449.54l11.61-13.36h8.52L70.99,449.54z
|
||||
M170.54,512c-3.69-5.58-5.64-12.36-6.04-19.11c-0.25-3.34-0.05-6.73,0.35-10.06c0.25-0.86,0.35-1.7,0.54-2.6
|
||||
c0.19-1.05,0.4-1.6,0.56-2.44c0.44-1.49,0.68-2.99,0.98-4.53c1.14-6.18,1.69-12.52,1.79-18.81c0.32-12.61-1.39-25.32-4.64-37.48
|
||||
c-0.84-3.09-1.84-6.04-2.99-8.92c-1.14-2.95-2.6-5.74-4.23-8.32c-1.7-2.65-3.69-5.08-6.13-7.02c-1.25-0.95-2.53-1.81-3.99-2.49
|
||||
c-1.44-0.65-2.95-1.16-4.53-1.25c3.2-0.46,6.59,0.65,9.48,2.35c1.74,0.98,3.39,2.23,4.79,3.6c0.65-1.46,1.19-2.9,1.44-4.41
|
||||
c0.35-1.53,0.44-3.09,0.44-4.64c-0.09-3.04-0.79-6.04-1.93-8.87c-1.09-2.84-2.6-5.58-4.34-8.18c-1.4-2.18-2.95-4.34-4.65-6.27
|
||||
l-2.48,0.81c-1.25,0.46-2.55,0.95-3.83,1.39c-1.26,0.54-2.46,1.09-3.69,1.65l-1.7,0.98c-0.6,0.3-1.19,0.6-1.79,1
|
||||
c-2.25,1.39-4.28,3.04-5.94,5.09c-0.51,0.54-0.84,1.09-1.16,1.63c-0.25,0.3-0.46,0.6-0.6,0.9c-0.09,0.3-0.35,0.6-0.44,0.91
|
||||
c-0.54,1.23-0.9,2.53-1.05,3.99c-0.3-1.4-0.3-2.9-0.11-4.36c0.11-0.68,0.35-1.44,0.54-2.12c0.35-0.65,0.6-1.46,0.95-2.05
|
||||
c0.74-1.39,1.55-2.53,2.6-3.74l1.44-1.63c0.61-0.49,1.16-0.95,1.76-1.46c0.54-0.54,1.19-0.93,1.74-1.33l1.84-1.3
|
||||
c1.25-0.65,2.55-1.46,3.83-2.05c1.39-0.6,2.69-1.25,4.04-1.76l4.09-1.44l1.35-0.46l1,1.04c1.28,1.35,2.25,2.65,3.28,3.99
|
||||
c1.05,1.35,2,2.74,2.79,4.2c1.79,2.88,3.2,6.02,4.13,9.41c0.4,1.6,0.79,3.28,0.9,5.04c0.16,1.74,0.05,3.49-0.16,5.18
|
||||
c-0.35,1.69-0.79,3.34-1.49,4.88c-0.81,1.46-1.7,2.84-2.76,4c0.98,0.95,1.84,1.9,2.76,2.97c2.12,2.6,3.88,5.44,5.43,8.43
|
||||
c1.55,2.95,2.84,6.09,3.88,9.17c2.2,6.29,3.69,12.71,4.76,19.25c0.95,6.53,1.33,13.15,1.14,19.79
|
||||
c-0.16,6.62-0.98,13.29-2.46,19.79c-0.35,1.6-0.9,3.2-1.39,4.83c-0.19,0.76-0.54,1.69-0.68,2.2c-0.19,0.68-0.35,1.49-0.46,2.2
|
||||
c-0.65,2.99-1,6.11-1,9.22c0,3.09,0.35,6.18,1.05,9.18c0.65,3.09,1.63,5.99,3.09,8.82c1.44,2.74,3.28,5.34,5.43,7.67
|
||||
c2.11,2.34,4.53,4.5,7.18,6.32C179.46,522.03,174.23,517.67,170.54,512z M207.58,458.21c0.25,2.49,0.76,4.97,1.35,7.57
|
||||
c0.65,2.48,1.6,4.93,2.53,7.48c-2.04-1.79-3.79-4.04-5.18-6.43c-1.35-2.44-2.41-5.02-3.2-7.81c-0.68-2.69-1.04-5.5-1.23-8.29
|
||||
c-0.16-2.83,0-5.62,0.35-8.36c0.6-5.6,2.2-10.98,4.34-16c2.2-4.99,4.83-9.78,8.53-13.91c-1.49,5.18-3.04,10.26-4.25,15.4
|
||||
c-1.23,5.04-2.18,10.12-2.9,15.21C207.32,448.1,207.18,453.17,207.58,458.21z M601.46,548.79c5.23,0.49,10.36,1.46,15.44,2.9
|
||||
c2.49,0.76,4.99,1.65,7.34,2.79c2.39,1.19,4.69,2.6,6.73,4.43l1.46,1.55c0.39,0.49,0.74,0.98,1.19,1.49
|
||||
c0.79,1.05,1.65,2.11,2.46,3.2c1.49,2.09,3.04,4.23,4.37,6.5c2.65,4.48,5.08,9.11,7.18,14l-0.19,0.11
|
||||
c-3.2-4.2-6.09-8.53-8.97-12.82l-4.36-6.48c-0.65-1.09-1.39-2.14-2.12-3.25c-0.4-0.49-0.81-1.04-1.11-1.6l-1.14-1.35
|
||||
c-1.84-1.74-3.9-3.23-6.13-4.37c-2.3-1.25-4.64-2.23-7.13-3.09c-4.83-1.74-9.87-2.93-15.07-3.74L601.46,548.79z M438.98,462.85
|
||||
c-1.6,0.95-3.04,2.09-4.39,3.28c-1.35,1.14-2.6,2.55-3.69,3.99c-0.6,0.65-1.05,1.39-1.55,2.16c-0.46,0.76-0.9,1.55-1.19,2.23v0.05
|
||||
l-0.16,0.21c-2.55,4.07-5.34,8.08-8.59,11.68c-1.39,1.93-3.18,3.64-4.88,5.43c-0.84,0.84-1.69,1.6-2.6,2.49
|
||||
c-0.95,0.79-1.74,1.63-2.74,2.39c-7.39,6.34-15.72,11.52-24.53,15.51c-8.78,4.04-18.16,6.78-27.57,8.52
|
||||
c-4.79,0.84-9.48,1.46-14.29,1.65c-2.46,0.16-4.85,0.19-7.24,0.16c-2.35-0.05-4.69-0.16-7.04-0.46
|
||||
c9.41-1.05,18.65-2.84,27.61-5.34c4.55-1.19,8.92-2.69,13.36-4.28c4.28-1.6,8.62-3.34,12.77-5.39
|
||||
c8.32-3.99,16.24-8.82,23.37-14.59c7.24-5.72,13.66-12.42,19.44-19.74c1.05-1.79,2.25-3.2,3.64-4.55
|
||||
c1.35-1.44,2.95-2.69,4.58-3.74c1.65-1.09,3.34-1.98,5.15-2.63c1.79-0.76,3.74-1.16,5.57-1.51c-0.84,0.4-1.79,0.7-2.58,1.19
|
||||
C440.67,461.95,439.77,462.35,438.98,462.85z M507.47,503.47l2.49,3.6c0.84,1.11,1.6,2.28,2.49,3.39l2.65,3.34l1.39,1.65
|
||||
l1.39,1.49c1.02,1.05,2,2.05,2.99,3.09l3.09,2.84c1.09,1.05,2.2,1.9,3.28,2.79c1.09,0.9,2.2,1.79,3.39,2.63
|
||||
c4.53,3.39,9.43,6.39,14.51,8.94c5.18,2.55,10.57,4.74,16.16,6.18l-0.16,0.54c-5.8-0.98-11.38-2.79-16.7-5.09
|
||||
c-5.34-2.23-10.57-4.97-15.44-8.22c-1.32-0.68-2.49-1.63-3.71-2.49c-1.19-0.9-2.34-1.69-3.48-2.67l-3.35-2.84
|
||||
c-1.09-0.93-2.2-1.98-3.28-2.99l-1.55-1.53l-1.49-1.65l-3.04-3.28c-0.95-1.14-1.81-2.28-2.69-3.49l-2.69-3.42l-1.3-1.65
|
||||
l-1.39-1.55c-0.86-1.09-1.84-1.98-2.79-2.99c-2.05-1.83-4.18-3.64-6.43-5.18c-2.3-1.62-4.58-2.95-7.08-4.23
|
||||
c-2.39-1.25-4.88-2.46-7.52-3.39c-10.27-4.04-21.58-5.9-33.12-6.2l-0.09-0.6c5.69-1.55,11.63-2.05,17.54-1.81
|
||||
c2.93,0.11,5.99,0.46,8.87,0.91c2.99,0.49,5.94,1.3,8.82,2.2c1.4,0.46,2.79,0.98,4.2,1.55c1.39,0.6,2.79,1.23,4.14,1.98
|
||||
c1.33,0.65,2.58,1.46,3.93,2.3c0.6,0.39,1.25,0.84,1.9,1.23c0.61,0.51,1.21,0.95,1.81,1.4C500.04,494,504.13,498.55,507.47,503.47
|
||||
z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1007 B |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 305 KiB |
@@ -1,78 +1,403 @@
|
||||
import { makeVar } from '@apollo/client';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import React from 'react';
|
||||
import { snackActions } from './components/utilities/Snackbar';
|
||||
import {restartWebsockets} from "./index";
|
||||
import {snackActions} from "./components/utilities/Snackbar";
|
||||
import {mergeRefreshedUser} from "./userState";
|
||||
|
||||
export const meState = makeVar({loggedIn:false, user: null, access_token: null, refresh_token: null});
|
||||
export const menuOpen = makeVar(false);
|
||||
|
||||
const meHookQuery = gql`
|
||||
query meHookQuery{
|
||||
meHook{
|
||||
status
|
||||
error
|
||||
current_operation_id
|
||||
current_operation
|
||||
export const alertCount = makeVar(0);
|
||||
export const taskTimestampDisplayFieldOptions = [
|
||||
{
|
||||
name: "timestamp",
|
||||
display: "Latest Timestamp for anything task related"
|
||||
},
|
||||
{
|
||||
name: "status_timestamp_preprocessing",
|
||||
display: "When Operator Submitted Task"
|
||||
},
|
||||
{
|
||||
name: "status_timestamp_processing",
|
||||
display: "When Agent Picked up Task",
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const MeHook = () => {
|
||||
const {data} = useQuery(meHookQuery, {fetchPolicy: "no-cache"});
|
||||
React.useEffect( () => {
|
||||
console.log("MeHook mounted")
|
||||
}, [])
|
||||
if(data) {
|
||||
console.log(data);
|
||||
if(data.meHook.status === "success"){
|
||||
try{
|
||||
if(data.meHook.current_operation_id !== meState().user.current_operation_id || data.meHook.current_operation !== meState().user.current_operation){
|
||||
console.log("updating meState");
|
||||
meState({...meState(), user: {...meState().user, current_operation_id: data.meHook.current_operation_id, current_operation: data.meHook.current_operation}});
|
||||
localStorage.setItem("user", JSON.stringify(meState().user));
|
||||
|
||||
} else {
|
||||
//console.log("no difference, not updating meState")
|
||||
}
|
||||
|
||||
}catch(error){
|
||||
console.log("error trying to parse meHook and meState data", error);
|
||||
}
|
||||
|
||||
} else {
|
||||
snackActions.error("Failed to get current operator information: " + data.meHook.error)
|
||||
]
|
||||
export const taskingDisplayFieldOptions = [
|
||||
{
|
||||
name: "timestamp",
|
||||
display: "Timestamp",
|
||||
description: "Show the configured task timestamp."
|
||||
},
|
||||
{
|
||||
name: "task",
|
||||
display: "Task number",
|
||||
description: "Show the T-number link for each task."
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
display: "Username",
|
||||
description: "Show the operator that issued the task."
|
||||
},
|
||||
{
|
||||
name: "callback",
|
||||
display: "Callback number",
|
||||
description: "Show the C-number link for the task callback."
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
display: "Host",
|
||||
description: "Show the callback host."
|
||||
},
|
||||
{
|
||||
name: "ip",
|
||||
display: "IP address",
|
||||
description: "Show the callback primary IP."
|
||||
},
|
||||
{
|
||||
name: "groups",
|
||||
display: "Callback groups",
|
||||
description: "Show the callback's tree groups."
|
||||
},
|
||||
{
|
||||
name: "payload_type",
|
||||
display: "Payload type",
|
||||
description: "Show the task payload type."
|
||||
},
|
||||
];
|
||||
export const defaultTaskingDisplayFields = ["timestamp", "task", "username", "callback", "payload_type"];
|
||||
export const normalizeTaskingDisplayFields = (fields) => {
|
||||
if(!Array.isArray(fields)){
|
||||
return [...defaultTaskingDisplayFields];
|
||||
}
|
||||
const validFieldNames = taskingDisplayFieldOptions.map((option) => option.name);
|
||||
return fields.reduce( (prev, fieldName) => {
|
||||
if(validFieldNames.includes(fieldName) && !prev.includes(fieldName)){
|
||||
return [...prev, fieldName];
|
||||
}
|
||||
} else {
|
||||
}
|
||||
return meState();
|
||||
return prev;
|
||||
}, []);
|
||||
}
|
||||
export const taskingContextFieldsOptions = ["impersonation_context", "cwd", "user", "host", "ip", "pid", "process_short_name", "extra_info", "architecture"].sort();
|
||||
export const defaultShortcuts = [
|
||||
"ActiveCallbacks", "Payloads", "PayloadTypesAndC2",
|
||||
"Operations", "SearchFiles", "SearchProxies",
|
||||
"CreatePayload", "Eventing", "Chat",
|
||||
].sort();
|
||||
export const operatorSettingDefaults = {
|
||||
navBarOpen: false,
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
showMedia: true,
|
||||
showOPSECBypassUsername: false,
|
||||
taskingDisplayFields: defaultTaskingDisplayFields,
|
||||
useDisplayParamsForCLIHistory: true,
|
||||
interactType: "interactSplit",
|
||||
taskTimestampDisplayField: "timestamp",
|
||||
callbacks_table_columns: ["Interact", "Host", "Domain", "User", "Description", "Last Checkin", "Agent", "IP", "PID"],
|
||||
callbacks_table_filters: {},
|
||||
autoTaskLsOnEmptyDirectories: false,
|
||||
hideBrowserTasking: false,
|
||||
hideTaskingContext: false,
|
||||
taskingContextFields: ["impersonation_context", "cwd"],
|
||||
["experiment-responseStreamLimit"]: 200,
|
||||
chatSelectedChannelID: 0,
|
||||
virtualizedTablePadding: 8,
|
||||
sideShortcuts: defaultShortcuts,
|
||||
palette: {
|
||||
primary: {
|
||||
dark: "#7dd3fc",
|
||||
light: "#2563eb",
|
||||
},
|
||||
error: {
|
||||
dark: '#fb7185',
|
||||
light: '#dc2626'
|
||||
},
|
||||
success: {
|
||||
dark: '#34d399',
|
||||
light: '#15803d',
|
||||
},
|
||||
secondary: {
|
||||
dark: '#94a3b8',
|
||||
light: '#64748b'
|
||||
},
|
||||
info: {
|
||||
dark: '#22d3ee',
|
||||
light: '#0891b2'
|
||||
},
|
||||
warning: {
|
||||
dark: "#fbbf24",
|
||||
light: "#b45309",
|
||||
},
|
||||
background: {
|
||||
dark: '#0b0f14',
|
||||
light: '#f6f8fb'
|
||||
},
|
||||
paper: {
|
||||
dark: '#111820',
|
||||
light: '#ffffff'
|
||||
},
|
||||
surfaceRaised: {
|
||||
dark: '#16212b',
|
||||
light: '#ffffff'
|
||||
},
|
||||
surfaceMuted: {
|
||||
dark: '#0f1720',
|
||||
light: '#fafbfc'
|
||||
},
|
||||
tableHeader: {
|
||||
dark: '#17212c',
|
||||
light: '#e8eef5'
|
||||
},
|
||||
tableHover: {
|
||||
dark: "#1e2a36",
|
||||
light: "#eaf1f8",
|
||||
},
|
||||
pageHeader: {
|
||||
dark: '#111a23',
|
||||
light: '#ffffff'
|
||||
},
|
||||
pageHeaderText: {
|
||||
dark: "#e7edf5",
|
||||
light: "#0f172a",
|
||||
},
|
||||
chatMessageOperatorBackground: {
|
||||
dark: "#111820",
|
||||
light: "#ffffff",
|
||||
},
|
||||
chatMessageSelfBackground: {
|
||||
dark: "#142033",
|
||||
light: "#f8fbff",
|
||||
},
|
||||
chatMessageSystemBackground: {
|
||||
dark: "#2a2116",
|
||||
light: "#fff7ed",
|
||||
},
|
||||
chatMarkdownSurfaceBackground: {
|
||||
dark: "#0b1118",
|
||||
light: "#f5f5f5",
|
||||
},
|
||||
chatMarkdownSurfaceStrongBackground: {
|
||||
dark: "#1f2a37",
|
||||
light: "#e2e8f0",
|
||||
},
|
||||
sectionHeaderAccent: {
|
||||
dark: "#7dd3fc",
|
||||
light: "#b3cbff",
|
||||
},
|
||||
sectionHeaderGradientStart: {
|
||||
dark: "#153049",
|
||||
light: "#dbeafe",
|
||||
},
|
||||
sectionHeaderGradientMiddle: {
|
||||
dark: "#111f2e",
|
||||
light: "#eef6ff",
|
||||
},
|
||||
sectionHeaderGradientEnd: {
|
||||
dark: "#111820",
|
||||
light: "#ffffff",
|
||||
},
|
||||
subtleAccentGradientStart: {
|
||||
dark: "#132338",
|
||||
light: "#eef6ff",
|
||||
},
|
||||
subtleAccentGradientEnd: {
|
||||
dark: "#111820",
|
||||
light: "#ffffff",
|
||||
},
|
||||
graphGroupColor: {
|
||||
dark: "#24384a",
|
||||
light: "#d9e6f2",
|
||||
},
|
||||
chartSeries1: {
|
||||
dark: "#38bdf8",
|
||||
light: "#0284c7",
|
||||
},
|
||||
chartSeries2: {
|
||||
dark: "#34d399",
|
||||
light: "#16a34a",
|
||||
},
|
||||
chartSeries3: {
|
||||
dark: "#fbbf24",
|
||||
light: "#ca8a04",
|
||||
},
|
||||
chartSeries4: {
|
||||
dark: "#fb7185",
|
||||
light: "#e11d48",
|
||||
},
|
||||
chartSeries5: {
|
||||
dark: "#a78bfa",
|
||||
light: "#7c3aed",
|
||||
},
|
||||
chartSeries6: {
|
||||
dark: "#2dd4bf",
|
||||
light: "#0d9488",
|
||||
},
|
||||
chartSeries7: {
|
||||
dark: "#f472b6",
|
||||
light: "#db2777",
|
||||
},
|
||||
chartSeries8: {
|
||||
dark: "#f97316",
|
||||
light: "#ea580c",
|
||||
},
|
||||
chartSeries9: {
|
||||
dark: "#818cf8",
|
||||
light: "#4f46e5",
|
||||
},
|
||||
chartSeries10: {
|
||||
dark: "#a3e635",
|
||||
light: "#65a30d",
|
||||
},
|
||||
text: {
|
||||
dark: "#e6edf3",
|
||||
light: "#111827",
|
||||
},
|
||||
textSecondary: {
|
||||
dark: "#a7b2c2",
|
||||
light: "#526174",
|
||||
},
|
||||
textDisabled: {
|
||||
dark: "#6f7c8d",
|
||||
light: "#93a1b2",
|
||||
},
|
||||
selectedCallbackColor: {
|
||||
dark: '#123a5a',
|
||||
light: '#dbeafe',
|
||||
},
|
||||
selectedCallbackHierarchyColor: {
|
||||
dark: '#172d49',
|
||||
light: '#edf6ff',
|
||||
},
|
||||
backgroundImage: {
|
||||
dark: null,
|
||||
light: null
|
||||
},
|
||||
navBarIcons: {
|
||||
dark: '#dce8f8',
|
||||
light: '#ffffff'
|
||||
},
|
||||
navBarText: {
|
||||
dark: '#dce8f8',
|
||||
light: '#ffffff'
|
||||
},
|
||||
navBarColor: {
|
||||
dark: "#1a283d",
|
||||
light: "#5c73a5",
|
||||
},
|
||||
navBarBottomColor: {
|
||||
dark: "#0c131c",
|
||||
light: "#1f2937",
|
||||
},
|
||||
taskPromptTextColor: {
|
||||
dark: '#9fb3c8',
|
||||
light: '#64748b'
|
||||
},
|
||||
taskPromptCommandTextColor: {
|
||||
dark: "#f8fafc",
|
||||
light: "#0f172a",
|
||||
},
|
||||
taskContextColor: {
|
||||
dark: "#14324f",
|
||||
light: "#dbeafe",
|
||||
},
|
||||
taskContextImpersonationColor: {
|
||||
dark: "#4a1824",
|
||||
light: "#fee2e2",
|
||||
},
|
||||
taskContextExtraColor: {
|
||||
dark: "#123a35",
|
||||
light: "#dcfce7",
|
||||
},
|
||||
emptyFolderColor: {
|
||||
dark: '#8997aa',
|
||||
light: '#64748b'
|
||||
},
|
||||
folderColor: {
|
||||
dark: '#f2c86b',
|
||||
light: '#d6a23f'
|
||||
},
|
||||
outputBackgroundColor: {
|
||||
dark: '#070b10',
|
||||
light: '#f8fafc'
|
||||
},
|
||||
outputTextColor: {
|
||||
dark: '#e6edf3',
|
||||
light: '#111827',
|
||||
},
|
||||
borderColor: {
|
||||
dark: "#263241",
|
||||
light: "#d5dee9"
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const mePreferences = makeVar(operatorSettingDefaults);
|
||||
|
||||
|
||||
export const successfulLogin = (data) => {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
let now = new Date();
|
||||
let serverNow = new Date(data.user.current_utc_time);
|
||||
const difference = (serverNow.getTime() - now.getTime());
|
||||
let me = {...data.user};
|
||||
me.server_skew = difference;
|
||||
me.login_time = now;
|
||||
meState({
|
||||
loggedIn: true,
|
||||
...data
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
user: {
|
||||
...me
|
||||
}
|
||||
});
|
||||
localStorage.setItem("user", JSON.stringify(me));
|
||||
restartWebsockets();
|
||||
}
|
||||
export const successfulRefresh = (data) => {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
let now = new Date();
|
||||
let serverNow = new Date(data.user.current_utc_time);
|
||||
const difference = (serverNow.getTime() - now.getTime()) ;
|
||||
let me = mergeRefreshedUser(meState().user, data.user);
|
||||
me.server_skew = difference;
|
||||
me.login_time = now;
|
||||
meState({
|
||||
loggedIn: true,
|
||||
access_token: localStorage.getItem("access_token"),
|
||||
...meState()
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
user: {
|
||||
...me
|
||||
}
|
||||
});
|
||||
localStorage.setItem("user", JSON.stringify(me));
|
||||
}
|
||||
export const FailedRefresh = () =>{
|
||||
export const FailedRefresh = (restart_websockets) =>{
|
||||
const accessToken = localStorage.getItem("access_token");
|
||||
if(accessToken){
|
||||
fetch('/logout', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${accessToken}`},
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
console.log("failed refresh");
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
// retrieve all cookies
|
||||
let Cookies = document.cookie.split(';');
|
||||
// set past expiry to all cookies
|
||||
for (let i = 0; i < Cookies.length; i++) {
|
||||
document.cookie = Cookies[i] + "=; expires="+ new Date(0).toUTCString();
|
||||
}
|
||||
meState({
|
||||
loggedIn: false,
|
||||
access_token: null,
|
||||
refresh_token: null,
|
||||
user: null
|
||||
});
|
||||
}
|
||||
mePreferences(operatorSettingDefaults);
|
||||
snackActions.clearAll();
|
||||
if(restart_websockets){
|
||||
restartWebsockets();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {gql, useSubscription} from '@apollo/client';
|
||||
import {snackActions} from './utilities/Snackbar';
|
||||
import {getSkewedNow} from "./utilities/Time";
|
||||
|
||||
//fromNow must be in ISO format for hasura/postgres stuff
|
||||
//new Date().toISOString() will do it
|
||||
/*
|
||||
|
||||
const subscribe_payloads = gql`
|
||||
subscription EventFeedNotificationSubscription($fromNow: timestamp!, $operation_id: Int!) {
|
||||
operationeventlog(limit: 1, where: {deleted: {_eq: false}, timestamp: {_gte: $fromNow}, operation_id: {_eq: $operation_id}}, order_by: {id: desc}) {
|
||||
operator {
|
||||
username
|
||||
}
|
||||
id
|
||||
message
|
||||
level
|
||||
resolved
|
||||
source
|
||||
}
|
||||
}
|
||||
`;
|
||||
*/
|
||||
const subscribe_payloads = gql`
|
||||
subscription EventFeedNotificationSubscription($fromNow: timestamp!) {
|
||||
operationeventlog_stream(cursor: {initial_value: {timestamp: $fromNow}, ordering: ASC}, batch_size: 1, where: {deleted: {_eq: false}}) {
|
||||
operator {
|
||||
@@ -31,44 +17,53 @@ subscription EventFeedNotificationSubscription($fromNow: timestamp!, $operation_
|
||||
level
|
||||
resolved
|
||||
source
|
||||
warning
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EventFeedNotifications(props) {
|
||||
const me = props.me;
|
||||
const fromNow = React.useRef( (new Date()).toISOString() );
|
||||
const { loading, error, data } = useSubscription(subscribe_payloads, {
|
||||
variables: {fromNow: fromNow.current},
|
||||
export function EventFeedNotifications() {
|
||||
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
|
||||
//const fromNow = React.useRef( );
|
||||
useSubscription(subscribe_payloads, {
|
||||
variables: {fromNow: fromNow },
|
||||
fetchPolicy: "no-cache",
|
||||
shouldResubscribe: true,
|
||||
onError: (errorData) => {
|
||||
snackActions.warning("Failed to get event notifications");
|
||||
console.log(errorData);
|
||||
},
|
||||
onData: ({data}) => {
|
||||
if (data.data.operationeventlog_stream.length > 0){
|
||||
if(data.data.operationeventlog_stream[0].level === "api" ||
|
||||
data.data.operationeventlog_stream[0].level === "debug" ||
|
||||
data.data.operationeventlog_stream[0].level === "agent"
|
||||
){
|
||||
return;
|
||||
}
|
||||
if(data.data.operationeventlog_stream[0].resolved){
|
||||
return;
|
||||
}
|
||||
if(data.data.operationeventlog_stream[0].operator){
|
||||
const message = data.data.operationeventlog_stream[0].operator.username + ":\n" + data.data.operationeventlog_stream[0].message;
|
||||
if (data.data.operationeventlog_stream[0].warning) {
|
||||
snackActions.warning(message, { autoClose: 2000});
|
||||
} else {
|
||||
snackActions.info(message, { autoClose: 2000});
|
||||
}
|
||||
|
||||
}else {
|
||||
if (data.data.operationeventlog_stream[0].warning) {
|
||||
snackActions.warning(data.data.operationeventlog_stream[0].message, {autoClose: 3000});
|
||||
} else {
|
||||
snackActions.info(
|
||||
<div style={{width: "100%"}}>{data.data.operationeventlog_stream[0].message}</div>,
|
||||
{autoClose: 3000});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect( () => {
|
||||
//console.log(data, loading, error, fromNow.current);
|
||||
if(!loading && !error && data && data.operationeventlog_stream.length > 0){
|
||||
if(data.operationeventlog_stream[0].source === "debug"){
|
||||
return;
|
||||
}
|
||||
if(data.operationeventlog_stream[0].resolved){
|
||||
return;
|
||||
}
|
||||
if(data.operationeventlog_stream[0].operator){
|
||||
const message = data.operationeventlog_stream[0].operator.username + ":" + data.operationeventlog_stream[0].message;
|
||||
snackActions.info(message, { autoClose: 2000});
|
||||
}else {
|
||||
snackActions.info(data.operationeventlog_stream[0].message, {autoClose: 3000});
|
||||
}
|
||||
}else if(error){
|
||||
console.error(error);
|
||||
snackActions.warning("Mythic encountered an error getting operational event stream", {autoHideDuration: 2000});
|
||||
}
|
||||
}, [loading, data, error, me.user]);
|
||||
return (
|
||||
null
|
||||
);
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import React from 'react';
|
||||
import {Box, Checkbox, InputAdornment, TextField, Typography} from '@mui/material';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import {gql, useQuery} from "@apollo/client";
|
||||
import {MythicChip} from './MythicChip';
|
||||
|
||||
export const defaultAPITokenScopes = ["*"];
|
||||
|
||||
export const apiTokenScopeDefinitionsQuery = gql`
|
||||
query apiTokenScopeDefinitionsQuery {
|
||||
apiTokenScopeDefinitions {
|
||||
status
|
||||
error
|
||||
scopes {
|
||||
name
|
||||
display_name
|
||||
description
|
||||
resource
|
||||
access
|
||||
includes
|
||||
}
|
||||
grantable_wildcards
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const normalizeAPITokenScopeSelection = (scopes = []) => {
|
||||
const uniqueScopes = Array.from(new Set((scopes || []).filter((scope) => Boolean(scope)))).sort();
|
||||
if(uniqueScopes.includes("*")){
|
||||
return ["*"];
|
||||
}
|
||||
return uniqueScopes.reduce((prev, scope) => {
|
||||
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
|
||||
if(scope.endsWith(".*")){
|
||||
return [
|
||||
...prev.filter((selectedScope) => !selectedScope.startsWith(`${resource}.`)),
|
||||
scope,
|
||||
].sort();
|
||||
}
|
||||
if(prev.includes(`${resource}.*`)){
|
||||
return prev;
|
||||
}
|
||||
return [...prev, scope].sort();
|
||||
}, []);
|
||||
};
|
||||
|
||||
export function APITokenScopeSelector({
|
||||
selectedScopes = [],
|
||||
onChange,
|
||||
requiredScopes = [],
|
||||
requiredScopeDescriptions = {},
|
||||
fetchPolicy = "cache-first",
|
||||
libraryMaxHeight,
|
||||
className = "",
|
||||
sx = {},
|
||||
}) {
|
||||
const [filter, setFilter] = React.useState("");
|
||||
const normalizedSelectedScopes = React.useMemo(() => {
|
||||
return normalizeAPITokenScopeSelection(selectedScopes);
|
||||
}, [selectedScopes]);
|
||||
const {data: scopeData, loading: scopeLoading, error: scopeQueryError} = useQuery(apiTokenScopeDefinitionsQuery, {
|
||||
fetchPolicy,
|
||||
});
|
||||
const availableScopes = React.useMemo(() => {
|
||||
if(scopeData?.apiTokenScopeDefinitions?.status !== "success"){
|
||||
return [];
|
||||
}
|
||||
return scopeData?.apiTokenScopeDefinitions?.scopes || [];
|
||||
}, [scopeData]);
|
||||
const grantableWildcards = React.useMemo(() => {
|
||||
return scopeData?.apiTokenScopeDefinitions?.grantable_wildcards || [];
|
||||
}, [scopeData]);
|
||||
const groupedScopes = React.useMemo(() => {
|
||||
return availableScopes.reduce((prev, cur) => {
|
||||
const resource = cur.resource || "other";
|
||||
return {
|
||||
...prev,
|
||||
[resource]: [...(prev[resource] || []), cur],
|
||||
};
|
||||
}, {});
|
||||
}, [availableScopes]);
|
||||
const visibleResources = React.useMemo(() => {
|
||||
const normalizedFilter = filter.toLowerCase();
|
||||
return Object.keys(groupedScopes).sort().filter(resource => {
|
||||
if(normalizedFilter === ""){
|
||||
return true;
|
||||
}
|
||||
if(resource.toLowerCase().includes(normalizedFilter)){
|
||||
return true;
|
||||
}
|
||||
return groupedScopes[resource].some(scope =>
|
||||
scope.name.toLowerCase().includes(normalizedFilter) ||
|
||||
(scope.display_name || "").toLowerCase().includes(normalizedFilter) ||
|
||||
(scope.description || "").toLowerCase().includes(normalizedFilter)
|
||||
);
|
||||
});
|
||||
}, [filter, groupedScopes]);
|
||||
const visibleScopeCount = React.useMemo(() => {
|
||||
return visibleResources.reduce((prev, resource) => prev + groupedScopes[resource].length, 0);
|
||||
}, [groupedScopes, visibleResources]);
|
||||
const scopeLoadFailed = scopeQueryError !== undefined || scopeData?.apiTokenScopeDefinitions?.status === "error";
|
||||
const scopesUnavailable = scopeLoading || scopeLoadFailed;
|
||||
const selectedScopesLabel = normalizedSelectedScopes.includes("*") ? "Full access selected" : `${normalizedSelectedScopes.length} selected`;
|
||||
const fullAccessDisabled = !grantableWildcards.includes("*");
|
||||
const emitScopes = (nextScopes) => {
|
||||
if(onChange){
|
||||
onChange(normalizeAPITokenScopeSelection(nextScopes));
|
||||
}
|
||||
}
|
||||
const scopeIsSelected = (scope) => {
|
||||
return normalizedSelectedScopes.includes(scope);
|
||||
}
|
||||
const toggleScope = (scope) => {
|
||||
if(scope === "*"){
|
||||
emitScopes(scopeIsSelected("*") ? [] : defaultAPITokenScopes);
|
||||
return;
|
||||
}
|
||||
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
|
||||
const withoutAll = normalizedSelectedScopes.filter(s => s !== "*");
|
||||
const withoutResourceWildcard = withoutAll.filter(s => s !== `${resource}.*`);
|
||||
if(scope.endsWith(".*")){
|
||||
emitScopes(normalizedSelectedScopes.includes(scope) ? withoutResourceWildcard : [
|
||||
...withoutResourceWildcard.filter(s => !s.startsWith(`${resource}.`)),
|
||||
scope,
|
||||
].sort());
|
||||
return;
|
||||
}
|
||||
if(normalizedSelectedScopes.includes(scope)){
|
||||
emitScopes(withoutResourceWildcard.filter(s => s !== scope));
|
||||
return;
|
||||
}
|
||||
emitScopes([...withoutResourceWildcard, scope].sort());
|
||||
}
|
||||
const selectVisibleScopes = () => {
|
||||
const visibleScopeNames = visibleResources.flatMap(resource => groupedScopes[resource].map(scope => scope.name));
|
||||
emitScopes(Array.from(new Set([
|
||||
...normalizedSelectedScopes.filter(scope => scope !== "*"),
|
||||
...visibleScopeNames,
|
||||
])).sort());
|
||||
}
|
||||
const clearScopes = () => {
|
||||
emitScopes([]);
|
||||
}
|
||||
const librarySx = libraryMaxHeight === undefined ? undefined : {maxHeight: libraryMaxHeight};
|
||||
|
||||
return (
|
||||
<Box className={className} sx={sx}>
|
||||
<Box sx={{alignItems: "center", display: "flex", flexWrap: "wrap", gap: 0.75, mb: 1}}>
|
||||
<MythicChip label={`${visibleScopeCount} visible`} />
|
||||
<MythicChip label={selectedScopesLabel} />
|
||||
<MythicActionButton disabled={scopesUnavailable || visibleScopeCount === 0 || scopeIsSelected("*")} size="small" onClick={selectVisibleScopes}>
|
||||
Select Visible
|
||||
</MythicActionButton>
|
||||
<MythicActionButton disabled={normalizedSelectedScopes.length === 0} size="small" onClick={clearScopes}>
|
||||
Clear
|
||||
</MythicActionButton>
|
||||
</Box>
|
||||
{requiredScopes.length > 0 &&
|
||||
<Box className="mythic-api-token-scope-card items-start flex gap-3 mythic-api-token-scope-card-selected min-w-0 rounded cursor-pointer bg-surface border-subtle text-primary mythic-tone-primary bg-tone-1 border-tone-3" sx={{mb: 1.25}}>
|
||||
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
|
||||
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">Needed for this use</Typography>
|
||||
{requiredScopes.map((scope) => (
|
||||
<Typography key={`required-${scope}`} variant="caption" color="text.secondary" sx={{display: "block"}}>
|
||||
{scope}: {requiredScopeDescriptions[scope] || "Required for this workflow."}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
<TextField
|
||||
className="mythic-api-token-scope-search"
|
||||
size="small"
|
||||
fullWidth
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
placeholder="Search scopes"
|
||||
disabled={scopesUnavailable}
|
||||
InputProps={{startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>}}
|
||||
/>
|
||||
<Box className="mythic-api-token-scope-library flex flex-column gap-5 overflow-auto rounded bg-neutral-1 border-subtle" sx={librarySx}>
|
||||
{scopesUnavailable &&
|
||||
<Box className={`mythic-api-token-scope-state items-center flex justify-center rounded bg-surface-raised border-subtle text-muted text-center${scopeLoadFailed ? " mythic-api-token-scope-state-error mythic-tone-error bg-tone-1 border-tone-3 text-tone" : ""}`}>
|
||||
<Typography variant="body2">
|
||||
{scopeLoading ? "Loading scopes..." : (scopeQueryError?.message || scopeData?.apiTokenScopeDefinitions?.error || "Failed to load scopes")}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
{!scopesUnavailable &&
|
||||
<>
|
||||
<Box
|
||||
className={`mythic-api-token-scope-card items-start flex gap-3 mythic-api-token-scope-card-full rounded cursor-pointer bg-surface border-subtle text-primary mythic-tone-warning bg-tone-1 border-tone-2${scopeIsSelected("*") ? " mythic-api-token-scope-card-selected mythic-tone-primary bg-tone-1 border-tone-3" : ""}${fullAccessDisabled ? " mythic-api-token-scope-card-disabled" : ""} min-w-0`}
|
||||
component="label"
|
||||
>
|
||||
<Checkbox
|
||||
disabled={fullAccessDisabled}
|
||||
checked={scopeIsSelected("*")}
|
||||
onChange={() => toggleScope("*")}
|
||||
/>
|
||||
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
|
||||
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">Full access (*)</Typography>
|
||||
<Typography className="mythic-api-token-scope-card-description text-xs leading-135 wrap-anywhere text-muted">
|
||||
Grants every current and future API scope available to this operator.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{visibleResources.length === 0 &&
|
||||
<Box className="mythic-api-token-scope-state items-center flex justify-center rounded bg-surface-raised border-subtle text-muted text-center">
|
||||
<Typography variant="body2">No scopes match your search.</Typography>
|
||||
</Box>
|
||||
}
|
||||
{visibleResources.map(resource => {
|
||||
const resourceWildcard = `${resource}.*`;
|
||||
const canGrantResourceWildcard = grantableWildcards.includes(resourceWildcard);
|
||||
const resourceScopes = [...groupedScopes[resource]].sort((a, b) => (a.access || "").localeCompare(b.access || ""));
|
||||
const resourceWildcardSelected = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
|
||||
return (
|
||||
<Box className="mythic-api-token-resource-card p-6 flex flex-column gap-5 min-w-0 rounded bg-surface-raised border-subtle" key={resource}>
|
||||
<Box className="mythic-api-token-resource-header items-start flex flex-wrap gap-6 justify-between min-w-0">
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography className="mythic-api-token-resource-title text-sm font-800 leading-120 text-primary">
|
||||
{resource.split("_").join(" ")}
|
||||
</Typography>
|
||||
<Typography className="mythic-api-token-resource-subtitle">
|
||||
{resourceScopes.length === 1 ? "1 available scope" : `${resourceScopes.length} available scopes`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
className={`mythic-api-token-resource-wildcard text-xs font-750 items-center inline-flex flex-none gap-2 rounded cursor-pointer bg-neutral-1 border-subtle text-muted${resourceWildcardSelected ? " mythic-api-token-resource-wildcard-selected" : ""}${!canGrantResourceWildcard ? " mythic-api-token-resource-wildcard-disabled" : ""}`}
|
||||
component="label"
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
disabled={scopeIsSelected("*") || !canGrantResourceWildcard}
|
||||
checked={resourceWildcardSelected}
|
||||
onChange={() => toggleScope(resourceWildcard)}
|
||||
/>
|
||||
<span>{resourceWildcard}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="mythic-api-token-scope-grid gap-4 min-w-0 grid">
|
||||
{resourceScopes.map(scope => {
|
||||
const includedByWildcard = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
|
||||
const scopeSelected = scopeIsSelected(scope.name) || includedByWildcard;
|
||||
return (
|
||||
<Box
|
||||
className={`mythic-api-token-scope-card items-start flex gap-3 rounded cursor-pointer bg-surface border-subtle text-primary${scopeSelected ? " mythic-api-token-scope-card-selected mythic-tone-primary bg-tone-1 border-tone-3" : ""}${includedByWildcard ? " mythic-api-token-scope-card-inherited" : ""} min-w-0`}
|
||||
component="label"
|
||||
key={scope.name}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
disabled={includedByWildcard}
|
||||
checked={scopeSelected}
|
||||
onChange={() => toggleScope(scope.name)}
|
||||
/>
|
||||
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
|
||||
<Box className="mythic-api-token-scope-card-title-row items-start flex gap-4 justify-between min-w-0">
|
||||
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">{scope.display_name || scope.name}</Typography>
|
||||
<MythicChip
|
||||
compact
|
||||
label={scope.access || "scope"}
|
||||
size="small"
|
||||
tone={scope.access === "read" ? "info" : ["write", "create", "update"].includes(scope.access) ? "success" : ["delete", "admin"].includes(scope.access) ? "error" : "neutral"}
|
||||
/>
|
||||
</Box>
|
||||
<Typography className="mythic-api-token-scope-name text-xs leading-135 wrap-anywhere text-muted font-mono">
|
||||
{scope.name}
|
||||
</Typography>
|
||||
<Typography className="mythic-api-token-scope-card-description text-xs leading-135 wrap-anywhere text-muted">
|
||||
{scope.description}
|
||||
</Typography>
|
||||
{scope.includes?.length > 0 &&
|
||||
<Typography className="mythic-api-token-scope-includes text-xs leading-135 font-700 wrap-anywhere mythic-tone-info text-tone">
|
||||
Includes {scope.includes.join(", ")}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import {normalizeMythicChipTone} from './MythicChip';
|
||||
import {MythicStyledTooltip} from './MythicStyledTooltip';
|
||||
|
||||
export const MythicActionButton = React.forwardRef(function MythicActionButton({
|
||||
active = false,
|
||||
appearance, // can be plain or raised
|
||||
autoWidth = false,
|
||||
children,
|
||||
className = "",
|
||||
colorMode = "hover", // can be always or hover
|
||||
compact = false,
|
||||
icon,
|
||||
iconOnly = false,
|
||||
label,
|
||||
muted = false,
|
||||
rotated = false,
|
||||
selected = false,
|
||||
shape, // can be square or round
|
||||
size = "small",
|
||||
startIcon,
|
||||
tone = "neutral",
|
||||
tooltip,
|
||||
tooltipStyle = {},
|
||||
variant = "outlined",
|
||||
...props
|
||||
}, ref) {
|
||||
const resolvedTone = normalizeMythicChipTone(tone);
|
||||
const resolvedAppearance = appearance || (iconOnly ? "plain" : "raised");
|
||||
const resolvedShape = shape || (resolvedAppearance === "raised" ? "square" : "round");
|
||||
const resolvedColorMode = colorMode === "always" ? "always" : "hover";
|
||||
const shapeClass = iconOnly ? "mythic-action-button-icon" : "mythic-action-button-label";
|
||||
const resolvedClassName = [
|
||||
"mythic-action-button",
|
||||
`mythic-action-button-${resolvedAppearance}`,
|
||||
shapeClass,
|
||||
`mythic-action-button-shape-${resolvedShape}`,
|
||||
`mythic-action-button-color-${resolvedColorMode}`,
|
||||
`mythic-tone-${resolvedTone}`,
|
||||
active || selected ? "mythic-action-button-active" : "",
|
||||
autoWidth ? "mythic-action-button-auto-width" : "",
|
||||
compact ? "mythic-action-button-compact" : "",
|
||||
muted ? "mythic-action-button-muted" : "",
|
||||
rotated ? "mythic-action-button-rotated" : "",
|
||||
className,
|
||||
].filter(Boolean).join(" ");
|
||||
const ariaLabel = props["aria-label"] || tooltip || (typeof label === "string" ? label : undefined);
|
||||
const content = label ?? children;
|
||||
const button = iconOnly ? (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
size={size}
|
||||
{...props}
|
||||
className={resolvedClassName}
|
||||
color="inherit"
|
||||
>
|
||||
{icon || children}
|
||||
</IconButton>
|
||||
) : (
|
||||
<Button
|
||||
ref={ref}
|
||||
aria-label={ariaLabel}
|
||||
size={size}
|
||||
startIcon={icon || startIcon}
|
||||
variant={variant}
|
||||
{...props}
|
||||
className={resolvedClassName}
|
||||
color="inherit"
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return tooltip ? (
|
||||
<MythicStyledTooltip title={tooltip} tooltipStyle={{display: "inline-flex", ...tooltipStyle}}>
|
||||
{button}
|
||||
</MythicStyledTooltip>
|
||||
) : button;
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import { faLink } from '@fortawesome/free-solid-svg-icons';
|
||||
import {ImageWithAuth} from "../utilities/ImageWithAuth";
|
||||
|
||||
export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
|
||||
const theme = useTheme();
|
||||
if(payload_type === ""){
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<MythicStyledTooltip title={payload_type} >
|
||||
<ImageWithAuth src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
|
||||
style={{...style}} />
|
||||
{is_p2p === false &&
|
||||
<WifiIcon style={{width: "20px", height: "20px", marginLeft: "-20px", marginBottom: "5px",
|
||||
borderRadius: "10px", border: "1px solid",
|
||||
backgroundColor: theme.palette.background.default}}/>
|
||||
}
|
||||
{is_p2p === true &&
|
||||
<FontAwesomeIcon icon={faLink} style={{width: "20px", height: "20px", marginLeft: "-20px", marginBottom: "5px",
|
||||
borderRadius: "10px", border: "1px solid",
|
||||
backgroundColor: theme.palette.background.default}} />
|
||||
}
|
||||
</MythicStyledTooltip>
|
||||
|
||||
)
|
||||
}
|
||||
export const MythicAgentSVGIconNoTooltip = ({payload_type, is_p2p, className, style}) => {
|
||||
const theme = useTheme();
|
||||
if(payload_type === ""){
|
||||
return;
|
||||
}
|
||||
const extraStyles = style ? style : {};
|
||||
return (
|
||||
<div style={{display: "inline-block", left: "50%"}} className={className}>
|
||||
<ImageWithAuth src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
|
||||
style={{height: "20px", margin: 'auto', ...extraStyles}} />
|
||||
{is_p2p === false &&
|
||||
<WifiIcon style={{width: "10px", height: "10px", marginLeft: "-5px", marginBottom: "0px",
|
||||
borderRadius: "10px", border: "1px solid",
|
||||
backgroundColor: theme.palette.primary.main, color: "white" }} />
|
||||
}
|
||||
{is_p2p === true &&
|
||||
<FontAwesomeIcon icon={faLink} style={{width: "10px", height: "10px", marginLeft: "-5px", marginBottom: "0px",
|
||||
borderRadius: "10px", border: "1px solid",
|
||||
backgroundColor: theme.palette.primary.main, color: "white"}} />
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import SmartToyTwoToneIcon from '@mui/icons-material/SmartToyTwoTone';
|
||||
import {ImageWithAuth} from "../utilities/ImageWithAuth";
|
||||
|
||||
const defaultIconProps = {fontSize: "small"};
|
||||
|
||||
const shallowEqualObjects = (left = {}, right = {}) => {
|
||||
if(left === right){
|
||||
return true;
|
||||
}
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if(leftKeys.length !== rightKeys.length){
|
||||
return false;
|
||||
}
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
};
|
||||
|
||||
const MythicChatContainerIconComponent = ({containerName, className = "", imgClassName = "", iconProps = defaultIconProps, altText}) => {
|
||||
const theme = useTheme();
|
||||
const [imageFailed, setImageFailed] = React.useState(false);
|
||||
const iconName = String(containerName || "").trim();
|
||||
const imageSrc = iconName ? `/static/${iconName}_${theme.palette.mode}.svg` : "";
|
||||
const handleImageError = React.useCallback(() => {
|
||||
setImageFailed(true);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
setImageFailed(false);
|
||||
}, [imageSrc]);
|
||||
if(!imageSrc || imageFailed){
|
||||
return <SmartToyTwoToneIcon {...iconProps} />;
|
||||
}
|
||||
return (
|
||||
<ImageWithAuth
|
||||
alt={altText || iconName}
|
||||
className={`${className} ${imgClassName}`.trim()}
|
||||
onError={handleImageError}
|
||||
src={imageSrc}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicChatContainerIcon = React.memo(MythicChatContainerIconComponent, (previousProps, nextProps) => (
|
||||
previousProps.containerName === nextProps.containerName &&
|
||||
previousProps.className === nextProps.className &&
|
||||
previousProps.imgClassName === nextProps.imgClassName &&
|
||||
previousProps.altText === nextProps.altText &&
|
||||
shallowEqualObjects(previousProps.iconProps || defaultIconProps, nextProps.iconProps || defaultIconProps)
|
||||
));
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import {useTheme} from "@mui/material/styles";
|
||||
|
||||
const supportedTones = new Set([
|
||||
"primary",
|
||||
"secondary",
|
||||
"neutral",
|
||||
"info",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
]);
|
||||
|
||||
export const normalizeMythicChipTone = (tone) => supportedTones.has(tone) ? tone : "secondary";
|
||||
|
||||
const ChipPrimitive = React.forwardRef(function ChipPrimitive({
|
||||
className = "",
|
||||
color = undefined,
|
||||
compact = false,
|
||||
iconOnly = false,
|
||||
muted = false,
|
||||
shape,
|
||||
size = "small",
|
||||
tone = undefined,
|
||||
variant = "outlined",
|
||||
...props
|
||||
}, ref) {
|
||||
const resolvedTone = normalizeMythicChipTone(tone || (color ? color : "secondary"));
|
||||
const muiVariant = variant ? variant : "outlined";
|
||||
const hasCustomColor = Boolean(color) && !supportedTones.has(color);
|
||||
const resolvedClassName = [
|
||||
"mythic-chip-base",
|
||||
"text-xs",
|
||||
"font-800",
|
||||
"items-center",
|
||||
"inline-flex",
|
||||
"gap-2",
|
||||
"max-w-full",
|
||||
"min-w-0",
|
||||
"truncate",
|
||||
"whitespace-nowrap",
|
||||
shape === "square" ? "mythic-square-chip" : "mythic-chip",
|
||||
hasCustomColor ? "mythic-chip-custom-color" : `mythic-tone-${resolvedTone}`,
|
||||
compact ? "mythic-chip-compact text-2xs" : "",
|
||||
muted ? "mythic-chip-muted" : "",
|
||||
iconOnly ? "mythic-chip-icon-only justify-center" : "",
|
||||
variant === "outlined" ? "mythic-chip-outlined" : "",
|
||||
className,
|
||||
].filter(Boolean).join(" ");
|
||||
return (
|
||||
<Chip
|
||||
ref={ref}
|
||||
className={resolvedClassName}
|
||||
//color={color ? color : undefined}
|
||||
size={size}
|
||||
variant={muiVariant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const MythicChip = React.forwardRef(function MythicChip(props, ref) {
|
||||
return <ChipPrimitive ref={ref} shape="pill" {...props} />;
|
||||
});
|
||||
|
||||
export const SquareChip = React.forwardRef(function SquareChip(props, ref) {
|
||||
return <ChipPrimitive ref={ref} shape="square" {...props} />;
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import {HexColorInput, HexColorPicker} from 'react-colorful';
|
||||
|
||||
export const isValidHexColor = (color) => {
|
||||
if(typeof color !== "string"){
|
||||
return false;
|
||||
}
|
||||
if(color.length !== 7){
|
||||
return false;
|
||||
}
|
||||
if(color[0] !== "#"){
|
||||
return false;
|
||||
}
|
||||
return /^#[0-9a-fA-F]{6}$/.test(color);
|
||||
}
|
||||
|
||||
export const getReadableTextColor = (backgroundColor) => {
|
||||
if(!isValidHexColor(backgroundColor)){
|
||||
return "#ffffff";
|
||||
}
|
||||
const red = parseInt(backgroundColor.slice(1, 3), 16);
|
||||
const green = parseInt(backgroundColor.slice(3, 5), 16);
|
||||
const blue = parseInt(backgroundColor.slice(5, 7), 16);
|
||||
const brightness = (red * 299 + green * 587 + blue * 114) / 1000;
|
||||
return brightness >= 140 ? "#111827" : "#ffffff";
|
||||
}
|
||||
|
||||
export const MythicColorSwatchInput = ({color, label, onChange, inputWidth = "96px", sx = {}}) => {
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const swatchRef = React.useRef(null);
|
||||
const safeColor = isValidHexColor(color) ? color : "#000000";
|
||||
return (
|
||||
<ClickAwayListener onClickAway={() => setPickerOpen(false)}>
|
||||
<Box sx={{display: "flex", alignItems: "center", gap: 1, minWidth: 0, position: "relative", ...sx}}>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
ref={swatchRef}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
setPickerOpen((open) => !open);
|
||||
}}
|
||||
aria-label={label}
|
||||
aria-expanded={pickerOpen}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
flex: "0 0 auto",
|
||||
height: 30,
|
||||
justifyContent: "center",
|
||||
p: "2px",
|
||||
width: 34,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: safeColor,
|
||||
borderRadius: "3px",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Popper
|
||||
open={pickerOpen}
|
||||
anchorEl={swatchRef.current}
|
||||
placement="bottom-start"
|
||||
style={{zIndex: 100000}}
|
||||
>
|
||||
<Box
|
||||
onMouseDown={(evt) => evt.stopPropagation()}
|
||||
onClick={(evt) => evt.stopPropagation()}
|
||||
sx={{
|
||||
backgroundColor: "background.paper",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: "6px",
|
||||
boxShadow: 8,
|
||||
mt: 0.5,
|
||||
p: 1,
|
||||
"& .react-colorful": {
|
||||
width: 210,
|
||||
height: 160,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<HexColorPicker color={safeColor} onChange={onChange} />
|
||||
</Box>
|
||||
</Popper>
|
||||
<HexColorInput
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
prefixed={true}
|
||||
style={{
|
||||
width: inputWidth,
|
||||
minWidth: 0,
|
||||
height: "30px",
|
||||
boxSizing: "border-box",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid rgba(128,128,128,0.45)",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
padding: "0 8px",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,42 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {MythicDialog} from './MythicDialog';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
|
||||
|
||||
export function MythicConfirmDialog(props) {
|
||||
const theme = useTheme();
|
||||
const handleSubmit = () => {
|
||||
props.onSubmit();
|
||||
if(props.dontCloseOnSubmit){
|
||||
return;
|
||||
}
|
||||
props.onClose();
|
||||
}
|
||||
const acceptIntent = props.acceptColor === "success" ? "primary" :
|
||||
props.acceptColor === "warning" ? "warning" :
|
||||
props.acceptColor === "info" ? "info" :
|
||||
"destructive";
|
||||
|
||||
return (
|
||||
<MythicDialog fullWidth={false} maxWidth="sm" open={props.open} onClose={()=>{props.onClose()}} innerDialog={
|
||||
<React.Fragment>
|
||||
<DialogTitle >{props.title ? (props.title) : ("Are you sure?")}</DialogTitle>
|
||||
{props.dialogText === undefined ? (null) : (
|
||||
{props.dialogText === undefined ? null : (
|
||||
<DialogContent dividers={true} style={{maxHeight: "calc(70vh)"}}>
|
||||
<DialogContentText>
|
||||
{props.dialogText}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
)}
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
{props.cancelText ? (props.cancelText) : ("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} autoFocus variant="contained" color={props.acceptColor ? (props.acceptColor) : ("error")}>
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={handleSubmit} autoFocus intent={acceptIntent}>
|
||||
{props.acceptText ? (props.acceptText) : ("Remove")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
} />
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {MythicActionButton} from "./MythicActionButton";
|
||||
import React, {useEffect} from 'react';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import MythicTextField from './MythicTextField';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
@@ -13,11 +11,91 @@ import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { toLocalTime } from '../utilities/Time';
|
||||
import AceEditor from 'react-ace';
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-monokai";
|
||||
import "ace-builds/src-noconflict/ext-searchbox";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import WrapTextIcon from '@mui/icons-material/WrapText';
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import Draggable from 'react-draggable';
|
||||
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
|
||||
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
|
||||
import {MythicChip} from "./MythicChip";
|
||||
|
||||
let mythicDialogIdCounter = 0;
|
||||
let mythicDialogStack = [];
|
||||
|
||||
export function MythicDialog(props) {
|
||||
const descriptionElementRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
const MythicDraggableDialogPaper = React.forwardRef(function MythicDraggableDialogPaper(props, ref) {
|
||||
const {
|
||||
draggableCancel,
|
||||
draggableHandle,
|
||||
draggableOnStart,
|
||||
draggableOnStop,
|
||||
...paperProps
|
||||
} = props;
|
||||
const nodeRef = React.useRef(null);
|
||||
const setPaperRef = React.useCallback((node) => {
|
||||
nodeRef.current = node;
|
||||
if(typeof ref === "function"){
|
||||
ref(node);
|
||||
} else if(ref){
|
||||
ref.current = node;
|
||||
}
|
||||
}, [ref]);
|
||||
const onStart = React.useCallback((event, data) => {
|
||||
return draggableOnStart?.(event, data, nodeRef.current);
|
||||
}, [draggableOnStart]);
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
handle={draggableHandle}
|
||||
cancel={draggableCancel}
|
||||
onStart={onStart}
|
||||
onStop={draggableOnStop}
|
||||
>
|
||||
<Paper {...paperProps} ref={setPaperRef} />
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
// Kept temporarily for route-level compatibility while consumers migrate to the lightweight base module.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
function LegacyMythicDialog(props) {
|
||||
const theme = useTheme();
|
||||
const dialogIdRef = React.useRef(null);
|
||||
if(dialogIdRef.current === null){
|
||||
dialogIdRef.current = mythicDialogIdCounter++;
|
||||
}
|
||||
const [draggedState, setDraggedState] = React.useState({
|
||||
style: {},
|
||||
paperStyle: {
|
||||
margin: "0",
|
||||
height: "fit-content",
|
||||
width: "stretch"
|
||||
},
|
||||
containerStyle: {
|
||||
|
||||
},
|
||||
hideBackdrop: false,
|
||||
modified: false
|
||||
});
|
||||
const descriptionElementRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if(!props.open){
|
||||
return undefined;
|
||||
}
|
||||
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
|
||||
mythicDialogStack.push(dialogIdRef.current);
|
||||
return () => {
|
||||
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
|
||||
};
|
||||
}, [props.open]);
|
||||
const isTopDialog = () => {
|
||||
return mythicDialogStack[mythicDialogStack.length - 1] === dialogIdRef.current;
|
||||
};
|
||||
React.useEffect(() => {
|
||||
if (props.open) {
|
||||
const { current: descriptionElement } = descriptionElementRef;
|
||||
if (descriptionElement !== null) {
|
||||
@@ -25,192 +103,390 @@ export function MythicDialog(props) {
|
||||
}
|
||||
}
|
||||
}, [props.open]);
|
||||
|
||||
const dialogOnClick = (e) => {
|
||||
if(e.target.classList?.contains("MuiDialog-container")){
|
||||
e.stopPropagation();
|
||||
if(!isTopDialog()){
|
||||
return;
|
||||
}
|
||||
if(draggedState.hideBackdrop){
|
||||
props.onClose?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
const dialogOnContextMenu = (e) => {
|
||||
e.stopPropagation();
|
||||
}
|
||||
const handleOnClose = (event, reason) => {
|
||||
if(reason === "backdropClick" || reason === "escapeKeyDown"){
|
||||
event?.stopPropagation?.();
|
||||
if(!isTopDialog()){
|
||||
return;
|
||||
}
|
||||
}
|
||||
if(reason === "backdropClick" && draggedState.hideBackdrop){
|
||||
return;
|
||||
}
|
||||
props.onClose?.();
|
||||
}
|
||||
const onStart = React.useCallback((e, _data, paperNode) => {
|
||||
if(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
setDraggedState((currentState) => {
|
||||
if(currentState.modified){
|
||||
return currentState;
|
||||
}
|
||||
const dragTarget = paperNode || e?.target?.closest?.(".MuiPaper-root") || e?.target?.offsetParent;
|
||||
const targetWidth = dragTarget?.offsetWidth || e?.target?.offsetParent?.offsetWidth || 0;
|
||||
return {
|
||||
style: {},
|
||||
paperStyle: {
|
||||
...(targetWidth > 0 ? {width: targetWidth + "px"} : {}),
|
||||
margin: 0,
|
||||
overflowY: "auto",
|
||||
},
|
||||
containerStyle: {
|
||||
overflow: "visible",
|
||||
},
|
||||
hideBackdrop: true,
|
||||
modified: true,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const onStop = React.useCallback((e) => {
|
||||
if(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
scroll="paper"
|
||||
maxWidth={props.maxWidth}
|
||||
fullWidth={props.fullWidth}
|
||||
aria-labelledby="scroll-dialog-title"
|
||||
aria-describedby="scroll-dialog-description"
|
||||
>
|
||||
{props.innerDialog}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={handleOnClose}
|
||||
scroll="paper"
|
||||
maxWidth={props.maxWidth}
|
||||
fullWidth={true}
|
||||
PaperComponent={MythicDraggableDialogPaper}
|
||||
PaperProps={{
|
||||
draggableHandle: "#mythic-draggable-title",
|
||||
draggableCancel: '[class*="MuiDialogContent-root"]',
|
||||
draggableOnStart: onStart,
|
||||
draggableOnStop: onStop,
|
||||
}}
|
||||
style={{...props.style, ...draggedState.style}}
|
||||
disableEnforceFocus={true}
|
||||
disablePortal={false}
|
||||
hideBackdrop={draggedState.hideBackdrop}
|
||||
aria-labelledby="scroll-dialog-title"
|
||||
aria-describedby="scroll-dialog-description"
|
||||
sx={{
|
||||
".MuiPaper-root": {
|
||||
borderRadius: "8px",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.borderColor}`,
|
||||
...draggedState.paperStyle
|
||||
},
|
||||
".MuiDialog-container": {
|
||||
...draggedState.containerStyle
|
||||
}
|
||||
}}
|
||||
onMouseDown={dialogOnClick}
|
||||
onContextMenu={dialogOnContextMenu}
|
||||
>
|
||||
{props.innerDialog}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export {MythicDialog} from "./MythicDialogBase";
|
||||
|
||||
export function MythicModifyStringDialog(props) {
|
||||
const [comment, setComment] = React.useState("");
|
||||
const [wrap, setWrap] = React.useState(props.wrap ? props.wrap : false);
|
||||
const theme = useTheme();
|
||||
const onCommitSubmit = () => {
|
||||
props.onSubmit(comment);
|
||||
if(props.dontCloseOnSubmit){
|
||||
return;
|
||||
}
|
||||
props.onClose();
|
||||
}
|
||||
const onChange = (name, value, error) => {
|
||||
const onChange = (value) => {
|
||||
setComment(value);
|
||||
}
|
||||
useEffect( () => {
|
||||
setComment(props.value);
|
||||
try{
|
||||
setComment(JSON.stringify(JSON.parse(props.value), null, 2));
|
||||
}catch(error){
|
||||
setComment(props.value);
|
||||
}
|
||||
|
||||
}, [props.value]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<MythicTextField autoFocus onEnter={props?.onEnter || onCommitSubmit} onChange={onChange} value={comment} multiline={props?.multiline || false} maxRows={props.maxRows} />
|
||||
{props.title !== "" &&
|
||||
<MythicDraggableDialogTitle>{props.title}
|
||||
<MythicStyledTooltip title={wrap ? "Toggle off word wrap" : "Toggl on word wrap"}
|
||||
tooltipStyle={{float: "right"}}>
|
||||
<MythicActionButton iconOnly onClick={() => {setWrap(!wrap)}}>
|
||||
<WrapTextIcon color={wrap ? "success" : "secondary"} />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
</MythicDraggableDialogTitle>
|
||||
}
|
||||
<DialogContent dividers={true} style={{ margin: 0, padding: 0}}>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
fontSize={14}
|
||||
showPrintMargin={false}
|
||||
wrapEnabled={wrap}
|
||||
value={comment}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
showInvisibles: false,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onCommitSubmit} variant="contained" color="success">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
{ (props.onClose || props.onSubmit) &&
|
||||
<MythicDialogFooter>
|
||||
{props.onClose &&
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</MythicDialogButton>
|
||||
}
|
||||
{props.onSubmit &&
|
||||
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
|
||||
{props.onSubmitText ? props.onSubmitText : "Submit"}
|
||||
</MythicDialogButton>
|
||||
}
|
||||
</MythicDialogFooter>
|
||||
}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicViewJSONAsTableDialog(props) {
|
||||
const [comment, setComment] = React.useState([]);
|
||||
const [tableType, setTableType] = React.useState("dictionary");
|
||||
const [headers, setHeaders] = React.useState([]);
|
||||
useEffect( () => {
|
||||
let permissions = [];
|
||||
try{
|
||||
let permissionDict;
|
||||
if(props.value.constructor === Object){
|
||||
permissionDict = props.value;
|
||||
}else{
|
||||
permissionDict = JSON.parse(props.value);
|
||||
}
|
||||
|
||||
if(permissionDict.constructor === Object){
|
||||
for(let key in permissionDict){
|
||||
if(permissionDict[key] && permissionDict[key].constructor === Object){
|
||||
// potentially have a nested dictionary here or array to become a dictionary, mark it
|
||||
permissions.push({"name": key, "value": permissionDict[key], new_table: true, is_dictionary: true, headers: ["Name", "Value"]});
|
||||
} else if(permissionDict[key] && Array.isArray(permissionDict[key])) {
|
||||
if (permissionDict[key].length == 1){
|
||||
if(permissionDict[key][0].constructor === Object){
|
||||
permissions.push({"name": key, "value": permissionDict[key][0], new_table: true, is_dictionary: true, headers: ["Name", "Value"]});
|
||||
|
||||
}else{
|
||||
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
|
||||
}
|
||||
|
||||
} else if (permissionDict[key].length > 1) {
|
||||
if (permissionDict[key][0].constructor === Object) {
|
||||
let newHeaders = [];
|
||||
for(let i = 0; i < permissionDict[key].length; i++){
|
||||
for(let newKey in permissionDict[key][i]){
|
||||
if(!newHeaders.includes(newKey)){newHeaders.push(newKey)}
|
||||
}
|
||||
}
|
||||
newHeaders.sort()
|
||||
permissions.push({"name": key, "value": permissionDict[key], new_table: true, is_array: true, headers: newHeaders});
|
||||
} else {
|
||||
// it's an array, but not of dictionaries, so just stringify it
|
||||
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
|
||||
}
|
||||
} else {
|
||||
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
|
||||
}
|
||||
}else if(permissionDict[key] !== undefined && permissionDict[key] !== null){
|
||||
permissions.push({"name": key, "value": permissionDict[key]});
|
||||
}
|
||||
|
||||
setHeaders([props.leftColumn, props.rightColumn]);
|
||||
}
|
||||
}else{
|
||||
setTableType("array");
|
||||
if(permissionDict.length > 0){
|
||||
setHeaders(Object.keys(permissionDict[0]));
|
||||
permissions = permissionDict;
|
||||
}else{
|
||||
setHeaders([]);
|
||||
}
|
||||
}
|
||||
}catch(error){
|
||||
console.log(error);
|
||||
const isPlainObject = (value) => {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const parseJSONDialogValue = (value) => {
|
||||
if(value === undefined || value === null){
|
||||
return value;
|
||||
}
|
||||
if(typeof value !== "string"){
|
||||
return value;
|
||||
}
|
||||
try{
|
||||
return JSON.parse(value);
|
||||
}catch(error){
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const getJSONValueType = (value) => {
|
||||
if(value === null){return "null"}
|
||||
if(value === undefined){return "empty"}
|
||||
if(Array.isArray(value)){return "array"}
|
||||
if(isPlainObject(value)){return "object"}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
const getJSONValueCount = (value) => {
|
||||
if(Array.isArray(value)){return value.length}
|
||||
if(isPlainObject(value)){return Object.keys(value).length}
|
||||
if(value === undefined || value === null || value === ""){return 0}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const getArrayObjectHeaders = (rows) => {
|
||||
const headers = [];
|
||||
rows.forEach((row) => {
|
||||
if(!isPlainObject(row)){return}
|
||||
Object.keys(row).forEach((key) => {
|
||||
if(!headers.includes(key)){
|
||||
headers.push(key);
|
||||
}
|
||||
setComment(permissions);
|
||||
}, [props.value, props.leftColumn, props.rightColumn]);
|
||||
});
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
const JSONTypeBadge = ({value}) => {
|
||||
const type = getJSONValueType(value);
|
||||
const count = getJSONValueCount(value);
|
||||
const label = type === "array" ? `${count} item${count === 1 ? "" : "s"}` :
|
||||
type === "object" ? `${count} field${count === 1 ? "" : "s"}` :
|
||||
type;
|
||||
return <MythicChip compact label={label} tone={["array", "object"].includes(type) ? "info" : "neutral"} />;
|
||||
}
|
||||
|
||||
const JSONPrimitiveValue = ({name, value, me}) => {
|
||||
const type = getJSONValueType(value);
|
||||
if(type === "null" || type === "empty" || value === ""){
|
||||
return <span className="mythic-json-value-empty text-xs text-disabled">None</span>
|
||||
}
|
||||
if(type === "boolean"){
|
||||
return <MythicChip compact label={value ? "True" : "False"} tone={value ? "success" : "warning"} />;
|
||||
}
|
||||
return <span className="mythic-json-value-primitive text-primary text-xs leading-135 wrap-anywhere">{convertValueToContextValue(name, value, me)}</span>
|
||||
}
|
||||
|
||||
const JSONTableCellValue = ({name, value, me, depth}) => {
|
||||
if(Array.isArray(value) || isPlainObject(value)){
|
||||
return (
|
||||
<JSONTableValue
|
||||
label={name}
|
||||
value={value}
|
||||
me={me}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <JSONPrimitiveValue name={name} value={value} me={me} />;
|
||||
}
|
||||
|
||||
const JSONTableValue = ({label, value, me, depth=0, leftColumn="Name", rightColumn="Value"}) => {
|
||||
const type = getJSONValueType(value);
|
||||
const showPanelHeader = Boolean(label) && depth === 0;
|
||||
if(type !== "array" && type !== "object"){
|
||||
return <JSONPrimitiveValue name={label || ""} value={value} me={me} />;
|
||||
}
|
||||
if(type === "object"){
|
||||
const entries = Object.entries(value);
|
||||
return (
|
||||
<div className={`mythic-json-panel p-4 flex flex-column gap-4 rounded bg-neutral-1 border-subtle${depth === 0 ? " mythic-json-panel-root bg-surface-muted" : ""} min-w-0`}>
|
||||
{showPanelHeader &&
|
||||
<div className="mythic-json-panel-header items-center flex flex-wrap gap-4 justify-between min-w-0">
|
||||
<span className="mythic-json-panel-title text-xs font-800 leading-125 min-w-0 wrap-anywhere text-primary">{label}</span>
|
||||
<JSONTypeBadge value={value} />
|
||||
</div>
|
||||
}
|
||||
{entries.length === 0 ? (
|
||||
<div className="mythic-json-empty-state text-xs text-muted">No fields to display.</div>
|
||||
) : (
|
||||
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
|
||||
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: depth === 0 ? "14rem" : "12rem"}}>{leftColumn}</TableCell>
|
||||
<TableCell>{rightColumn}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{entries.map(([key, entryValue]) => (
|
||||
<TableRow key={`${depth}-${key}`} hover>
|
||||
<TableCell className="mythic-json-key-cell">
|
||||
<div className="mythic-json-key-stack flex flex-column gap-2 min-w-0">
|
||||
<span className="mythic-json-key text-xs font-800 leading-125 wrap-anywhere text-primary">{key}</span>
|
||||
<JSONTypeBadge value={entryValue} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
|
||||
<JSONTableCellValue name={key} value={entryValue} me={me} depth={depth} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const objectHeaders = getArrayObjectHeaders(value);
|
||||
const isObjectArray = value.length > 0 && objectHeaders.length > 0 && value.every((row) => isPlainObject(row));
|
||||
return (
|
||||
<div className={`mythic-json-panel p-4 flex flex-column gap-4 rounded bg-neutral-1 border-subtle${depth === 0 ? " mythic-json-panel-root bg-surface-muted" : ""} min-w-0`}>
|
||||
{showPanelHeader &&
|
||||
<div className="mythic-json-panel-header items-center flex flex-wrap gap-4 justify-between min-w-0">
|
||||
<span className="mythic-json-panel-title text-xs font-800 leading-125 min-w-0 wrap-anywhere text-primary">{label}</span>
|
||||
<JSONTypeBadge value={value} />
|
||||
</div>
|
||||
}
|
||||
{value.length === 0 ? (
|
||||
<div className="mythic-json-empty-state text-xs text-muted">No items to display.</div>
|
||||
) : isObjectArray ? (
|
||||
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
|
||||
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed", minWidth: `${Math.max(38, objectHeaders.length * 12)}rem`}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: "4rem"}}>#</TableCell>
|
||||
{objectHeaders.map((header) => (
|
||||
<TableCell key={`array-header-${header}`} style={{width: "12rem"}}>{header}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.map((row, rowIndex) => (
|
||||
<TableRow key={`array-row-${rowIndex}`} hover>
|
||||
<TableCell className="mythic-json-index-cell text-xs font-800 text-muted text-right">{rowIndex + 1}</TableCell>
|
||||
{objectHeaders.map((header) => (
|
||||
<TableCell key={`array-row-${rowIndex}-${header}`} className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
|
||||
<JSONTableCellValue name={header} value={row[header]} me={me} depth={depth} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
|
||||
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: "4rem"}}>#</TableCell>
|
||||
<TableCell>Value</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{value.map((entryValue, rowIndex) => (
|
||||
<TableRow key={`array-value-row-${rowIndex}`} hover>
|
||||
<TableCell className="mythic-json-index-cell text-xs font-800 text-muted text-right">{rowIndex + 1}</TableCell>
|
||||
<TableCell className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
|
||||
<JSONTableCellValue name={`${label || "value"} ${rowIndex + 1}`} value={entryValue} me={me} depth={depth} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicViewJSONAsTableDialog(props) {
|
||||
const parsedValue = React.useMemo(() => parseJSONDialogValue(props.value), [props.value]);
|
||||
const rootLabel = props.title || "JSON Data";
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<Paper elevation={5} style={{position: "relative"}} variant={"elevation"}>
|
||||
<TableContainer component={Paper} className="mythicElement">
|
||||
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{headers.map( (header, index) => (
|
||||
<TableCell key={'header' + index} style={index === 0 ? {width: "30%"} : {}}>{header}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tableType === "dictionary" ? (
|
||||
comment.map( (element, index) => (
|
||||
<TableRow key={'row' + index} hover>
|
||||
<TableCell>{element.name}</TableCell>
|
||||
{element.new_table ?
|
||||
(
|
||||
<TableContainer component={Paper} className="mythicElement">
|
||||
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{element.headers.map( (header, index) => (
|
||||
<TableCell key={'eheader' + index} style={index === 0 ? {width: "30%"} : {}}>{header}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{element.is_dictionary ? (
|
||||
Object.keys(element.value).map( (key, dictIndex) => (
|
||||
<TableRow key={'element' + dictIndex + "dictheader"}>
|
||||
<TableCell style={{width: "30%", wordBreak: "break-all"}}>{key}</TableCell>
|
||||
<TableCell style={{wordBreak: "break-all"}}>{convertValueToContextValue(key, element.value[key])}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
): (
|
||||
element.value.map( (e, elementIndex) => (
|
||||
<TableRow>
|
||||
{element.headers.map( (header, headerIndex) => (
|
||||
<TableCell key={'element' + elementIndex + "header" + headerIndex} style={headerIndex === 0 ? {width: "30%"} : {}}>{convertValueToContextValue(header, e[header])}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
:
|
||||
(<TableCell style={{wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{convertValueToContextValue(element.name, element.value) }</TableCell>)
|
||||
}
|
||||
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
comment.map( (row, index) => (
|
||||
<TableRow key={'row' + index} hover>
|
||||
{Object.keys(row).map( (key) => (
|
||||
<TableCell key={"row" + index + "cell" + key}>{convertValueToContextValue(key, row[key])}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) }
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDraggableDialogTitle style={{wordBreak: "break-all", maxWidth: "100%"}}>
|
||||
<div className="mythic-json-title-row items-center flex flex-wrap gap-4 justify-between min-w-0 w-full">
|
||||
<span>{rootLabel}</span>
|
||||
<JSONTypeBadge value={parsedValue} />
|
||||
</div>
|
||||
</MythicDraggableDialogTitle>
|
||||
<DialogContent dividers={true} className="mythic-dialog-body flex flex-column gap-6 mythic-json-dialog-body min-w-0 w-full overflow-auto">
|
||||
<JSONTableValue
|
||||
value={parsedValue}
|
||||
me={props.me}
|
||||
leftColumn={props.leftColumn || "Name"}
|
||||
rightColumn={props.rightColumn || "Value"}
|
||||
/>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -234,7 +510,7 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<Paper elevation={5} style={{position: "relative"}} variant={"elevation"}>
|
||||
<TableContainer component={Paper} className="mythicElement">
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -245,8 +521,8 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
|
||||
<TableBody>
|
||||
{comment.map( (element, index) => (
|
||||
<TableRow key={'row' + index}>
|
||||
<TableCell>{element.name}</TableCell>
|
||||
<TableCell>{convertValueToContextValue(element.name, element.value)}</TableCell>
|
||||
<TableCell style={{wordBreak: "break-all"}}>{element.name}</TableCell>
|
||||
<TableCell style={{wordBreak: "break-all"}}>{convertValueToContextValue(element.name, element.value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -254,30 +530,34 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const convertValueToContextValue = (key, value) => {
|
||||
if( key.includes("time") ){
|
||||
const convertValueToContextValue = (key, value, me) => {
|
||||
const keyText = String(key || "").toLowerCase();
|
||||
if(value === undefined || value === null){
|
||||
return "";
|
||||
}
|
||||
if( keyText.includes("time") ){
|
||||
try{
|
||||
return TableRowDateCell({cellData: value})
|
||||
return TableRowDateCell({cellData: value, view_utc_time: me?.user?.view_utc_time})
|
||||
}catch(error){
|
||||
console.log("failed to parse metadata as date", key, value);
|
||||
return value;
|
||||
}
|
||||
} else if( key.includes("size") ){
|
||||
} else if( keyText.includes("size") ){
|
||||
try{
|
||||
return TableRowSizeCell({cellData: value})
|
||||
}catch(error){
|
||||
console.log("failed to parse metadata as size", key, value);
|
||||
return value;
|
||||
}
|
||||
} else if (value.constructor === Object) {
|
||||
} else if (isPlainObject(value)) {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} else if (Array.isArray(value)){
|
||||
return JSON.stringify(value, null, 2);
|
||||
@@ -290,27 +570,39 @@ const convertValueToContextValue = (key, value) => {
|
||||
}
|
||||
}
|
||||
export const TableRowDateCell = ({ cellData, rowData, view_utc_time=true }) => {
|
||||
|
||||
try{
|
||||
let cellDataInt = parseInt(cellData)
|
||||
if(cellData === "" || cellData === undefined || cellDataInt <= 0){
|
||||
return cellData;
|
||||
}
|
||||
// handle Unix epoch timestamps
|
||||
const dateData = new Date(cellDataInt).toISOString();
|
||||
return toLocalTime(dateData.slice(0, 10) + " " + dateData.slice(11,-1), view_utc_time);
|
||||
}catch(error){
|
||||
try{
|
||||
let cellDataInt = parseInt(cellData)
|
||||
// handle windows FILETIME values
|
||||
const dateData = new Date( ((cellDataInt / 10000000) - 11644473600) * 1000).toISOString();
|
||||
return toLocalTime(dateData.slice(0, 10) + " " + dateData.slice(11,-1), view_utc_time);
|
||||
}catch(error2){
|
||||
console.log("error with timestamp: ", cellData);
|
||||
return String(cellData);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try{
|
||||
let cellDataInt = parseInt(cellData)
|
||||
if(cellData === "" || cellData === undefined || cellDataInt <= 0){
|
||||
return "";
|
||||
}
|
||||
let view_utc = true;
|
||||
if(view_utc_time !== undefined){
|
||||
view_utc = view_utc_time
|
||||
}
|
||||
// handle Unix epoch timestamps
|
||||
if (view_utc) {
|
||||
let init_date = new Date(cellDataInt);
|
||||
return init_date.toDateString() + " " + init_date.toTimeString().substring(0, 8) + " UTC";
|
||||
} else {
|
||||
let timezoneDate = new Date(cellDataInt);
|
||||
timezoneDate.setTime(timezoneDate.getTime() - (timezoneDate.getTimezoneOffset() *60*1000));
|
||||
return timezoneDate.toLocaleDateString() + " " + timezoneDate.toLocaleString([], {hour12: true, hour: "2-digit", minute: "2-digit"});
|
||||
}
|
||||
//const dateData = new Date(cellDataInt).toISOString();
|
||||
//return toLocalTime(dateData.slice(0, 10) + " " + dateData.slice(11,-1), view_utc_time);
|
||||
}catch(error){
|
||||
try{
|
||||
let cellDataInt = parseInt(cellData)
|
||||
// handle windows FILETIME values
|
||||
const dateData = new Date( ((cellDataInt / 10000000) - 11644473600) * 1000).toISOString();
|
||||
return toLocalTime(dateData.slice(0, 10) + " " + dateData.slice(11,-1), view_utc_time);
|
||||
}catch(error2){
|
||||
console.log("error with timestamp: ", cellData);
|
||||
return String(cellData);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
export const TableRowSizeCell = ({ cellData, rowData }) => {
|
||||
@@ -333,4 +625,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
|
||||
}
|
||||
};
|
||||
return getStringSize(cellData);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from "react";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Draggable from "react-draggable";
|
||||
import {useTheme} from "@mui/material/styles";
|
||||
|
||||
let mythicDialogIdCounter = 0;
|
||||
let mythicDialogStack = [];
|
||||
|
||||
const MythicDraggableDialogPaper = React.forwardRef(function MythicDraggableDialogPaper(props, ref) {
|
||||
const {draggableCancel, draggableHandle, draggableOnStart, draggableOnStop, ...paperProps} = props;
|
||||
const nodeRef = React.useRef(null);
|
||||
const setPaperRef = React.useCallback((node) => {
|
||||
nodeRef.current = node;
|
||||
if(typeof ref === "function"){
|
||||
ref(node);
|
||||
}else if(ref){
|
||||
ref.current = node;
|
||||
}
|
||||
}, [ref]);
|
||||
const onStart = React.useCallback((event, data) => {
|
||||
return draggableOnStart?.(event, data, nodeRef.current);
|
||||
}, [draggableOnStart]);
|
||||
return (
|
||||
<Draggable nodeRef={nodeRef} handle={draggableHandle} cancel={draggableCancel}
|
||||
onStart={onStart} onStop={draggableOnStop}>
|
||||
<Paper {...paperProps} ref={setPaperRef} />
|
||||
</Draggable>
|
||||
);
|
||||
});
|
||||
|
||||
export function MythicDialog(props) {
|
||||
const theme = useTheme();
|
||||
const dialogIdRef = React.useRef(null);
|
||||
if(dialogIdRef.current === null){
|
||||
dialogIdRef.current = mythicDialogIdCounter++;
|
||||
}
|
||||
const [draggedState, setDraggedState] = React.useState({
|
||||
style: {},
|
||||
paperStyle: {margin: "0", height: "fit-content", width: "stretch"},
|
||||
containerStyle: {},
|
||||
hideBackdrop: false,
|
||||
modified: false,
|
||||
});
|
||||
const descriptionElementRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if(!props.open){return undefined;}
|
||||
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
|
||||
mythicDialogStack.push(dialogIdRef.current);
|
||||
return () => {
|
||||
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
|
||||
};
|
||||
}, [props.open]);
|
||||
const isTopDialog = () => mythicDialogStack[mythicDialogStack.length - 1] === dialogIdRef.current;
|
||||
React.useEffect(() => {
|
||||
if(props.open && descriptionElementRef.current !== null){
|
||||
descriptionElementRef.current.focus();
|
||||
}
|
||||
}, [props.open]);
|
||||
const dialogOnClick = (event) => {
|
||||
if(event.target.classList?.contains("MuiDialog-container")){
|
||||
event.stopPropagation();
|
||||
if(isTopDialog() && draggedState.hideBackdrop){
|
||||
props.onClose?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleOnClose = (event, reason) => {
|
||||
if(reason === "backdropClick" || reason === "escapeKeyDown"){
|
||||
event?.stopPropagation?.();
|
||||
if(!isTopDialog()){return;}
|
||||
}
|
||||
if(reason === "backdropClick" && draggedState.hideBackdrop){return;}
|
||||
props.onClose?.();
|
||||
};
|
||||
const onStart = React.useCallback((event, _data, paperNode) => {
|
||||
event?.stopPropagation?.();
|
||||
event?.preventDefault?.();
|
||||
setDraggedState((currentState) => {
|
||||
if(currentState.modified){return currentState;}
|
||||
const dragTarget = paperNode || event?.target?.closest?.(".MuiPaper-root") || event?.target?.offsetParent;
|
||||
const targetWidth = dragTarget?.offsetWidth || event?.target?.offsetParent?.offsetWidth || 0;
|
||||
return {
|
||||
style: {},
|
||||
paperStyle: {...(targetWidth > 0 ? {width: `${targetWidth}px`} : {}), margin: 0, overflowY: "auto"},
|
||||
containerStyle: {overflow: "visible"},
|
||||
hideBackdrop: true,
|
||||
modified: true,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const onStop = React.useCallback((event) => {
|
||||
event?.stopPropagation?.();
|
||||
event?.preventDefault?.();
|
||||
}, []);
|
||||
return (
|
||||
<Dialog open={props.open} onClose={handleOnClose} scroll="paper" maxWidth={props.maxWidth} fullWidth={true}
|
||||
PaperComponent={MythicDraggableDialogPaper}
|
||||
PaperProps={{draggableHandle: "#mythic-draggable-title", draggableCancel: '[class*="MuiDialogContent-root"]', draggableOnStart: onStart, draggableOnStop: onStop}}
|
||||
style={{...props.style, ...draggedState.style}} disableEnforceFocus={true} disablePortal={false}
|
||||
hideBackdrop={draggedState.hideBackdrop} aria-labelledby="scroll-dialog-title"
|
||||
aria-describedby="scroll-dialog-description"
|
||||
sx={{
|
||||
".MuiPaper-root": {borderRadius: "8px", backgroundColor: theme.palette.background.paper, border: `1px solid ${theme.borderColor}`, ...draggedState.paperStyle},
|
||||
".MuiDialog-container": {...draggedState.containerStyle},
|
||||
}}
|
||||
onMouseDown={dialogOnClick} onContextMenu={(event) => event.stopPropagation()}>
|
||||
{props.innerDialog}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
const joinClasses = (...classes) => classes.filter(Boolean).join(" ");
|
||||
|
||||
export function MythicDialogBody({children, className, compact = false, ...props}) {
|
||||
return (
|
||||
<Box
|
||||
className={joinClasses("mythic-dialog-body flex flex-column gap-6 min-w-0 w-full", compact && "mythic-dialog-body-compact gap-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogSection({title, description, actions, children, className, ...props}) {
|
||||
return (
|
||||
<Box component="section" className={joinClasses("mythic-dialog-section bg-surface-muted border-subtle min-w-0 rounded", className)} {...props}>
|
||||
{(title || description || actions) &&
|
||||
<Box className="mythic-dialog-section-header items-start flex gap-6 justify-between min-w-0">
|
||||
<Box sx={{minWidth: 0}}>
|
||||
{title &&
|
||||
<Typography component="h3" className="mythic-dialog-section-title text-sm font-700 leading-125 text-primary">
|
||||
{title}
|
||||
</Typography>
|
||||
}
|
||||
{description &&
|
||||
<Typography component="div" className="mythic-dialog-section-description">
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
{actions &&
|
||||
<Box className="mythic-dialog-section-actions items-center flex flex-none gap-3">
|
||||
{actions}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
|
||||
return (
|
||||
<Box
|
||||
className={joinClasses("mythic-dialog-grid items-logical-start gap-5 min-w-0 w-full grid", className)}
|
||||
sx={{"--mythic-dialog-grid-min": minWidth, ...sx}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogChoiceRow({children, className, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-dialog-choice-row items-center gap-4 w-full grid", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogChoiceDivider({children = "OR", className, ...props}) {
|
||||
return (
|
||||
<Box component="span" className={joinClasses("mythic-dialog-choice-divider text-xs font-700 text-muted text-center", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogFooter({children, className, ...props}) {
|
||||
return (
|
||||
<DialogActions className={className} {...props}>
|
||||
{children}
|
||||
</DialogActions>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogButton({children, className, intent = "secondary", ...props}) {
|
||||
const tone = intent === "destructive" ? "error" : intent;
|
||||
return (
|
||||
<MythicActionButton
|
||||
className={joinClasses("mythic-dialog-button", className)}
|
||||
colorMode="always"
|
||||
size="small"
|
||||
tone={tone}
|
||||
variant="contained"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MythicActionButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicForm({children, className, ...props}) {
|
||||
return (
|
||||
<Box component="form" className={joinClasses("mythic-form flex flex-column gap-6 min-w-0 w-full", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
|
||||
return (
|
||||
<Box
|
||||
className={joinClasses("mythic-form-grid items-logical-start gap-6 min-w-0 w-full grid", className)}
|
||||
sx={{"--mythic-form-grid-min": minWidth, ...sx}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormField({children, className, description, label, required = false, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-form-field flex flex-column gap-3 min-w-0", className)} {...props}>
|
||||
{(label || description) &&
|
||||
<Box className="mythic-form-field-copy min-w-0">
|
||||
{label &&
|
||||
<Typography component="label" className="mythic-form-field-label text-xs font-750 leading-125 text-primary">
|
||||
{label}{required && <Box component="span" className="mythic-form-field-required"> *</Box>}
|
||||
</Typography>
|
||||
}
|
||||
{description &&
|
||||
<Typography component="div" className="mythic-form-field-description">
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
<Box className="mythic-form-field-control min-w-0 w-full">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormNote({children, className, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-form-note text-xs leading-140 rounded bg-neutral-1 border-subtle text-muted", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormSwitchRow({control, label, description, className, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-form-switch-row py-5 px-6 items-center flex gap-6 justify-between min-w-0 w-full rounded bg-surface border-subtle", className)} {...props}>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography component="div" className="mythic-form-field-label text-xs font-750 leading-125 text-primary">
|
||||
{label}
|
||||
</Typography>
|
||||
{description &&
|
||||
<Typography component="div" className="mythic-form-field-description">
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
<Box className="mythic-form-switch-control flex-none">
|
||||
{control}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,16 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {MythicDialog} from './MythicDialog';
|
||||
import MythicTextField from './MythicTextField';
|
||||
import {MythicDialog, MythicModifyStringDialog} from './MythicDialog';
|
||||
|
||||
export function MythicDisplayTextDialog(props) {
|
||||
return (
|
||||
<MythicDialog fullWidth={props.fullWidth === null ? false : props.fullWidth} maxWidth={props.maxWidth === null ? "sm" : props.maxWidth} open={props.open} onClose={()=>{props.onClose()}} innerDialog={
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<MythicTextField multiline={true} value={props.value} onChange={()=>{}} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
<MythicDialog fullWidth={props.fullWidth === null ? false : props.fullWidth}
|
||||
maxWidth={props.maxWidth === null ? "sm" : props.maxWidth}
|
||||
open={props.open}
|
||||
onClose={()=>{props.onClose()}} innerDialog={
|
||||
<MythicModifyStringDialog title={props.title}
|
||||
onClose={props.onClose}
|
||||
value={props.value}
|
||||
maxRows={20} />
|
||||
} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
|
||||
export const MythicDraggableDialogTitle = ({children}) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<DialogTitle id="mythic-draggable-title" style={{
|
||||
cursor: 'move',
|
||||
width: "100%",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
color: theme.palette.text.primary,
|
||||
}}>
|
||||
{children}
|
||||
</DialogTitle>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
export const reorder = (
|
||||
list,
|
||||
startIndex,
|
||||
endIndex
|
||||
) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const MythicDraggablePortal = ({children, isDragging}) => {
|
||||
if(isDragging && typeof document !== "undefined"){
|
||||
return createPortal(children, document.body);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
@@ -1,19 +1,23 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { Divider, Input, MenuItem, Select } from '@mui/material';
|
||||
import MythicTextField from './MythicTextField';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import {gql } from '@apollo/client';
|
||||
import {snackActions} from '../utilities/Snackbar';
|
||||
import {useMutation } from '@apollo/client';
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogGrid,
|
||||
MythicDialogSection,
|
||||
MythicForm,
|
||||
MythicFormField,
|
||||
MythicFormNote
|
||||
} from './MythicDialogLayout';
|
||||
|
||||
const submitFeedbackMutation = gql`
|
||||
mutation submitFeedback($webhookType: String!, $webhookData: jsonb!){
|
||||
@@ -25,15 +29,15 @@ const submitFeedbackMutation = gql`
|
||||
`;
|
||||
export function MythicFeedbackDialog(props) {
|
||||
const [message, setMessage] = React.useState("");
|
||||
const [taskID, setTaskID] = React.useState(0);
|
||||
const [taskID, setTaskID] = React.useState("");
|
||||
const messageTypeOptions = [
|
||||
{display: "Bug", value: "bug"},
|
||||
{display: "Feature Request", value: "feature_request"},
|
||||
{display: "Confusing UI", value: "confusing_ui"},
|
||||
{display: "Detection", value: "detection"},
|
||||
{display: "Bug", value: "bug", description: "Something is broken or behaving unexpectedly."},
|
||||
{display: "Feature Request", value: "feature_request", description: "A workflow, capability, or quality-of-life improvement."},
|
||||
{display: "Confusing UI", value: "confusing_ui", description: "A part of the interface is unclear or hard to use."},
|
||||
{display: "Detection", value: "detection", description: "Detection, telemetry, or visibility feedback."},
|
||||
];
|
||||
const [messageType, setMessageType] = React.useState("bug");
|
||||
const [submitFeedback] = useMutation(submitFeedbackMutation, {
|
||||
const [submitFeedback, {loading}] = useMutation(submitFeedbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.sendExternalWebhook.status === "success"){
|
||||
snackActions.success("Submitted Feedback!");
|
||||
@@ -48,90 +52,111 @@ export function MythicFeedbackDialog(props) {
|
||||
}
|
||||
});
|
||||
const handleSubmit = () => {
|
||||
let webhookData = {};
|
||||
if(taskID > 0){
|
||||
webhookData["task_id"] = String(taskID);
|
||||
const trimmedMessage = message.trim();
|
||||
if(trimmedMessage.length === 0){
|
||||
snackActions.warning("Please include feedback before submitting.");
|
||||
return;
|
||||
}
|
||||
webhookData["message"] = message;
|
||||
let webhookData = {};
|
||||
if(Number(taskID) > 0){
|
||||
webhookData["task_display_id"] = String(Number(taskID));
|
||||
}
|
||||
webhookData["message"] = trimmedMessage;
|
||||
webhookData["feedback_type"] = messageType;
|
||||
submitFeedback({variables: {webhookType: "new_feedback", webhookData: webhookData}});
|
||||
}
|
||||
const handleMessageTypeChange = (evt) => {
|
||||
setMessageType(evt.target.value);
|
||||
}
|
||||
const handleTaskIDChange = (name, value, error) => {
|
||||
setTaskID(value);
|
||||
const handleTaskIDChange = (evt) => {
|
||||
setTaskID(evt.target.value);
|
||||
}
|
||||
const handleMessageChange = (name, value, error) => {
|
||||
setMessage(value);
|
||||
const handleMessageChange = (evt) => {
|
||||
setMessage(evt.target.value);
|
||||
}
|
||||
const handleOnEnter = (event) => {
|
||||
if( event.shiftKey){
|
||||
handleSubmit();
|
||||
}
|
||||
const onSubmitForm = (event) => {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
const selectedMessageType = messageTypeOptions.find((opt) => opt.value === messageType);
|
||||
const canSubmit = message.trim().length > 0 && !loading;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle >{props.title}</DialogTitle>
|
||||
<Divider></Divider>
|
||||
<DialogContent style={{padding: "10px"}}>
|
||||
Send a feedback report to the slack webhook configured for the current operation.
|
||||
This provides a way to easily capture a bug, feedback requests, or comments without breaking operator flow too much. <br/>
|
||||
Shift+Enter will auto-submit the form.
|
||||
<TableContainer component={Paper} className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody style={{whiteSpace: "pre"}}>
|
||||
<TableRow hover >
|
||||
<TableCell style={{width: "5rem"}}>
|
||||
Feedback Type
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={messageType}
|
||||
onChange={handleMessageTypeChange}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
>
|
||||
{messageTypeOptions.map( (opt) => (
|
||||
<MenuItem value={opt.value} key={opt.value}>{opt.display}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover >
|
||||
<TableCell style={{width: "5rem"}}>
|
||||
Task (if applicable)
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={taskID} type={"number"}
|
||||
onChange={handleTaskIDChange} display="inline-block" name={"taskid"} showLabel={false}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>
|
||||
Feedback
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={message} multiline={true} onEnter={handleOnEnter}
|
||||
onChange={handleMessageChange} display="inline-block" name={"taskid"} showLabel={false}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DialogTitle>{props.title || "Submit Feedback"}</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Feedback Destination"
|
||||
description="Send this report to the feedback webhook configured for the current operation."
|
||||
>
|
||||
<MythicFormNote>
|
||||
Use this for bugs, workflow friction, feature ideas, detection notes, or anything operators should be able to capture without leaving Mythic.
|
||||
</MythicFormNote>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection
|
||||
title="Report Details"
|
||||
description="Capture the category, optional task context, and the feedback details."
|
||||
>
|
||||
<MythicForm onSubmit={onSubmitForm}>
|
||||
<MythicDialogGrid minWidth="14rem">
|
||||
<MythicFormField label="Feedback Type" description={selectedMessageType?.description} required>
|
||||
<FormControl fullWidth size="small">
|
||||
<Select
|
||||
labelId="feedback-type-select-label"
|
||||
id="feedback-type-select"
|
||||
value={messageType}
|
||||
onChange={handleMessageTypeChange}
|
||||
>
|
||||
{messageTypeOptions.map( (opt) => (
|
||||
<MenuItem value={opt.value} key={opt.value}>{opt.display}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</MythicFormField>
|
||||
<MythicFormField
|
||||
label="Task"
|
||||
description="Optional task display ID for feedback tied to specific output or tasking."
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
type="number"
|
||||
value={taskID}
|
||||
onChange={handleTaskIDChange}
|
||||
placeholder="Optional"
|
||||
inputProps={{min: 0}}
|
||||
/>
|
||||
</MythicFormField>
|
||||
</MythicDialogGrid>
|
||||
<MythicFormField
|
||||
label="Feedback"
|
||||
description="Include the context an operator or maintainer would need to understand the issue."
|
||||
required
|
||||
>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
maxRows={10}
|
||||
value={message}
|
||||
onChange={handleMessageChange}
|
||||
placeholder="What happened, what did you expect, and what would make this easier?"
|
||||
/>
|
||||
</MythicFormField>
|
||||
</MythicForm>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" color="success">
|
||||
Submit Feedback
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={handleSubmit} intent="primary" disabled={!canSubmit}>
|
||||
{loading ? "Submitting..." : "Submit Feedback"}
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,387 +1,483 @@
|
||||
import { alpha } from "@mui/material";
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
import { snackActions } from '../utilities/Snackbar';
|
||||
import {faFolderOpen, faFolder} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
import DescriptionIcon from '@mui/icons-material/Description';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import { Typography } from '@mui/material';
|
||||
import { MythicStyledTooltip } from "./MythicStyledTooltip";
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import { areEqual } from 'react-window';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
rowContainer: {},
|
||||
row: {
|
||||
const PREFIX = 'FileBrowserVirtualTree';
|
||||
|
||||
const classes = {
|
||||
rowContainer: `${PREFIX}-rowContainer`,
|
||||
row: `${PREFIX}-row`,
|
||||
rowButtonWrapper: `${PREFIX}-rowButtonWrapper`,
|
||||
rowButton: `${PREFIX}-rowButton`,
|
||||
rowLabel: `${PREFIX}-rowLabel`,
|
||||
heading: `${PREFIX}-heading`,
|
||||
secondaryHeading: `${PREFIX}-secondaryHeading`,
|
||||
taskAndTimeDisplay: `${PREFIX}-taskAndTimeDisplay`,
|
||||
secondaryHeadingExpanded: `${PREFIX}-secondaryHeadingExpanded`,
|
||||
icon: `${PREFIX}-icon`,
|
||||
details: `${PREFIX}-details`,
|
||||
column: `${PREFIX}-column`,
|
||||
paper: `${PREFIX}-paper`,
|
||||
table: `${PREFIX}-table`,
|
||||
visuallyHidden: `${PREFIX}-visuallyHidden`
|
||||
};
|
||||
|
||||
const StyledAutoSizer = styled(AutoSizer)((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
|
||||
[`& .${classes.rowContainer}`]: {
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
transition: "background-color 120ms ease, color 120ms ease",
|
||||
},
|
||||
|
||||
[`& .${classes.rowContainer}:hover`]: {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
|
||||
[`& .${classes.row}`]: {
|
||||
display: "flex",
|
||||
alignItems: "left",
|
||||
marginLeft: (props) => theme.spacing(3 * props.depth),
|
||||
alignItems: "center",
|
||||
height: "100%",
|
||||
minWidth: "fit-content",
|
||||
paddingRight: theme.spacing(1),
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap"
|
||||
whiteSpace: "nowrap",
|
||||
width: "100%",
|
||||
},
|
||||
rowButtonWrapper: {
|
||||
|
||||
[`& .${classes.row}[data-deleted="true"]`]: {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
|
||||
[`& .${classes.rowButtonWrapper}`]: {
|
||||
alignSelf: "stretch",
|
||||
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.34)}`,
|
||||
flex: "0 0 14px",
|
||||
marginLeft: 7,
|
||||
},
|
||||
|
||||
[`& .${classes.rowButton}`]: {
|
||||
alignItems: "center",
|
||||
display: "inline-flex",
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "center",
|
||||
width: theme.spacing(3),
|
||||
textAlign: "center",
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline"
|
||||
}
|
||||
},
|
||||
rowButton: {
|
||||
width: theme.spacing(3)
|
||||
|
||||
[`& .${classes.rowLabel}`]: {
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: "inherit",
|
||||
fontSize: theme.typography.pxToRem(13),
|
||||
lineHeight: 1.35,
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
rowLabel: {
|
||||
marginLeft: theme.spacing(0.5)
|
||||
|
||||
[`& .${classes.rowLabel}[data-muted="true"]`]: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
heading: {
|
||||
|
||||
[`& .${classes.heading}`]: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
whiteSpace: 'pre-line',
|
||||
},
|
||||
secondaryHeading: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
//color: theme.palette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
taskAndTimeDisplay: {
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
color: theme.palette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
secondaryHeadingExpanded: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
//color: theme.palette.text.secondary,
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'break-word',
|
||||
},
|
||||
icon: {
|
||||
verticalAlign: 'middle',
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
details: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
column: {
|
||||
padding: '0 5px 0 0',
|
||||
display: 'inline-block',
|
||||
margin: 0,
|
||||
height: 'auto',
|
||||
},
|
||||
paper: {
|
||||
width: '100%',
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
table: {
|
||||
minWidth: 750,
|
||||
},
|
||||
visuallyHidden: {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
width: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
const VirtualTreeRow = ({
|
||||
[`& .${classes.secondaryHeading}`]: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
//color: theme.palette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
|
||||
[`& .${classes.taskAndTimeDisplay}`]: {
|
||||
fontSize: theme.typography.pxToRem(12),
|
||||
color: theme.palette.text.secondary,
|
||||
overflow: 'hidden',
|
||||
display: 'block',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
|
||||
[`& .${classes.secondaryHeadingExpanded}`]: {
|
||||
fontSize: theme.typography.pxToRem(15),
|
||||
//color: theme.palette.text.secondary,
|
||||
display: 'block',
|
||||
overflow: 'auto',
|
||||
maxWidth: 'calc(90vw)',
|
||||
whiteSpace: 'break-word',
|
||||
},
|
||||
|
||||
[`& .${classes.icon}`]: {
|
||||
flex: "0 0 auto",
|
||||
height: 16,
|
||||
marginLeft: 3,
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
width: 16,
|
||||
},
|
||||
|
||||
[`& .${classes.icon}[data-folder="true"]`]: {
|
||||
color: theme.folderColor,
|
||||
},
|
||||
|
||||
[`& .${classes.icon}[data-empty="true"]`]: {
|
||||
color: theme.emptyFolderColor,
|
||||
},
|
||||
|
||||
[`& .${classes.details}`]: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
[`& .${classes.column}`]: {
|
||||
padding: '0 5px 0 0',
|
||||
display: 'inline-block',
|
||||
margin: 0,
|
||||
height: 'auto',
|
||||
},
|
||||
|
||||
[`& .${classes.paper}`]: {
|
||||
width: '100%',
|
||||
height: "100%",
|
||||
marginBottom: 0,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
[`& .${classes.table}`]: {
|
||||
minWidth: 750,
|
||||
},
|
||||
|
||||
[`& .${classes.visuallyHidden}`]: {
|
||||
border: 0,
|
||||
clip: 'rect(0 0 0 0)',
|
||||
height: 1,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
width: 1,
|
||||
}
|
||||
}));
|
||||
function itemKey(index, data) {
|
||||
// Find the item at the specified index.
|
||||
// In this case "data" is an Array that was passed to List as "itemData".
|
||||
const item = data[index];
|
||||
if(item.root){
|
||||
return `${item.group};${item.id}`;
|
||||
}
|
||||
if(item.is_group){
|
||||
return item.group;
|
||||
}
|
||||
return `${item.group};${item.host};${item.full_path_text}`;
|
||||
}
|
||||
const VirtualTreeRow = React.memo(({
|
||||
onSelectNode,
|
||||
onExpandNode,
|
||||
onCollapseNode,
|
||||
onDoubleClickNode,
|
||||
contextMenuOptions,
|
||||
onContextMenu,
|
||||
tabInfo,
|
||||
selectedFolderData,
|
||||
...ListProps
|
||||
}) => {
|
||||
//console.log("listprops", ListProps)
|
||||
const itemTreeData = ListProps.data[ListProps.index];
|
||||
const item = ListProps.treeRootData[itemTreeData.host]?.[itemTreeData.full_path_text] || itemTreeData;
|
||||
const item = ListProps.treeRootData[itemTreeData.group]?.[itemTreeData.host]?.[itemTreeData.full_path_text] || itemTreeData;
|
||||
//console.log("item", item, "itemlookup", ListProps.treeRootData[itemTreeData.host]?.[itemTreeData.name])
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const classes = useStyles();
|
||||
const handleOnClickButton = (e) => {
|
||||
e.stopPropagation();
|
||||
if (itemTreeData.isOpen) {
|
||||
onCollapseNode(item.id, item);
|
||||
onCollapseNode(item.id, {...item, group: itemTreeData.group, host: itemTreeData.host});
|
||||
} else {
|
||||
snackActions.info('fetching elements...', { autoClose: false });
|
||||
onExpandNode(item.id, item);
|
||||
//snackActions.info('fetching elements...', { autoClose: false });
|
||||
onExpandNode(item.id, {...item, group: itemTreeData.group, host: itemTreeData.host});
|
||||
}
|
||||
};
|
||||
const handleOnClickRow = (e) => {
|
||||
onSelectNode(item.id, item);
|
||||
onSelectNode(item.id, {...item, group: itemTreeData.group, host: itemTreeData.host});
|
||||
};
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const handleContextClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if(contextMenuOptions && contextMenuOptions.length > 0){
|
||||
|
||||
setOpenContextMenu(true);
|
||||
const handleContextClick = (e) => {
|
||||
onContextMenu({event: e, item, itemTreeData});
|
||||
}
|
||||
const selectedPath = () => {
|
||||
if(itemTreeData.group === selectedFolderData.group && itemTreeData.host === selectedFolderData.host){
|
||||
if(itemTreeData.root){
|
||||
return "selectedCallbackHierarchy";
|
||||
}
|
||||
if(selectedFolderData.full_path_text === itemTreeData.full_path_text){
|
||||
return "selectedCallback";
|
||||
}
|
||||
},
|
||||
[contextMenuOptions] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
contextMenuOptions[index].click({event, node: item});
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
<div style={ListProps.style}>
|
||||
<div style={{display: 'flex' , marginBottom: "1px", flexGrow: 1, width: "100%"}}>
|
||||
<div className={`${classes.rowContainer} hoverme ${selectedPath()}`.trim()}
|
||||
style={ListProps.style}
|
||||
onContextMenu={handleContextClick}
|
||||
onClick={handleOnClickRow}>
|
||||
<div className={classes.row} data-deleted={itemTreeData.deleted ? "true" : undefined}>
|
||||
{[...Array(itemTreeData.depth)].map((o, i) => (
|
||||
<div
|
||||
<span
|
||||
className={classes.rowButtonWrapper}
|
||||
key={'folder' + itemTreeData.id + 'lines' + i}
|
||||
style={{
|
||||
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.4)}`,
|
||||
marginLeft: 7,
|
||||
paddingRight: 7,
|
||||
display: 'inline-block',
|
||||
}}></div>
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={classes.root}
|
||||
style={{ backgroundColor: theme.body, color: theme.text, alignItems: 'center', display: 'flex', paddingRight: "10px", textDecoration: itemTreeData.deleted ? 'line-through' : '' }}
|
||||
onClick={handleOnClickRow}
|
||||
onContextMenu={handleContextClick}
|
||||
ref={dropdownAnchorRef}
|
||||
>
|
||||
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition disablePortal style={{zIndex: 4}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
{itemTreeData.root ? (
|
||||
<ComputerIcon style={{ marginLeft: '3px', marginRight: '5px' }} onClick={handleOnClickButton} />
|
||||
|
||||
{itemTreeData.is_group ? (
|
||||
<WidgetsIcon className={classes.icon} />
|
||||
): itemTreeData.root ? (
|
||||
<ComputerIcon className={classes.icon} />
|
||||
) : !itemTreeData.can_have_children ? (
|
||||
<DescriptionIcon style={{ marginLeft: '3px', marginRight: '5px' }} />
|
||||
<DescriptionIcon className={classes.icon} />
|
||||
) : itemTreeData.isOpen ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
style={{
|
||||
marginLeft: '3px',
|
||||
marginRight: '5px',
|
||||
color: theme.folderColor
|
||||
}}
|
||||
size={"1x"}
|
||||
className={classes.icon}
|
||||
data-folder={item?.has_children || item.success ? "true" : undefined}
|
||||
data-empty={item?.has_children || item.success ? undefined : "true"}
|
||||
size={"lg"}
|
||||
onClick={handleOnClickButton} />
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
style={{ paddingTop: '5px', marginLeft: '3px', marginRight: '5px', color: theme.folderColor }} size={"lg"} icon={faFolder} onClick={handleOnClickButton} />
|
||||
className={classes.icon}
|
||||
data-folder={item?.has_children || item.success ? "true" : undefined}
|
||||
data-empty={item?.has_children || item.success ? undefined : "true"}
|
||||
size={"lg"}
|
||||
icon={faFolder} onClick={handleOnClickButton} />
|
||||
)}
|
||||
<Typography
|
||||
style={{
|
||||
color:
|
||||
itemTreeData.children.length > 0 ||
|
||||
item.success !== null
|
||||
? theme.palette.text.primary
|
||||
: theme.palette.text.secondary,
|
||||
}} component="pre">
|
||||
className={classes.rowLabel}
|
||||
data-muted={item?.has_children || item.success !== null ? undefined : "true"}
|
||||
component="pre">
|
||||
{itemTreeData.name}
|
||||
</Typography>
|
||||
|
||||
{item.success === true && itemTreeData.depth > 0 ? (
|
||||
<MythicStyledTooltip title='Successfully listed contents of folder'>
|
||||
<CheckCircleOutlineIcon fontSize='small' color="success" />
|
||||
<CheckCircleOutlineIcon className={classes.icon} fontSize='small' color="success" />
|
||||
</MythicStyledTooltip>
|
||||
) : item.success === false && itemTreeData.depth > 0 ? (
|
||||
<MythicStyledTooltip title='Failed to list contents of folder'>
|
||||
<ErrorIcon fontSize='small' color="error" />
|
||||
<ErrorIcon className={classes.icon} fontSize='small' color="error" />
|
||||
</MythicStyledTooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FileBrowserVirtualTree = ({
|
||||
}, areEqual);
|
||||
const caseInsensitiveCompare = (a, b) => {
|
||||
try{
|
||||
return a.localeCompare(b);
|
||||
}catch(error){
|
||||
console.log("localeCompare failed for", a, b);
|
||||
return a < b;
|
||||
}
|
||||
}
|
||||
const FileBrowserVirtualTreePreMemo = ({
|
||||
treeRootData,
|
||||
treeAdjMatrix,
|
||||
openNodes,
|
||||
onSelectNode,
|
||||
onExpandNode,
|
||||
onCollapseNode,
|
||||
contextMenuOptions,
|
||||
onContextMenu,
|
||||
showDeletedFiles,
|
||||
selectedFolderData,
|
||||
tabInfo,
|
||||
}) => {
|
||||
const gridRef = React.useRef(null);
|
||||
const flattenNode = useCallback(
|
||||
// node is just a full_path_text
|
||||
(node, host, depth = 0) => {
|
||||
(node, group, host, depth = 0) => {
|
||||
//console.log(node, group, host, depth);
|
||||
if(depth === 0){
|
||||
return [
|
||||
{
|
||||
id: treeRootData[host][node].id,
|
||||
name: treeRootData[host][node].name_text,
|
||||
full_path_text: treeRootData[host][node].full_path_text,
|
||||
deleted: treeRootData[host][node].deleted,
|
||||
id: treeRootData[group]?.[host]?.[node].id,
|
||||
name: treeRootData[group]?.[host]?.[node].name_text,
|
||||
full_path_text: treeRootData[group]?.[host]?.[node].full_path_text,
|
||||
deleted: treeRootData[group]?.[host]?.[node].deleted,
|
||||
depth,
|
||||
isLeaf: Object.keys(treeAdjMatrix[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[host][node].can_have_children,
|
||||
isLeaf: Object.keys(treeAdjMatrix[group]?.[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[group]?.[host]?.[node].can_have_children,
|
||||
isOpen: true,
|
||||
children: (treeAdjMatrix[host]?.[node] || {}),
|
||||
children: (treeAdjMatrix[group][host]?.[node] || {}),
|
||||
host,
|
||||
group,
|
||||
root: true
|
||||
},
|
||||
...(Object.keys(treeAdjMatrix[host]?.[node] || {})).reduce( (prev, cur) => {
|
||||
if(!treeRootData[host][cur].can_have_children){return [...prev]}
|
||||
return [...prev, flattenNode(cur, host, depth+1)];
|
||||
...(Object.keys(treeAdjMatrix[group]?.[host]?.[node] || {})).sort(caseInsensitiveCompare).reduce( (prev, cur) => {
|
||||
if(!treeRootData[group][host][cur].can_have_children){return [...prev]}
|
||||
return [...prev, flattenNode(cur, group, host, depth+1)];
|
||||
}, []).flat()
|
||||
];
|
||||
}
|
||||
if (openNodes[treeRootData[host][node].id] === true) {
|
||||
if (openNodes[`${group};${host};${treeRootData[group][host][node].full_path_text}`] === true) {
|
||||
return [
|
||||
{
|
||||
id: treeRootData[host][node].id,
|
||||
name: treeRootData[host][node].name_text,
|
||||
full_path_text: treeRootData[host][node].full_path_text,
|
||||
deleted: treeRootData[host][node].deleted,
|
||||
id: treeRootData[group][host][node].id,
|
||||
name: treeRootData[group][host][node].name_text,
|
||||
full_path_text: treeRootData[group][host][node].full_path_text,
|
||||
deleted: treeRootData[group][host][node].deleted,
|
||||
depth,
|
||||
isLeaf: Object.keys(treeAdjMatrix[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[host][node].can_have_children,
|
||||
isLeaf: Object.keys(treeAdjMatrix[group]?.[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[group][host][node].can_have_children,
|
||||
isOpen: true,
|
||||
children: (treeAdjMatrix[host]?.[node] || {}),
|
||||
children: (treeAdjMatrix[group]?.[host]?.[node] || {}),
|
||||
host,
|
||||
group,
|
||||
root: false,
|
||||
},
|
||||
...(Object.keys(treeAdjMatrix[host]?.[node] || {})).reduce( (prev, cur) => {
|
||||
if(!treeRootData[host][cur].can_have_children){return [...prev]}
|
||||
if(!showDeletedFiles && treeRootData[host][cur].deleted){return [...prev]}
|
||||
return [...prev, flattenNode(cur, host, depth+1)];
|
||||
...(Object.keys(treeAdjMatrix[group]?.[host]?.[node] || {})).sort(caseInsensitiveCompare).reduce( (prev, cur) => {
|
||||
if(!treeRootData[group][host][cur].can_have_children){return [...prev]}
|
||||
if(!showDeletedFiles && treeRootData[group][host][cur].deleted){return [...prev]}
|
||||
return [...prev, flattenNode(cur, group, host, depth+1)];
|
||||
}, []).flat()
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: treeRootData[host][node].id,
|
||||
name: treeRootData[host][node].name_text,
|
||||
full_path_text: treeRootData[host][node].full_path_text,
|
||||
deleted: treeRootData[host][node].deleted,
|
||||
id: treeRootData[group]?.[host]?.[node].id,
|
||||
name: treeRootData[group]?.[host]?.[node].name_text,
|
||||
full_path_text: treeRootData[group]?.[host]?.[node].full_path_text,
|
||||
deleted: treeRootData[group]?.[host]?.[node].deleted,
|
||||
depth,
|
||||
isLeaf: Object.keys(treeAdjMatrix[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[host][node].can_have_children,
|
||||
isLeaf: Object.keys(treeAdjMatrix[group]?.[host]?.[node] || {}).length === 0,
|
||||
can_have_children: treeRootData[group]?.[host]?.[node].can_have_children,
|
||||
isOpen: false,
|
||||
children: (treeAdjMatrix[host]?.[node] || {}),
|
||||
children: (treeAdjMatrix[group]?.[host]?.[node] || {}),
|
||||
host,
|
||||
group,
|
||||
root: false,
|
||||
}
|
||||
];
|
||||
|
||||
},
|
||||
[openNodes, showDeletedFiles] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[openNodes, showDeletedFiles, treeAdjMatrix] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const flattenedNodes = useMemo(() => {
|
||||
//console.log("in tree", treeRootData, treeAdjMatrix)
|
||||
// need to return an array
|
||||
let finalData = [];
|
||||
for(const [host, matrix] of Object.entries(treeAdjMatrix)){
|
||||
finalData.push({
|
||||
id: host,
|
||||
name: host,
|
||||
depth: 0,
|
||||
isLeaf: false,
|
||||
isOpen: true,
|
||||
can_have_children: true,
|
||||
host,
|
||||
root: true,
|
||||
deleted: false,
|
||||
success: true,
|
||||
children: matrix[""],
|
||||
full_path_text: host,
|
||||
});
|
||||
finalData.push(...Object.keys(matrix[""]).reduce((prev, c) => {
|
||||
if(!showDeletedFiles && c.deleted) {
|
||||
return [...prev];
|
||||
} else {
|
||||
return [...prev, ...flattenNode(c, host, 1)]
|
||||
}
|
||||
}, []).flat())
|
||||
//console.log(treeAdjMatrix);
|
||||
const groupKeys = Object.keys(treeAdjMatrix).sort(caseInsensitiveCompare);
|
||||
for(let i = 0; i < groupKeys.length; i++){
|
||||
finalData.push({
|
||||
id: groupKeys[i],
|
||||
name: groupKeys[i],
|
||||
depth: 0,
|
||||
isLeaf: false,
|
||||
isOpen: true,
|
||||
can_have_children: true,
|
||||
root: false,
|
||||
group: groupKeys[i],
|
||||
is_group: true,
|
||||
deleted: false,
|
||||
success: true,
|
||||
children: treeAdjMatrix[groupKeys[i]],
|
||||
full_path_text: groupKeys[i],
|
||||
});
|
||||
const hostKeys = Object.keys(treeAdjMatrix[groupKeys[i]]).sort(caseInsensitiveCompare);
|
||||
for(let j = 0; j < hostKeys.length; j++){
|
||||
//for(const [host, matrix] of Object.entries(hosts)){
|
||||
finalData.push({
|
||||
id: hostKeys[j],
|
||||
name: hostKeys[j],
|
||||
depth: 1,
|
||||
isLeaf: false,
|
||||
isOpen: true,
|
||||
can_have_children: true,
|
||||
host: hostKeys[j],
|
||||
group: groupKeys[i],
|
||||
root: true,
|
||||
deleted: false,
|
||||
children: treeAdjMatrix[groupKeys[i]][hostKeys[j]][""],
|
||||
full_path_text: hostKeys[j],
|
||||
});
|
||||
//console.log(matrix);
|
||||
finalData.push(...Object.keys(treeAdjMatrix[groupKeys[i]][hostKeys[j]][""]).sort(caseInsensitiveCompare).reduce((prev, c) => {
|
||||
if(!showDeletedFiles && c.deleted) {
|
||||
return [...prev];
|
||||
} else {
|
||||
return [...prev, ...flattenNode(c, groupKeys[i], hostKeys[j], 2)]
|
||||
}
|
||||
}, []).flat())
|
||||
}
|
||||
}
|
||||
//console.log("flattened data", finalData)
|
||||
return finalData;
|
||||
//nodes.map((node) => flattenNode(node)).flat()
|
||||
},[flattenNode, treeRootData, treeAdjMatrix, showDeletedFiles]);
|
||||
return (
|
||||
flattenedNodes.length > 0 ? (
|
||||
<AutoSizer>
|
||||
React.useEffect( () => {
|
||||
let rowIndex = flattenedNodes?.findIndex(e =>
|
||||
e.full_path_text === selectedFolderData.full_path_text &&
|
||||
e.host === selectedFolderData.host &&
|
||||
e.group === selectedFolderData.group
|
||||
);
|
||||
if(rowIndex >= 0){
|
||||
if(gridRef.current){
|
||||
gridRef.current?.scrollToItem(rowIndex, "smart")
|
||||
}
|
||||
}
|
||||
}, [selectedFolderData, flattenedNodes]);
|
||||
return flattenedNodes.length > 0 ? (
|
||||
<div className="mythic-process-browser-table-shell flex flex-column h-full min-h-0 min-w-0 overflow-hidden w-full relative">
|
||||
<StyledAutoSizer>
|
||||
{(AutoSizerProps) => (
|
||||
<List
|
||||
itemData={flattenedNodes}
|
||||
direction="vertical"
|
||||
height={AutoSizerProps.height - 10}
|
||||
width={AutoSizerProps.width - 10}
|
||||
layout="vertical"
|
||||
height={AutoSizerProps.height}
|
||||
width={AutoSizerProps.width}
|
||||
itemCount={flattenedNodes.length}
|
||||
itemKey={itemKey}
|
||||
itemSize={24}
|
||||
ref={gridRef}
|
||||
>
|
||||
{(ListProps) => (
|
||||
<VirtualTreeRow
|
||||
{...ListProps}
|
||||
tabInfo={tabInfo}
|
||||
selectedFolderData={selectedFolderData}
|
||||
treeRootData={treeRootData}
|
||||
onSelectNode={onSelectNode}
|
||||
onExpandNode={onExpandNode}
|
||||
onCollapseNode={onCollapseNode}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
</AutoSizer>
|
||||
) : null
|
||||
|
||||
);
|
||||
</StyledAutoSizer>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default FileBrowserVirtualTree;
|
||||
export const FileBrowserVirtualTree = React.memo(FileBrowserVirtualTreePreMemo);
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import {snackActions} from "../utilities/Snackbar";
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import {MythicDialog} from "./MythicDialog";
|
||||
import {gql} from '@apollo/client';
|
||||
import {b64DecodeUnicode} from "../pages/Callbacks/ResponseDisplay";
|
||||
import {PreviewFileMediaDialog} from "./PreviewFileMedia";
|
||||
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {useMythicLazyQuery} from "../utilities/useMythicLazyQuery";
|
||||
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
|
||||
|
||||
export const getfileInformationQuery = gql`
|
||||
query getFileInformation($file_id: String!){
|
||||
filemeta(where: {agent_file_id: {_eq: $file_id}}){
|
||||
filename_text
|
||||
agent_file_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MythicFileContext = ({agent_file_id, display_link, filename, extraStyles}) => {
|
||||
const [fileData, setFileData] = React.useState({
|
||||
agent_file_id: agent_file_id,
|
||||
display_link: display_link,
|
||||
filename: filename,
|
||||
})
|
||||
const [openPreviewMediaDialog, setOpenPreviewMediaDialog] = React.useState(false);
|
||||
const onPreviewMedia = (event) => {
|
||||
if(event){
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
setOpenPreviewMediaDialog(true);
|
||||
}
|
||||
const getFileInformationSuccess = (data) => {
|
||||
if(data.filemeta.length === 0){
|
||||
setFileData({
|
||||
filename: undefined,
|
||||
display_link: "",
|
||||
agent_file_id: agent_file_id,
|
||||
})
|
||||
return;
|
||||
}
|
||||
setFileData( {...fileData,
|
||||
agent_file_id: data.filemeta[0].agent_file_id,
|
||||
filename: b64DecodeUnicode(data.filemeta[0].filename_text)});
|
||||
if(display_link === "" || display_link === undefined){
|
||||
setFileData( {...fileData,
|
||||
agent_file_id: data.filemeta[0].agent_file_id,
|
||||
filename: b64DecodeUnicode(data.filemeta[0].filename_text),
|
||||
display_link: b64DecodeUnicode(data.filemeta[0].filename_text)});
|
||||
}
|
||||
}
|
||||
const getFileInformationError = (data) => {
|
||||
snackActions.error("Failed to fetch file data: " + data);
|
||||
console.log(data);
|
||||
}
|
||||
const getFileInformation = useMythicLazyQuery(getfileInformationQuery, {
|
||||
fetchPolicy: "no-cache"
|
||||
})
|
||||
React.useEffect( () => {
|
||||
if(agent_file_id !== '' && agent_file_id !== undefined){
|
||||
getFileInformation({variables: {file_id: agent_file_id}})
|
||||
.then(({data}) => getFileInformationSuccess(data)).catch((data) => getFileInformationError(data));
|
||||
}
|
||||
}, [filename, agent_file_id]);
|
||||
if(agent_file_id === '' || agent_file_id === undefined){
|
||||
return null
|
||||
}
|
||||
if(fileData.agent_file_id === '' || fileData.filename === undefined){
|
||||
return agent_file_id
|
||||
}
|
||||
const onClose = (e) => {
|
||||
if(e){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
setOpenPreviewMediaDialog(false)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<MythicStyledTooltip title={"Preview Media"} tooltipStyle={extraStyles ? extraStyles : {}}>
|
||||
<FontAwesomeIcon icon={faPhotoVideo}
|
||||
style={{height: "20px", position: "relative", cursor: "pointer", display: "inline-block"}}
|
||||
onClick={onPreviewMedia} />
|
||||
</MythicStyledTooltip>
|
||||
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
|
||||
{fileData.display_link === "" ? window.location.origin + "/direct/download/" + fileData.agent_file_id : fileData.display_link}
|
||||
</FileDownloadLinkWithAuth>
|
||||
{openPreviewMediaDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
|
||||
onClose={onClose}
|
||||
innerDialog={<PreviewFileMediaDialog
|
||||
agent_file_id={fileData.agent_file_id}
|
||||
filename={fileData.filename}
|
||||
onClose={onClose} />}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +1,33 @@
|
||||
import { snackActions } from '../utilities/Snackbar';
|
||||
import {snackActions} from '../utilities/Snackbar';
|
||||
import {mythicFetch} from '../utilities/MythicConnection';
|
||||
|
||||
export const UploadTaskFile = async (file, comment) => {
|
||||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("comment", comment);
|
||||
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 2000});
|
||||
try{
|
||||
const upload_response = await fetch('/api/v1.4/task_upload_file_webhook', {
|
||||
formData.append("file", file);
|
||||
formData.append("comment", comment);
|
||||
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
|
||||
}catch(error){
|
||||
console.log(error)
|
||||
return null;
|
||||
}
|
||||
try{
|
||||
const upload_response = await mythicFetch('/task_upload_file_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`,
|
||||
MythicSource: "web",
|
||||
}
|
||||
});
|
||||
try{
|
||||
const upload_result = upload_response.json().then(data => {
|
||||
//console.log(data);
|
||||
return data?.agent_file_id || data?.error || null;
|
||||
if(data?.status === "error"){
|
||||
snackActions.error(data.error || "Failed to upload file");
|
||||
return null;
|
||||
}
|
||||
return data?.agent_file_id || null;
|
||||
}).catch(error => {
|
||||
console.log(upload_response);
|
||||
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
|
||||
console.log("Error trying to get json response", error.toString());
|
||||
return null;
|
||||
@@ -32,4 +41,108 @@ export const UploadTaskFile = async (file, comment) => {
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UploadDirectFile = async (agent_file_id, file) => {
|
||||
let formData = new FormData();
|
||||
try{
|
||||
formData.append("file", file);
|
||||
//snackActions.info("Saving " + file.name + " to Mythic...", {autoHideDuration: 1000});
|
||||
}catch(error){
|
||||
console.log(error)
|
||||
return null;
|
||||
}
|
||||
try{
|
||||
const upload_response = await mythicFetch('/direct/upload/' + agent_file_id, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`,
|
||||
MythicSource: "web",
|
||||
}
|
||||
});
|
||||
try{
|
||||
return upload_response.json().then(data => {
|
||||
if(data?.status === "error"){
|
||||
snackActions.error(data.error || "Failed to save file");
|
||||
return data;
|
||||
}
|
||||
return data;
|
||||
}).catch(error => {
|
||||
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
|
||||
console.log("Error trying to get json response", error.toString());
|
||||
return null;
|
||||
});
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export const UploadEventFile = async (file, comment) => {
|
||||
let formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("comment", comment);
|
||||
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
|
||||
try{
|
||||
const upload_response = await mythicFetch('/eventing_import_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
}
|
||||
});
|
||||
try{
|
||||
return upload_response.json().then(data => {
|
||||
//console.log(data);
|
||||
return data;
|
||||
}).catch(error => {
|
||||
console.log(upload_response);
|
||||
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
|
||||
console.log("Error trying to get json response", error.toString());
|
||||
return null;
|
||||
});
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export const UploadEventGroupFile = async (file, eventgroup_id) => {
|
||||
let formData = new FormData();
|
||||
formData.append("eventgroup_id", eventgroup_id);
|
||||
formData.append("file", file);
|
||||
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
|
||||
try{
|
||||
const upload_response = await mythicFetch('/eventing_register_file_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${localStorage.getItem("access_token")}`
|
||||
}
|
||||
});
|
||||
try{
|
||||
return upload_response.json().then(data => {
|
||||
//console.log(data);
|
||||
return data;
|
||||
}).catch(error => {
|
||||
console.log(upload_response);
|
||||
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
|
||||
console.log("Error trying to get json response", error.toString());
|
||||
return null;
|
||||
});
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}catch(error){
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import * as React from "react";
|
||||
import styled from "@emotion/styled";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import ArrowRight from "@mui/icons-material/ArrowRight";
|
||||
// slightly modified from https://medium.com/geekculture/creating-a-dropdown-with-nested-menu-items-using-react-mui-bb0c084226da
|
||||
export const Dropdown = React.forwardRef(
|
||||
(
|
||||
{
|
||||
menu,
|
||||
isOpen: controlledIsOpen,
|
||||
onOpen: onControlledOpen,
|
||||
externallyOpen,
|
||||
minWidth,
|
||||
absoluteX,
|
||||
absoluteY,
|
||||
anchorReference="anchorEl",
|
||||
style,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
MenuListProps,
|
||||
PaperProps,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isInternalOpen, setInternalOpen] = React.useState(null);
|
||||
|
||||
const isOpen = controlledIsOpen || isInternalOpen;
|
||||
|
||||
let anchorRef = React.useRef(isOpen);
|
||||
if (ref) {
|
||||
anchorRef = ref;
|
||||
}
|
||||
const handleClose = (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleForceClose();
|
||||
};
|
||||
const handleForceClose = () => {
|
||||
onControlledOpen ? onControlledOpen(null) : setInternalOpen(null);
|
||||
};
|
||||
const renderMenu = (menuItem, index) => {
|
||||
const { ...props } = menuItem.props;
|
||||
|
||||
let extraProps = {};
|
||||
if (props.menu) {
|
||||
extraProps = {
|
||||
parentMenuOpen: isOpen
|
||||
};
|
||||
}
|
||||
return React.createElement(menuItem.type, {
|
||||
...props,
|
||||
key: index,
|
||||
...extraProps,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation();
|
||||
if (menuItem.props.onClick) {
|
||||
menuItem.props.onClick(event);
|
||||
}
|
||||
},
|
||||
children: props.menu
|
||||
? React.Children.map(props.menu, renderMenu)
|
||||
: props.children
|
||||
});
|
||||
};
|
||||
const anchorEl = isOpen && isOpen.currentTarget ? isOpen.currentTarget : isOpen;
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
elevation={5}
|
||||
PaperProps={{
|
||||
...PaperProps,
|
||||
sx: { minWidth: minWidth ?? 0, ...(PaperProps?.sx || {}) }
|
||||
}}
|
||||
MenuListProps={MenuListProps}
|
||||
style={{zIndex: 100000, position: "absolute"}}
|
||||
anchorEl={anchorReference === "anchorEl" ? anchorEl : undefined}
|
||||
transition={"true"}
|
||||
open={!!externallyOpen}
|
||||
anchorPosition={anchorReference === "anchorEl" ? undefined : {top: absoluteY, left: absoluteX}}
|
||||
anchorReference={ anchorReference ? anchorReference : "anchorEl"}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={anchorOrigin ? anchorOrigin : {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
transformOrigin={transformOrigin ? transformOrigin : {
|
||||
vertical: 'top',
|
||||
horizontal: 'left',
|
||||
}}
|
||||
>
|
||||
{React.Children.map(menu, renderMenu)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const NestedMenuItem = React.forwardRef((props, ref) => {
|
||||
const {
|
||||
parentMenuOpen,
|
||||
label,
|
||||
rightIcon = <ArrowRight style={{ fontSize: 16 }} />,
|
||||
children,
|
||||
customTheme,
|
||||
className,
|
||||
tabIndex: tabIndexProp,
|
||||
ContainerProps: ContainerPropsProp = {},
|
||||
rightAnchored,
|
||||
...MenuItemProps
|
||||
} = props;
|
||||
|
||||
const { ref: containerRefProp, ...ContainerProps } = ContainerPropsProp;
|
||||
|
||||
const menuItemRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => menuItemRef.current);
|
||||
|
||||
const containerRef = React.useRef(null);
|
||||
React.useImperativeHandle(containerRefProp, () => containerRef.current);
|
||||
|
||||
const menuContainerRef = React.useRef(null);
|
||||
|
||||
const [isSubMenuOpen, setIsSubMenuOpen] = React.useState(false);
|
||||
|
||||
const handleMouseEnter = (event) => {
|
||||
setIsSubMenuOpen(true);
|
||||
|
||||
if (ContainerProps?.onMouseEnter) {
|
||||
ContainerProps.onMouseEnter(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (event) => {
|
||||
setIsSubMenuOpen(false);
|
||||
|
||||
if (ContainerProps?.onMouseLeave) {
|
||||
ContainerProps.onMouseLeave(event);
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmenuFocused = () => {
|
||||
const active = containerRef.current?.ownerDocument?.activeElement;
|
||||
|
||||
for (const child of menuContainerRef.current?.children ?? []) {
|
||||
if (child === active) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleFocus = (event) => {
|
||||
if (event.target === containerRef.current) {
|
||||
setIsSubMenuOpen(true);
|
||||
}
|
||||
|
||||
if (ContainerProps?.onFocus) {
|
||||
ContainerProps.onFocus(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsSubMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubmenuFocused()) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const active = containerRef.current?.ownerDocument?.activeElement;
|
||||
|
||||
if (event.key === "ArrowLeft" && isSubmenuFocused()) {
|
||||
containerRef.current?.focus();
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "ArrowRight" &&
|
||||
event.target === containerRef.current &&
|
||||
event.target === active
|
||||
) {
|
||||
const firstChild = menuContainerRef.current?.children[0];
|
||||
firstChild?.focus();
|
||||
}
|
||||
if(event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "ArrowLeft"){
|
||||
setIsSubMenuOpen(false);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
const open = isSubMenuOpen && parentMenuOpen;
|
||||
|
||||
let tabIndex;
|
||||
if (!props.disabled) {
|
||||
tabIndex = tabIndexProp !== undefined ? tabIndexProp : -1;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...ContainerProps}
|
||||
ref={containerRef}
|
||||
onFocus={handleFocus}
|
||||
tabIndex={tabIndex}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<MenuItem
|
||||
{...MenuItemProps}
|
||||
data-open={!!open || undefined}
|
||||
className={className}
|
||||
ref={menuItemRef}
|
||||
>
|
||||
{label}
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
{rightIcon}
|
||||
</MenuItem>
|
||||
<Menu
|
||||
hideBackdrop
|
||||
disablePortal
|
||||
disableEnforceFocus={false}
|
||||
disableAutoFocus={false}
|
||||
style={{ pointerEvents: "none", zIndex: 100000}}
|
||||
anchorEl={menuItemRef.current}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: rightAnchored ? "left" : "right"
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: rightAnchored ? "right" : "left"
|
||||
}}
|
||||
css={customTheme}
|
||||
open={!!open}
|
||||
onClose={() => {
|
||||
setIsSubMenuOpen(false);
|
||||
}}
|
||||
MenuListProps={{
|
||||
'aria-hidden': false,
|
||||
role: 'menu'
|
||||
}}
|
||||
>
|
||||
<div ref={menuContainerRef} style={{ pointerEvents: "auto" }}>
|
||||
{children}
|
||||
</div>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const DropdownMenuItem = styled(MenuItem)`
|
||||
|
||||
`;
|
||||
|
||||
export const DropdownNestedMenuItem = styled(NestedMenuItem)`
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
|
||||
export const MythicPageBody = ({children}) => {
|
||||
return (
|
||||
<div style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: "0.5rem",
|
||||
gap: "0.2rem",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {alpha, useTheme} from '@mui/material/styles';
|
||||
import {MythicChip} from './MythicChip';
|
||||
|
||||
export const MythicPageHeader = ({
|
||||
actions,
|
||||
children,
|
||||
className = "",
|
||||
dense = false,
|
||||
icon,
|
||||
meta,
|
||||
style = {},
|
||||
subtitle,
|
||||
sx = {},
|
||||
title,
|
||||
headerVariant = "h6",
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
|
||||
const supportingTextColor = alpha(headerTextColor, 0.78);
|
||||
const hasActionContent = actions || children;
|
||||
const actionSurface = alpha(headerTextColor, 0.1);
|
||||
const actionSurfaceHover = alpha(headerTextColor, 0.16);
|
||||
const actionSurfaceActive = alpha(headerTextColor, 0.23);
|
||||
const actionBorder = alpha(headerTextColor, 0.24);
|
||||
const actionBorderHover = alpha(headerTextColor, 0.42);
|
||||
return (
|
||||
<Paper
|
||||
className={`mythic-page-header${className ? ` ${className}` : ""}`}
|
||||
elevation={0}
|
||||
style={style}
|
||||
sx={{
|
||||
alignItems: {xs: "stretch", md: "center"},
|
||||
backgroundColor: theme.pageHeader.main,
|
||||
border: `1px solid ${alpha(headerTextColor, 0.18)}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.12)}`,
|
||||
color: headerTextColor,
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
flexDirection: {xs: "column", md: "row"},
|
||||
flexWrap: {xs: "nowrap", md: "wrap"},
|
||||
gap: dense ? 0.75 : 1,
|
||||
height: "auto",
|
||||
justifyContent: "space-between",
|
||||
maxHeight: "none",
|
||||
minHeight: dense ? 44 : 58,
|
||||
minWidth: 0,
|
||||
overflow: "visible",
|
||||
p: dense ? "0.5rem 0.75rem" : "0.7rem 0.85rem",
|
||||
width: "100%",
|
||||
"& .MuiButton-root, & .MuiToggleButton-root": {
|
||||
alignItems: "center",
|
||||
backgroundColor: `${actionSurface} !important`,
|
||||
border: `1px solid ${actionBorder} !important`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
boxShadow: "none !important",
|
||||
color: `${headerTextColor} !important`,
|
||||
fontSize: "0.76rem",
|
||||
fontWeight: 750,
|
||||
gap: 0.35,
|
||||
justifyContent: "center",
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
minHeight: 32,
|
||||
minWidth: {xs: "auto", sm: "7rem"},
|
||||
px: 1.05,
|
||||
textTransform: "none",
|
||||
whiteSpace: "nowrap",
|
||||
"&:hover": {
|
||||
backgroundColor: `${actionSurfaceHover} !important`,
|
||||
borderColor: `${actionBorderHover} !important`,
|
||||
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.12)}`,
|
||||
},
|
||||
},
|
||||
"& .MuiButton-root.Mui-focusVisible, & .MuiToggleButton-root.Mui-focusVisible": {
|
||||
outline: `2px solid ${alpha(headerTextColor, 0.38)}`,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
"& .MuiButton-root.Mui-disabled, & .MuiToggleButton-root.Mui-disabled": {
|
||||
backgroundColor: `${alpha(headerTextColor, 0.04)} !important`,
|
||||
borderColor: `${alpha(headerTextColor, 0.1)} !important`,
|
||||
color: `${alpha(headerTextColor, 0.38)} !important`,
|
||||
},
|
||||
"& .MuiToggleButton-root.Mui-selected": {
|
||||
backgroundColor: `${actionSurfaceActive} !important`,
|
||||
borderColor: `${alpha(headerTextColor, 0.48)} !important`,
|
||||
color: `${headerTextColor} !important`,
|
||||
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.16)}`,
|
||||
"&:hover": {
|
||||
backgroundColor: `${alpha(headerTextColor, 0.28)} !important`,
|
||||
},
|
||||
},
|
||||
"& .MuiButton-startIcon, & .MuiButton-endIcon": {
|
||||
color: "inherit",
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
"& .MuiButton-startIcon": {
|
||||
paddingRight: "0.25rem",
|
||||
},
|
||||
"& .MuiButton-endIcon": {
|
||||
paddingLeft: "0.25rem",
|
||||
},
|
||||
"& .MuiIconButton-root": {
|
||||
backgroundColor: `${actionSurface} !important`,
|
||||
border: `1px solid ${actionBorder} !important`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
color: `${headerTextColor} !important`,
|
||||
height: 32,
|
||||
width: 32,
|
||||
"&:hover": {
|
||||
backgroundColor: `${actionSurfaceHover} !important`,
|
||||
borderColor: `${actionBorderHover} !important`,
|
||||
},
|
||||
"&.Mui-disabled": {
|
||||
backgroundColor: `${alpha(headerTextColor, 0.04)} !important`,
|
||||
borderColor: `${alpha(headerTextColor, 0.1)} !important`,
|
||||
color: `${alpha(headerTextColor, 0.38)} !important`,
|
||||
},
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{gap: 1, minWidth: 0, flex: {xs: "0 1 auto", md: "1 1 24rem"}}}>
|
||||
{icon &&
|
||||
<Box sx={{
|
||||
alignItems: "center",
|
||||
backgroundColor: alpha(headerTextColor, 0.1),
|
||||
border: `1px solid ${alpha(headerTextColor, 0.16)}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
display: "inline-flex",
|
||||
flex: "0 0 auto",
|
||||
height: dense ? 32 : 36,
|
||||
justifyContent: "center",
|
||||
mt: subtitle || meta ? 0.15 : 0,
|
||||
width: dense ? 32 : 36,
|
||||
}}>
|
||||
{icon}
|
||||
</Box>
|
||||
}
|
||||
<Box sx={{display: "flex", flexDirection: "column", gap: 0.25, minWidth: 0}}>
|
||||
<Typography
|
||||
variant={headerVariant}
|
||||
sx={{
|
||||
color: headerTextColor,
|
||||
fontWeight: 750,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.18,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle &&
|
||||
<Typography
|
||||
component="div"
|
||||
sx={{
|
||||
color: supportingTextColor,
|
||||
fontSize: "0.78rem",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.35,
|
||||
maxWidth: "64rem",
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
}
|
||||
{meta &&
|
||||
<Box sx={{alignItems: "center", display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.25}}>
|
||||
{meta}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
</Stack>
|
||||
{hasActionContent &&
|
||||
<Stack direction="row" sx={{
|
||||
alignContent: "center",
|
||||
alignItems: "center",
|
||||
color: headerTextColor,
|
||||
flex: "0 1 auto",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.45,
|
||||
justifyContent: {xs: "flex-start", md: "flex-end"},
|
||||
maxWidth: "100%",
|
||||
minWidth: 0,
|
||||
}}>
|
||||
{actions}
|
||||
{children}
|
||||
</Stack>
|
||||
}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export const MythicPageHeaderChip = ({status, sx = {}, ...props}) => {
|
||||
const theme = useTheme();
|
||||
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
|
||||
const normalizedStatus = status === "active" || status === "enabled" ? "success" :
|
||||
status === "inactive" || status === "disabled" ? "warning" :
|
||||
status === "neutral" ? "secondary" : status;
|
||||
const statusColor = normalizedStatus ? theme.palette[normalizedStatus]?.main : null;
|
||||
const chipColor = statusColor || alpha(headerTextColor, 0.88);
|
||||
return (
|
||||
<MythicChip
|
||||
size="small"
|
||||
tone={normalizedStatus || "secondary"}
|
||||
variant="outlined"
|
||||
{...props}
|
||||
sx={{
|
||||
backgroundColor: statusColor ? alpha(statusColor, theme.palette.mode === "dark" ? 0.22 : 0.13) : alpha(headerTextColor, 0.08),
|
||||
borderColor: statusColor ? alpha(statusColor, theme.palette.mode === "dark" ? 0.55 : 0.38) : alpha(headerTextColor, 0.2),
|
||||
color: chipColor,
|
||||
fontSize: "0.72rem",
|
||||
fontWeight: 750,
|
||||
height: 22,
|
||||
letterSpacing: 0,
|
||||
"& .MuiChip-icon": {
|
||||
color: statusColor ? chipColor : alpha(headerTextColor, 0.82),
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicSectionHeader = ({
|
||||
actions,
|
||||
dense = false,
|
||||
subtitle,
|
||||
sx = {},
|
||||
title,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
|
||||
const sectionAccentColor = theme.sectionHeader?.accent || theme.palette.primary.main;
|
||||
return (
|
||||
<MythicPageHeader
|
||||
actions={actions}
|
||||
className="mythic-section-header overflow-hidden relative"
|
||||
dense={dense}
|
||||
subtitle={subtitle}
|
||||
title={title}
|
||||
sx={{
|
||||
backgroundImage: theme.gradients?.sectionHeader,
|
||||
borderColor: alpha(sectionAccentColor, theme.palette.mode === "dark" ? 0.55 : 0.38),
|
||||
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.22)}, 0 2px 6px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.28 : 0.12)}`,
|
||||
mb: 0.5,
|
||||
mt: dense ? 1 : 1.25,
|
||||
overflow: "hidden",
|
||||
pl: dense ? 1.45 : 1.6,
|
||||
position: "relative",
|
||||
"&::before": {
|
||||
backgroundColor: sectionAccentColor,
|
||||
bottom: 0,
|
||||
boxShadow: `0 0 0 1px ${alpha(headerTextColor, 0.2)}`,
|
||||
content: '""',
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
width: 6,
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,107 +1,213 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import useStyles from './styles';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import {classes} from './styles';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../MythicNestedMenus";
|
||||
|
||||
const CellPreMemo = ({ VariableSizeGridProps: { style, rowIndex, columnIndex, data } }) => {
|
||||
const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const rowClassName = data.gridUUID + "row" + rowIndex;
|
||||
const classes = useStyles();
|
||||
const contextMenuOptions = data?.rowContextMenuOptions || [];
|
||||
const rowHighlight = rowIndex % 2 === 0 ? 'MythicResizableGridRowHighlight' : '';
|
||||
const [contextMenuOptions, setContextMenuOptions] = React.useState(data?.rowContextMenuOptions || []);
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const handleDoubleClick = useCallback(
|
||||
(e) => {
|
||||
data.onDoubleClickRow(e, rowIndex - 1); // minus 1 to account for header row
|
||||
},
|
||||
[data, rowIndex]
|
||||
);
|
||||
|
||||
const item = data.items[rowIndex][columnIndex];
|
||||
const cellStyle = item?.props?.cellData?.cellStyle || {};
|
||||
const rowStyle = data.items[rowIndex][columnIndex]?.props?.rowData?.rowStyle || {};
|
||||
const onMouseEnter = () => {
|
||||
const contextMenuLocationRef = React.useRef({x: 0, y: 0});
|
||||
const ownsContextRowRef = React.useRef(false);
|
||||
const updateRowClass = useCallback((className, shouldAdd) => {
|
||||
const cells = document.getElementsByClassName(rowClassName);
|
||||
if(cells.length > 0){
|
||||
for(const cell of cells){
|
||||
cell.classList.add(classes.hoveredRow);
|
||||
cell.classList.toggle(className, shouldAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
const onMouseLeave = () => {
|
||||
const cells = document.getElementsByClassName(rowClassName);
|
||||
if(cells.length > 0){
|
||||
for(const cell of cells){
|
||||
cell.classList.remove(classes.hoveredRow);
|
||||
}
|
||||
}, [rowClassName]);
|
||||
const handleDoubleClick = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
data.onDoubleClickRow(e, rowIndex - 1, data.items[rowIndex][columnIndex]?.props?.rowData); // minus 1 to account for header row
|
||||
},
|
||||
[columnIndex, data, rowIndex]
|
||||
);
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if(data.onRowClick){
|
||||
data.onRowClick({event, rowDataStatic: data.items[rowIndex][columnIndex]?.props?.rowData})
|
||||
}
|
||||
}
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
};
|
||||
const selectedClass = data.items[rowIndex][columnIndex]?.props?.rowData?.selected ? "selectedCallback" : "";
|
||||
const customRowClass = data.items[rowIndex][columnIndex]?.props?.rowData?.rowClassName || "";
|
||||
const rowFirstCellClass = columnIndex === 0 ? classes.rowFirstCell : "";
|
||||
const rowLastCellClass = columnIndex === data.items[rowIndex].length - 1 ? classes.rowLastCell : "";
|
||||
const rowInteractiveClass = data.onRowClick ? classes.rowInteractive : "";
|
||||
const setHoveredRow = useCallback((enabled) => {
|
||||
updateRowClass(classes.hoveredRow, enabled);
|
||||
}, [updateRowClass]);
|
||||
const setContextRow = useCallback((enabled) => {
|
||||
updateRowClass(classes.contextRow, enabled);
|
||||
}, [updateRowClass]);
|
||||
const onMouseEnter = useCallback(() => {
|
||||
setHoveredRow(true);
|
||||
}, [setHoveredRow]);
|
||||
const onMouseLeave = useCallback(() => {
|
||||
if(!ownsContextRowRef.current){
|
||||
setHoveredRow(false);
|
||||
}
|
||||
}, [setHoveredRow]);
|
||||
const handleMenuItemClick = (event, clickOption) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clickOption({event, columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}});
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
if(openContextMenu){
|
||||
ownsContextRowRef.current = true;
|
||||
setContextRow(true);
|
||||
setHoveredRow(true);
|
||||
} else if(ownsContextRowRef.current){
|
||||
ownsContextRowRef.current = false;
|
||||
setContextRow(false);
|
||||
setHoveredRow(false);
|
||||
}
|
||||
}, [openContextMenu, setContextRow, setHoveredRow]);
|
||||
React.useEffect( () => {
|
||||
return () => {
|
||||
if(ownsContextRowRef.current){
|
||||
setContextRow(false);
|
||||
setHoveredRow(false);
|
||||
}
|
||||
};
|
||||
}, [setContextRow, setHoveredRow]);
|
||||
const handleContextClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if(item.disableFilterMenu){
|
||||
event.stopPropagation();
|
||||
if(item?.disableFilterMenu){
|
||||
return;
|
||||
}
|
||||
if(contextMenuOptions && contextMenuOptions.length > 0){
|
||||
|
||||
setOpenContextMenu(true);
|
||||
contextMenuLocationRef.current.x = event.clientX;
|
||||
contextMenuLocationRef.current.y = event.clientY;
|
||||
if(data.onRowContextMenuClick){
|
||||
const newMenuItems = data.onRowContextMenuClick({rowDataStatic: data.items[rowIndex][columnIndex]?.props?.rowData});
|
||||
Promise.resolve(newMenuItems).then(function(value) {
|
||||
if(value.length > 0){
|
||||
setContextMenuOptions(value);
|
||||
setOpenContextMenu(true);
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if(contextMenuOptions && contextMenuOptions.length > 0){
|
||||
setOpenContextMenu(true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[contextMenuOptions, columnIndex] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[contextMenuOptions, data.onRowContextMenuClick] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
contextMenuOptions[index].click({event, columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}});
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
return (
|
||||
<div style={{...style, ...cellStyle, ...rowStyle}}
|
||||
className={`${classes.cell} ${rowClassName}`}
|
||||
<div style={{...style, ...cellStyle, ...rowStyle}}
|
||||
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onContextMenu={handleContextClick}
|
||||
ref={dropdownAnchorRef}
|
||||
>
|
||||
<div className={classes.cellInner}>{item}</div>
|
||||
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition disablePortal style={{zIndex: 4}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
<div className={classes.cellInner} style={{height: style.height}}>
|
||||
{item}
|
||||
</div>
|
||||
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={contextMenuOptions}
|
||||
disableFilterMenu={item?.disableFilterMenu} openContextMenu={openContextMenu}
|
||||
contextMenuLocationRef={contextMenuLocationRef}
|
||||
setOpenContextMenu={setOpenContextMenu} handleMenuItemClick={handleMenuItemClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOptions, setOpenContextMenu, handleMenuItemClick, contextMenuLocationRef}) => {
|
||||
const handleClose = (event) => {
|
||||
//if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
// return;
|
||||
//}
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
|
||||
return (
|
||||
openContextMenu &&
|
||||
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
|
||||
<Dropdown
|
||||
isOpen={dropdownAnchorRef.current}
|
||||
onOpen={setOpenContextMenu}
|
||||
externallyOpen={openContextMenu}
|
||||
absoluteX={contextMenuLocationRef.current.x}
|
||||
absoluteY={contextMenuLocationRef.current.y}
|
||||
anchorReference={"anchorPosition"}
|
||||
menu={[
|
||||
contextMenuOptions.map((option, index) => (
|
||||
option.type === 'item' ? (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
disabled={option.disabled}
|
||||
className={option.className || (option.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined)}
|
||||
onClick={(event) => handleMenuItemClick(event, option.click)}
|
||||
>
|
||||
{option.icon}{option.name}
|
||||
</DropdownMenuItem>
|
||||
) : option.type === 'menu' ? (
|
||||
<DropdownNestedMenuItem
|
||||
label={option.name}
|
||||
disabled={option.disabled}
|
||||
menu={
|
||||
option.menuItems.map((menuOption, indx) => (
|
||||
<DropdownMenuItem
|
||||
key={menuOption.name}
|
||||
disabled={menuOption.disabled}
|
||||
className={menuOption.className || (menuOption.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined)}
|
||||
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
|
||||
>
|
||||
{menuOption.icon}{menuOption.name}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
)),
|
||||
]}
|
||||
/>
|
||||
</ClickAwayListener>
|
||||
|
||||
)
|
||||
}
|
||||
const Cell = React.memo(CellPreMemo);
|
||||
export default Cell;
|
||||
|
||||
/*
|
||||
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition disablePortal style={{zIndex: 4}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
*/
|
||||
@@ -1,58 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import useStyles from './styles';
|
||||
import Draggable from 'react-draggable';
|
||||
|
||||
const DraggableHandles = React.forwardRef(({ height, rowHeight, width, minColumnWidth, columnWidths, onStop }, ref) => {
|
||||
const classes = useStyles();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.draggableHandlesContainer}
|
||||
style={{
|
||||
height: height,
|
||||
width: width,
|
||||
pointerEvents: isDragging ? 'initial' : 'none',
|
||||
}}>
|
||||
{columnWidths.map((_, i) => {
|
||||
const leftOffset = columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<Draggable
|
||||
key={i}
|
||||
axis='x'
|
||||
bounds={{
|
||||
left: minColumnWidth - columnWidths[i],
|
||||
right: Number.POSITIVE_INFINITY,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
position={isDragging ? null : { x: 0, y: 0 }}
|
||||
onStart={() => {
|
||||
setIsDragging(i);
|
||||
}}
|
||||
onStop={(e, data) => {
|
||||
setIsDragging(false);
|
||||
onStop(data.x, i);
|
||||
}}>
|
||||
<div
|
||||
className={classes.draggableHandlesClickArea}
|
||||
style={{
|
||||
left: leftOffset + columnWidths[i] - 1 - 8,
|
||||
height: rowHeight,
|
||||
}}>
|
||||
<div
|
||||
className={classes.draggableHandlesIndicator}
|
||||
style={{
|
||||
display: isDragging === i ? 'block' : 'none',
|
||||
height: height / 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default DraggableHandles;
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {MythicActionButton} from '../MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
const DEFAULT_FILTER = {
|
||||
include: [],
|
||||
exclude: [],
|
||||
includeMode: "any",
|
||||
caseSensitive: false,
|
||||
};
|
||||
|
||||
const splitFilterTerms = (value) => {
|
||||
if(Array.isArray(value)){
|
||||
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
if(value === undefined || value === null){
|
||||
return [];
|
||||
}
|
||||
return String(value)
|
||||
.split(/[\n,]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const normalizeGridColumnFilter = (value) => {
|
||||
if(value === undefined || value === null){
|
||||
return {...DEFAULT_FILTER};
|
||||
}
|
||||
if(typeof value === "string" || typeof value === "number" || typeof value === "boolean"){
|
||||
return {
|
||||
...DEFAULT_FILTER,
|
||||
include: splitFilterTerms(value),
|
||||
};
|
||||
}
|
||||
if(typeof value === "object"){
|
||||
return {
|
||||
include: splitFilterTerms(value.include),
|
||||
exclude: splitFilterTerms(value.exclude),
|
||||
includeMode: value.includeMode === "all" ? "all" : "any",
|
||||
caseSensitive: Boolean(value.caseSensitive),
|
||||
};
|
||||
}
|
||||
return {...DEFAULT_FILTER};
|
||||
};
|
||||
|
||||
export const isGridColumnFilterActive = (value) => {
|
||||
const normalizedFilter = normalizeGridColumnFilter(value);
|
||||
return normalizedFilter.include.length > 0 || normalizedFilter.exclude.length > 0;
|
||||
};
|
||||
|
||||
export const getUpdatedGridFilterOptions = (filterOptions, key, value) => {
|
||||
const nextFilterOptions = {...filterOptions};
|
||||
if(!key){
|
||||
return nextFilterOptions;
|
||||
}
|
||||
const normalizedFilter = normalizeGridColumnFilter(value);
|
||||
if(isGridColumnFilterActive(normalizedFilter)){
|
||||
nextFilterOptions[key] = normalizedFilter;
|
||||
}else{
|
||||
delete nextFilterOptions[key];
|
||||
}
|
||||
return nextFilterOptions;
|
||||
};
|
||||
|
||||
export const gridValuePassesFilter = (value, filterOption) => {
|
||||
const normalizedFilter = normalizeGridColumnFilter(filterOption);
|
||||
if(!isGridColumnFilterActive(normalizedFilter)){
|
||||
return true;
|
||||
}
|
||||
const cellText = String(value ?? "");
|
||||
const haystack = normalizedFilter.caseSensitive ? cellText : cellText.toLowerCase();
|
||||
const normalizeTerm = (term) => normalizedFilter.caseSensitive ? term : term.toLowerCase();
|
||||
const termMatches = (term) => haystack.includes(normalizeTerm(term));
|
||||
const includeMatches = normalizedFilter.include.length === 0 ? true :
|
||||
normalizedFilter.includeMode === "all" ?
|
||||
normalizedFilter.include.every(termMatches) :
|
||||
normalizedFilter.include.some(termMatches);
|
||||
if(!includeMatches){
|
||||
return false;
|
||||
}
|
||||
return !normalizedFilter.exclude.some(termMatches);
|
||||
};
|
||||
|
||||
export function GridColumnFilterDialog({filterValue, onClose, onSubmit, selectedColumn}) {
|
||||
const normalizedFilter = React.useMemo(() => normalizeGridColumnFilter(filterValue), [filterValue]);
|
||||
const [includeText, setIncludeText] = React.useState(normalizedFilter.include.join("\n"));
|
||||
const [excludeText, setExcludeText] = React.useState(normalizedFilter.exclude.join("\n"));
|
||||
const [includeMode, setIncludeMode] = React.useState(normalizedFilter.includeMode);
|
||||
const [caseSensitive, setCaseSensitive] = React.useState(normalizedFilter.caseSensitive);
|
||||
const columnName = selectedColumn?.name || selectedColumn?.plaintext || selectedColumn?.key || "Column";
|
||||
const buildFilter = () => ({
|
||||
include: splitFilterTerms(includeText),
|
||||
exclude: splitFilterTerms(excludeText),
|
||||
includeMode,
|
||||
caseSensitive,
|
||||
});
|
||||
const handleSubmit = () => {
|
||||
onSubmit(buildFilter());
|
||||
onClose();
|
||||
};
|
||||
const handleClear = () => {
|
||||
onSubmit({...DEFAULT_FILTER});
|
||||
onClose();
|
||||
};
|
||||
const onIncludeModeChange = (event, value) => {
|
||||
if(value !== null){
|
||||
setIncludeMode(value);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="mythic-grid-filter-dialog bg-surface-raised text-primary">
|
||||
<DialogTitle className="mythic-grid-filter-dialog-title bg-header text-header text-base font-850 leading-120 border-b-subtle">
|
||||
Filter {columnName}
|
||||
</DialogTitle>
|
||||
<DialogContent className="mythic-grid-filter-dialog-content flex flex-column">
|
||||
<Typography className="mythic-grid-filter-dialog-copy text-muted text-xs font-650 leading-135" component="div">
|
||||
Add one term per line, or separate terms with commas. Include terms decide what can stay visible; exclude terms remove matching rows.
|
||||
</Typography>
|
||||
<Box className="mythic-grid-filter-dialog-mode-row items-center flex gap-6 justify-between">
|
||||
<Typography className="mythic-grid-filter-dialog-label text-xs font-800 text-primary" component="div">
|
||||
Include matching
|
||||
</Typography>
|
||||
<ToggleButtonGroup
|
||||
exclusive
|
||||
onChange={onIncludeModeChange}
|
||||
size="small"
|
||||
value={includeMode}
|
||||
>
|
||||
<ToggleButton value="any">Any</ToggleButton>
|
||||
<ToggleButton value="all">All</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</Box>
|
||||
<div className="mythic-grid-filter-dialog-fields grid grid-cols-2">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Include rows matching"
|
||||
minRows={4}
|
||||
multiline
|
||||
autoFocus
|
||||
onChange={(event) => setIncludeText(event.target.value)}
|
||||
placeholder={"admin\nprod"}
|
||||
value={includeText}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Exclude rows matching"
|
||||
minRows={4}
|
||||
multiline
|
||||
onChange={(event) => setExcludeText(event.target.value)}
|
||||
placeholder={"debug\ntest"}
|
||||
value={excludeText}
|
||||
/>
|
||||
</div>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={caseSensitive}
|
||||
onChange={(event) => setCaseSensitive(event.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label="Case sensitive"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions className="mythic-grid-filter-dialog-actions py-6 px-8 border-t-subtle">
|
||||
<MythicActionButton onClick={handleClear}>Clear</MythicActionButton>
|
||||
<MythicActionButton onClick={onClose}>Cancel</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={handleSubmit} tone="primary" variant="contained">Apply Filter</MythicActionButton>
|
||||
</DialogActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { useCallback } from 'react';
|
||||
import useSingleAndDoubleClick from '../../utilities/useSingleAndDoubleClick';
|
||||
import useStyles from './styles';
|
||||
import {classes} from './styles';
|
||||
import React from 'react';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faFilter} from '@fortawesome/free-solid-svg-icons';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import FilterAltOutlinedIcon from '@mui/icons-material/FilterAltOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import {ContextMenu} from "./Cell";
|
||||
|
||||
const HeaderCell = ({
|
||||
onClick = () => {},
|
||||
@@ -20,13 +15,17 @@ const HeaderCell = ({
|
||||
sortIndicatorIndex,
|
||||
sortDirection,
|
||||
headerNameKey = "name",
|
||||
VariableSizeGridProps: { style, rowIndex, columnIndex, data, ...other },
|
||||
isResizing = false,
|
||||
onResizePointerDown = () => {},
|
||||
VariableSizeGridProps: { style, rowIndex, columnIndex, data },
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const item = data.items[rowIndex][columnIndex];
|
||||
const isFiltered = item?.filtered || false;
|
||||
const isSorted = sortIndicatorIndex === columnIndex;
|
||||
const HeaderSortIcon = sortDirection === 'ASC' ? KeyboardArrowUpIcon : KeyboardArrowDownIcon;
|
||||
const headerLabel = item?.[headerNameKey] || "";
|
||||
const contextMenuLocationRef = React.useRef({x: 0, y: 0});
|
||||
const handleClick = useCallback(
|
||||
(e) => {
|
||||
onClick(e, columnIndex);
|
||||
@@ -40,70 +39,131 @@ const HeaderCell = ({
|
||||
},
|
||||
[onDoubleClick, columnIndex]
|
||||
);
|
||||
const handleResizePointerDown = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onResizePointerDown(event, columnIndex);
|
||||
},
|
||||
[onResizePointerDown, columnIndex]
|
||||
);
|
||||
const handleResizeDoubleClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDoubleClick(event, columnIndex);
|
||||
},
|
||||
[onDoubleClick, columnIndex]
|
||||
);
|
||||
const stopResizeClickPropagation = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[]
|
||||
);
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const menuContext = React.useMemo(() => {
|
||||
return {columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}};
|
||||
}, [columnIndex, data.items, rowIndex]);
|
||||
const resolvedContextMenuOptions = React.useMemo(() => {
|
||||
const resolveOption = (option) => {
|
||||
const resolvedOption = {
|
||||
...option,
|
||||
disabled: typeof option.disabled === "function" ? option.disabled(menuContext) : option.disabled,
|
||||
};
|
||||
if(option.menuItems){
|
||||
resolvedOption.menuItems = option.menuItems.map(resolveOption);
|
||||
}
|
||||
return resolvedOption;
|
||||
};
|
||||
return contextMenuOptions.map(resolveOption);
|
||||
}, [contextMenuOptions, menuContext]);
|
||||
const handleContextClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
if(item.disableFilterMenu){
|
||||
return;
|
||||
}
|
||||
if(contextMenuOptions && contextMenuOptions.length > 0){
|
||||
|
||||
if(resolvedContextMenuOptions && resolvedContextMenuOptions.length > 0){
|
||||
contextMenuLocationRef.current.x = event.clientX;
|
||||
contextMenuLocationRef.current.y = event.clientY;
|
||||
setOpenContextMenu(true);
|
||||
}
|
||||
},
|
||||
[contextMenuOptions, columnIndex] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
[resolvedContextMenuOptions]
|
||||
);
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
contextMenuOptions[index].click({event, columnIndex});
|
||||
const handleMenuItemClick = (event, clickOption) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clickOption({event, ...menuContext});
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
const handleClicks = useSingleAndDoubleClick(handleClick, handleDoubleClick);
|
||||
const filterMenuOption = React.useMemo(
|
||||
() => resolvedContextMenuOptions.find((option) => option.type === "item" && option.name === "Filter Column"),
|
||||
[resolvedContextMenuOptions]
|
||||
);
|
||||
const handleFilterIndicatorClick = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if(filterMenuOption?.disabled || !filterMenuOption?.click){
|
||||
return;
|
||||
}
|
||||
filterMenuOption.click({event, ...menuContext});
|
||||
setOpenContextMenu(false);
|
||||
},
|
||||
[filterMenuOption, menuContext]
|
||||
);
|
||||
|
||||
|
||||
const handleClicks = useSingleAndDoubleClick(handleClick, handleDoubleClick);
|
||||
const className = `${classes.headerCell} ${item.disableSort ? classes.headerCellNoSort : ""}`;
|
||||
|
||||
return (
|
||||
<div style={style} className={classes.headerCell} onClick={handleClicks} onContextMenu={handleContextClick} ref={dropdownAnchorRef}>
|
||||
<Box display='flex' alignItems='center' justifyContent='space-between' width='100%'>
|
||||
<Typography className={classes.cellInner} variant='body1'>
|
||||
{item[headerNameKey].toUpperCase()}
|
||||
<div
|
||||
style={style}
|
||||
className={className}
|
||||
onClick={handleClicks}
|
||||
onContextMenu={handleContextClick}
|
||||
ref={dropdownAnchorRef}
|
||||
title={typeof headerLabel === "string" ? headerLabel : undefined}
|
||||
>
|
||||
<Box className={classes.headerContent}>
|
||||
<Typography className={`${classes.cellInner} ${classes.headerLabel}`} component='div' variant='body1'>
|
||||
{typeof headerLabel === "string" ? headerLabel : headerLabel}
|
||||
</Typography>
|
||||
{isFiltered ? (
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
) : (null)}
|
||||
{sortIndicatorIndex === columnIndex && (sortDirection === 'ASC' ? <div>↑</div> : <div>↓</div>)}
|
||||
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition disablePortal style={{zIndex: 4}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
<Box className={classes.headerActions}>
|
||||
{isFiltered &&
|
||||
<button
|
||||
aria-label={`Edit filter for ${typeof headerLabel === "string" ? headerLabel : "column"}`}
|
||||
className={`${classes.headerIndicator} ${classes.headerFilterIcon}`}
|
||||
onClick={handleFilterIndicatorClick}
|
||||
title="Edit Filter"
|
||||
type="button"
|
||||
>
|
||||
<FilterAltOutlinedIcon fontSize="inherit" />
|
||||
</button>
|
||||
}
|
||||
{isSorted &&
|
||||
<span className={`${classes.headerIndicator} ${classes.headerSortIcon}`} title={`Sorted ${sortDirection === 'ASC' ? 'ascending' : 'descending'}`}>
|
||||
<HeaderSortIcon fontSize="inherit" />
|
||||
</span>
|
||||
}
|
||||
</Box>
|
||||
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={resolvedContextMenuOptions}
|
||||
disableFilterMenu={item?.disableFilterMenu} openContextMenu={openContextMenu}
|
||||
contextMenuLocationRef={contextMenuLocationRef}
|
||||
setOpenContextMenu={setOpenContextMenu} handleMenuItemClick={handleMenuItemClick}
|
||||
/>
|
||||
</Box>
|
||||
{!item.disableResize &&
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={`Resize ${typeof headerLabel === "string" ? headerLabel : "column"} column`}
|
||||
className={`${classes.headerResizeHandle} ${isResizing ? classes.headerResizeHandleActive : ""}`}
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onDoubleClick={handleResizeDoubleClick}
|
||||
onClick={stopResizeClickPropagation}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState, createContext, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import useScrollbarSize from 'react-scrollbar-size';
|
||||
import { VariableSizeGrid } from 'react-window';
|
||||
import HeaderCell from './HeaderCell';
|
||||
import Cell from './Cell';
|
||||
import DraggableHandles from './DraggableHandles';
|
||||
import useStyles from './styles';
|
||||
import {classes} from './styles';
|
||||
import {GetMythicSetting, useSetMythicSetting} from "../MythicSavedUserSetting";
|
||||
import FitScreenIcon from '@mui/icons-material/FitScreen';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
|
||||
import FilterAltOutlinedIcon from '@mui/icons-material/FilterAltOutlined';
|
||||
|
||||
const MIN_COLUMN_WIDTH = 100;
|
||||
const HeaderCellContext = createContext({});
|
||||
|
||||
const CellRenderer = (VariableSizeGridProps) => {
|
||||
return VariableSizeGridProps.rowIndex === 0 ? null : <Cell VariableSizeGridProps={VariableSizeGridProps} />;
|
||||
const MIN_COLUMN_WIDTH = 50;
|
||||
const MIN_FLEX_COLUMN_WIDTH = 50;
|
||||
const AUTOSIZE_HORIZONTAL_PADDING = 40;
|
||||
const AUTOSIZE_HEADER_EXTRA_WIDTH = 30;
|
||||
const headerMenuIconStyle = {fontSize: "1rem", marginRight: "8px"};
|
||||
const normalizeHeaderContextMenuOption = (option) => {
|
||||
if(option?.name === "Filter Column"){
|
||||
return {
|
||||
...option,
|
||||
icon: <FilterAltOutlinedIcon style={headerMenuIconStyle} />,
|
||||
};
|
||||
}
|
||||
if(option?.menuItems){
|
||||
return {
|
||||
...option,
|
||||
menuItems: option.menuItems.map(normalizeHeaderContextMenuOption),
|
||||
};
|
||||
}
|
||||
return option;
|
||||
};
|
||||
|
||||
let autosizeCanvas;
|
||||
|
||||
const getNumericWidth = (value) => {
|
||||
const width = Number(value);
|
||||
return Number.isFinite(width) ? width : undefined;
|
||||
};
|
||||
const getColumnMinWidth = (column) => {
|
||||
const configuredMinWidth = getNumericWidth(column?.minWidth);
|
||||
return Math.max(configuredMinWidth || 0, column?.fillWidth ? MIN_FLEX_COLUMN_WIDTH : MIN_COLUMN_WIDTH);
|
||||
};
|
||||
const getColumnMaxWidth = (column) => {
|
||||
const configuredMaxWidth = getNumericWidth(column?.maxWidth);
|
||||
return configuredMaxWidth && configuredMaxWidth > 0 ? configuredMaxWidth : Infinity;
|
||||
};
|
||||
const clampColumnWidth = (column, width) => {
|
||||
const minWidth = getColumnMinWidth(column);
|
||||
const maxWidth = getColumnMaxWidth(column);
|
||||
const numericWidth = getNumericWidth(width) || minWidth;
|
||||
return Math.floor(Math.min(maxWidth, Math.max(numericWidth, minWidth)));
|
||||
};
|
||||
const getPreferredColumnWidth = (column) => {
|
||||
return column?.width === undefined ? getColumnMinWidth(column) : column.width;
|
||||
};
|
||||
const distributeFillWidth = ({columns, columnWidths, totalWidth}) => {
|
||||
const availableWidth = Math.max(0, Math.floor(totalWidth || 0));
|
||||
const currentWidth = columnWidths.reduce((a, b) => a + b, 0);
|
||||
const widthDifference = availableWidth - currentWidth;
|
||||
if(widthDifference <= 0 || columns.length === 0){
|
||||
return columnWidths;
|
||||
}
|
||||
const fillColumnIndexes = columns.reduce((previous, column, index) => {
|
||||
return column.fillWidth ? [...previous, index] : previous;
|
||||
}, []);
|
||||
const targetIndexes = fillColumnIndexes.length > 0 ? fillColumnIndexes : [columns.length - 1];
|
||||
let remainingWidth = widthDifference;
|
||||
const updatedColumnWidths = [...columnWidths];
|
||||
for(let i = 0; i < targetIndexes.length; i++){
|
||||
const columnIndex = targetIndexes[i];
|
||||
const share = i === targetIndexes.length - 1 ? remainingWidth : Math.floor(widthDifference / targetIndexes.length);
|
||||
const currentColumnWidth = updatedColumnWidths[columnIndex];
|
||||
const nextColumnWidth = clampColumnWidth(columns[columnIndex], currentColumnWidth + share);
|
||||
updatedColumnWidths[columnIndex] = nextColumnWidth;
|
||||
remainingWidth -= nextColumnWidth - currentColumnWidth;
|
||||
}
|
||||
return updatedColumnWidths;
|
||||
};
|
||||
const getInitialColumnWidths = ({columns, savedWidths = [], totalWidth = 0}) => {
|
||||
const hasSavedWidths = savedWidths.length === columns.length;
|
||||
const baseColumnWidths = columns.map((column, index) => {
|
||||
return clampColumnWidth(column, hasSavedWidths ? savedWidths[index] : getPreferredColumnWidth(column));
|
||||
});
|
||||
if(hasSavedWidths){
|
||||
return baseColumnWidths;
|
||||
}
|
||||
return distributeFillWidth({columns, columnWidths: baseColumnWidths, totalWidth});
|
||||
};
|
||||
const areColumnWidthsEqual = (first = [], second = []) => {
|
||||
if(first.length !== second.length){
|
||||
return false;
|
||||
}
|
||||
return first.every((width, index) => Math.floor(width || MIN_COLUMN_WIDTH) === Math.floor(second[index] || MIN_COLUMN_WIDTH));
|
||||
};
|
||||
const normalizeColumnWidths = (columnWidths = []) => {
|
||||
return columnWidths.map((columnWidth) => Math.floor(getNumericWidth(columnWidth) || MIN_COLUMN_WIDTH));
|
||||
};
|
||||
const getMeasureContext = () => {
|
||||
if(typeof document === "undefined"){
|
||||
return null;
|
||||
}
|
||||
if(autosizeCanvas === undefined){
|
||||
autosizeCanvas = document.createElement("canvas");
|
||||
}
|
||||
const context = autosizeCanvas.getContext("2d");
|
||||
if(context === null){
|
||||
return null;
|
||||
}
|
||||
const referenceElement = document.querySelector(`.${classes.cell}`) || document.body;
|
||||
const computedStyle = window.getComputedStyle(referenceElement);
|
||||
context.font = [
|
||||
computedStyle.fontStyle,
|
||||
computedStyle.fontVariant,
|
||||
computedStyle.fontWeight,
|
||||
computedStyle.fontSize,
|
||||
computedStyle.fontFamily,
|
||||
].join(" ");
|
||||
return context;
|
||||
};
|
||||
const getDisplayText = (value) => {
|
||||
if(value === undefined || value === null){
|
||||
return "";
|
||||
}
|
||||
if(typeof value === "string"){
|
||||
const trimmedValue = value.trim();
|
||||
if(trimmedValue.startsWith("[")){
|
||||
try{
|
||||
const parsedValue = JSON.parse(trimmedValue);
|
||||
if(Array.isArray(parsedValue) && parsedValue.length > 0){
|
||||
return getDisplayText(parsedValue[0]);
|
||||
}
|
||||
}catch(error){
|
||||
// Keep the original string when it is not a serialized array.
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if(typeof value === "number" || typeof value === "boolean"){
|
||||
return String(value);
|
||||
}
|
||||
if(Array.isArray(value)){
|
||||
return value.map((entry) => getDisplayText(entry)).filter(Boolean).join(", ");
|
||||
}
|
||||
if(React.isValidElement(value)){
|
||||
return getDisplayText(value.props?.cellData || value.props?.children);
|
||||
}
|
||||
if(typeof value === "object"){
|
||||
if(value.plaintext !== undefined){
|
||||
return getDisplayText(value.plaintext);
|
||||
}
|
||||
if(value.name !== undefined){
|
||||
return getDisplayText(value.name);
|
||||
}
|
||||
if(value.button?.name !== undefined){
|
||||
return getDisplayText(value.button.name);
|
||||
}
|
||||
if(value.display !== undefined){
|
||||
return getDisplayText(value.display);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const getCellAutosizeText = ({column, cell}) => {
|
||||
const cellProps = cell?.props || {};
|
||||
const columnKey = column?.key || column?.plaintext;
|
||||
const rowData = cellProps.rowData || {};
|
||||
const cellDataText = getDisplayText(cellProps.cellData);
|
||||
if(cellDataText !== ""){
|
||||
return cellDataText;
|
||||
}
|
||||
if(columnKey !== undefined){
|
||||
return getDisplayText(rowData?.[columnKey]);
|
||||
}
|
||||
return getDisplayText(cell);
|
||||
};
|
||||
const measureAutosizeText = (context, text) => {
|
||||
const normalizedText = getDisplayText(text);
|
||||
if(normalizedText === ""){
|
||||
return 0;
|
||||
}
|
||||
const lines = normalizedText.split(/\r?\n/);
|
||||
return lines.reduce((longestLineWidth, line) => {
|
||||
const textWidth = context ? context.measureText(line).width : line.length * 8;
|
||||
return Math.max(longestLineWidth, textWidth);
|
||||
}, 0);
|
||||
};
|
||||
const getAutosizedColumnWidth = ({column, columnIndex, headerNameKey, items}) => {
|
||||
const measureContext = getMeasureContext();
|
||||
const headerText = getDisplayText(column?.[headerNameKey] || column?.name || column?.plaintext);
|
||||
const headerWidth = measureAutosizeText(measureContext, headerText.toUpperCase()) + AUTOSIZE_HEADER_EXTRA_WIDTH;
|
||||
const widestCellWidth = items.reduce((widestWidth, itemRow) => {
|
||||
const cellText = getCellAutosizeText({column, cell: itemRow[columnIndex]});
|
||||
return Math.max(widestWidth, measureAutosizeText(measureContext, cellText));
|
||||
}, 0);
|
||||
return clampColumnWidth(column, Math.max(headerWidth, widestCellWidth) + AUTOSIZE_HORIZONTAL_PADDING);
|
||||
};
|
||||
|
||||
const CellRendererPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
return rowIndex === 0 ? null : <Cell style={style} rowIndex={rowIndex} columnIndex={columnIndex} data={data} />;
|
||||
};
|
||||
const CellRenderer = React.memo(CellRendererPreMemo);
|
||||
const innerElementType = React.forwardRef(({ children, style }, ref) => {
|
||||
const HeaderCellData = useContext(HeaderCellContext);
|
||||
const resizingColumnIndex = HeaderCellData.resizingColumnIndex;
|
||||
const resizeGuideLeft = resizingColumnIndex >= 0 ?
|
||||
HeaderCellData.columnWidths.slice(0, resizingColumnIndex + 1).reduce((a, b) => a + b, 0) :
|
||||
-1;
|
||||
const onHeaderDoubleClick = React.useCallback( (e, columnIndex) => {
|
||||
if (HeaderCellData.columns[columnIndex].disableAutosize) return;
|
||||
HeaderCellData.autosizeColumn({columnIndex});
|
||||
}, [HeaderCellData.columns, HeaderCellData.autosizeColumn]);
|
||||
return (
|
||||
<div ref={ref} style={{...style, position: "relative"}}>
|
||||
{/* always render header cells */}
|
||||
<div
|
||||
className={classes.headerCellRow}
|
||||
style={{
|
||||
height: HeaderCellData.getRowHeight(0),
|
||||
}}>
|
||||
{HeaderCellData.columns.map((column, i) => {
|
||||
const leftOffset = HeaderCellData.columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<HeaderCell
|
||||
key={i}
|
||||
headerNameKey={HeaderCellData.headerNameKey}
|
||||
onClick={HeaderCellData.onClickHeader}
|
||||
onDoubleClick={onHeaderDoubleClick}
|
||||
contextMenuOptions={HeaderCellData.contextMenuOptions}
|
||||
sortIndicatorIndex={HeaderCellData.sortIndicatorIndex}
|
||||
sortDirection={HeaderCellData.sortDirection}
|
||||
isResizing={HeaderCellData.resizingColumnIndex === i}
|
||||
onResizePointerDown={HeaderCellData.startColumnResize}
|
||||
VariableSizeGridProps={{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: leftOffset,
|
||||
height: HeaderCellData.getRowHeight(0),
|
||||
width: HeaderCellData.getColumnWidth(i),
|
||||
},
|
||||
rowIndex: 0,
|
||||
columnIndex: i,
|
||||
data: { items: HeaderCellData.itemsWithHeader },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* render other cells as usual */}
|
||||
{children}
|
||||
{resizingColumnIndex >= 0 &&
|
||||
<div
|
||||
className={classes.resizeGuide}
|
||||
style={{
|
||||
left: resizeGuideLeft,
|
||||
height: style.height,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const GetShortRandomString = () => {
|
||||
return (Math.random() + 1).toString(36).substring(2);
|
||||
}
|
||||
const ResizableGridWrapper = ({
|
||||
name,
|
||||
columns,
|
||||
sortIndicatorIndex,
|
||||
sortDirection,
|
||||
@@ -24,175 +281,295 @@ const ResizableGridWrapper = ({
|
||||
onDoubleClickRow,
|
||||
contextMenuOptions,
|
||||
rowContextMenuOptions,
|
||||
onRowContextMenuClick,
|
||||
rowHeight,
|
||||
headerRowHeight,
|
||||
widthMeasureKey,
|
||||
callbackTableGridRef,
|
||||
onRowClick,
|
||||
...AutoSizerProps
|
||||
}) => {
|
||||
/* Hooks */
|
||||
const { width: scrollbarWidth } = useScrollbarSize();
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState(columns.map((column) => column.width || MIN_COLUMN_WIDTH));
|
||||
|
||||
const localColumnsRef = React.useRef(columns);
|
||||
const initialSavedWidths = name === undefined ? [] : GetMythicSetting({setting_name: `${name}_column_widths`, default_value: {}, output: "json-array"});
|
||||
const savedColumnWidths = Array.isArray(initialSavedWidths) ? initialSavedWidths : [];
|
||||
const [columnWidths, setColumnWidths] = useState(() => getInitialColumnWidths({
|
||||
columns,
|
||||
savedWidths: savedColumnWidths,
|
||||
totalWidth: AutoSizerProps.width - scrollbarWidth,
|
||||
}));
|
||||
const gridUUID = React.useMemo( () => GetShortRandomString(), []);
|
||||
const gridRef = useRef(null);
|
||||
|
||||
const dragHandlesRef = useRef(null);
|
||||
|
||||
const columnWidthsRef = useRef(columnWidths);
|
||||
const lastResetColumnIndexRef = useRef(0);
|
||||
const activeResizeRef = useRef(null);
|
||||
const resizeFrameRef = useRef(null);
|
||||
const cleanupResizeListenersRef = useRef(null);
|
||||
const [resizingColumnIndex, setResizingColumnIndex] = useState(-1);
|
||||
const [updateSetting] = useSetMythicSetting();
|
||||
const setColumnWidthsAndRef = useCallback( (newColumnWidths, resetColumnIndex=0) => {
|
||||
const normalizedColumnWidths = normalizeColumnWidths(newColumnWidths);
|
||||
if(areColumnWidthsEqual(columnWidthsRef.current, normalizedColumnWidths)){
|
||||
return false;
|
||||
}
|
||||
lastResetColumnIndexRef.current = resetColumnIndex;
|
||||
columnWidthsRef.current = normalizedColumnWidths;
|
||||
setColumnWidths(normalizedColumnWidths);
|
||||
return true;
|
||||
}, []);
|
||||
const getColumnWidth = useCallback(
|
||||
(index) => {
|
||||
return columnWidths[index] || MIN_COLUMN_WIDTH;
|
||||
},
|
||||
[columnWidths]
|
||||
);
|
||||
const getShortRandomString = () => {
|
||||
return (Math.random() + 1).toString(36).substring(2);
|
||||
}
|
||||
const gridUUID = React.useMemo( () => getShortRandomString(), []);
|
||||
const getRowHeight = useCallback(
|
||||
(index) => {
|
||||
if(index === 0){
|
||||
return headerRowHeight;
|
||||
}
|
||||
return rowHeight;
|
||||
},
|
||||
[rowHeight]
|
||||
[rowHeight, headerRowHeight]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const totalWidth = AutoSizerProps.width - scrollbarWidth;
|
||||
const updatedColumnWidths = columns.map((column) => column.width || MIN_COLUMN_WIDTH);
|
||||
const totalWidthDiff = totalWidth - updatedColumnWidths.reduce((a, b) => a + b, 0);
|
||||
if (totalWidthDiff > 0) {
|
||||
let updatedWidthIndexs = [];
|
||||
for(let i = 0; i < columns.length; i++){
|
||||
// check if any of the columns have the `fillWidth` property to true
|
||||
if(columns[i]["fillWidth"]){
|
||||
updatedWidthIndexs.push(i);
|
||||
}
|
||||
}
|
||||
if(updatedWidthIndexs.length === 0){
|
||||
updatedWidthIndexs.push(columns.length - 1);
|
||||
}
|
||||
for(let i = 0; i < updatedWidthIndexs.length; i++){
|
||||
updatedColumnWidths[updatedWidthIndexs[i]] += totalWidthDiff / updatedWidthIndexs.length;
|
||||
}
|
||||
//updatedColumnWidths[updatedWidthIndex] += totalWidthDiff;
|
||||
if(savedColumnWidths.length === columns.length && localColumnsRef.current.length === columns.length){
|
||||
return;
|
||||
}
|
||||
setColumnWidths(updatedColumnWidths);
|
||||
}, [scrollbarWidth, columns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
localColumnsRef.current = columns;
|
||||
const updatedColumnWidths = getInitialColumnWidths({
|
||||
columns,
|
||||
savedWidths: [],
|
||||
totalWidth: AutoSizerProps.width - scrollbarWidth,
|
||||
});
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths);
|
||||
if(didUpdateWidths && name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
|
||||
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current, setColumnWidthsAndRef]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
gridRef.current.resetAfterColumnIndex(0, true);
|
||||
if(gridRef.current){
|
||||
gridRef.current.resetAfterColumnIndex(lastResetColumnIndexRef.current, true);
|
||||
}
|
||||
}, [columnWidths]);
|
||||
|
||||
/* Event Handlers */
|
||||
|
||||
const resizeColumn = (x, columnIndex) => {
|
||||
const updatedWidths = columnWidths.map((columnWidth, index) => {
|
||||
const startColumnResize = useCallback( (event, columnIndex) => {
|
||||
if(columns[columnIndex]?.disableResize){
|
||||
return;
|
||||
}
|
||||
const initialWidths = columnWidthsRef.current.map( (columnWidth) => Math.floor(columnWidth || MIN_COLUMN_WIDTH));
|
||||
const startClientX = event.clientX;
|
||||
const startWidth = initialWidths[columnIndex] || MIN_COLUMN_WIDTH;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
const applyResizeWidth = (clientX) => {
|
||||
const activeResize = activeResizeRef.current;
|
||||
if(!activeResize){
|
||||
return;
|
||||
}
|
||||
activeResize.latestClientX = clientX;
|
||||
if(resizeFrameRef.current !== null){
|
||||
return;
|
||||
}
|
||||
resizeFrameRef.current = window.requestAnimationFrame(() => {
|
||||
resizeFrameRef.current = null;
|
||||
const currentResize = activeResizeRef.current;
|
||||
if(!currentResize){
|
||||
return;
|
||||
}
|
||||
const resizeDelta = currentResize.latestClientX - currentResize.startClientX;
|
||||
const nextColumnWidth = clampColumnWidth(columns[currentResize.columnIndex], currentResize.startWidth + resizeDelta);
|
||||
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
|
||||
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
|
||||
});
|
||||
setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
|
||||
});
|
||||
};
|
||||
const cleanupResizeListeners = () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
document.body.style.cursor = previousCursor;
|
||||
document.body.style.userSelect = previousUserSelect;
|
||||
cleanupResizeListenersRef.current = null;
|
||||
};
|
||||
const finishResize = (clientX) => {
|
||||
const currentResize = activeResizeRef.current;
|
||||
if(!currentResize){
|
||||
cleanupResizeListeners();
|
||||
return;
|
||||
}
|
||||
if(resizeFrameRef.current !== null){
|
||||
window.cancelAnimationFrame(resizeFrameRef.current);
|
||||
resizeFrameRef.current = null;
|
||||
}
|
||||
const resizeDelta = clientX - currentResize.startClientX;
|
||||
const nextColumnWidth = clampColumnWidth(columns[currentResize.columnIndex], currentResize.startWidth + resizeDelta);
|
||||
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
|
||||
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
|
||||
});
|
||||
activeResizeRef.current = null;
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
|
||||
if(didUpdateWidths && name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
setResizingColumnIndex(-1);
|
||||
cleanupResizeListeners();
|
||||
};
|
||||
function handlePointerMove(pointerEvent){
|
||||
pointerEvent.preventDefault();
|
||||
applyResizeWidth(pointerEvent.clientX);
|
||||
}
|
||||
function handlePointerUp(pointerEvent){
|
||||
pointerEvent.preventDefault();
|
||||
finishResize(pointerEvent.clientX);
|
||||
}
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
activeResizeRef.current = {
|
||||
columnIndex,
|
||||
initialWidths,
|
||||
latestClientX: startClientX,
|
||||
startClientX,
|
||||
startWidth,
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
setResizingColumnIndex(columnIndex);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
cleanupResizeListenersRef.current = cleanupResizeListeners;
|
||||
}, [columns, name, setColumnWidthsAndRef, updateSetting]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupResizeListenersRef.current?.();
|
||||
activeResizeRef.current = null;
|
||||
if(resizeFrameRef.current !== null){
|
||||
window.cancelAnimationFrame(resizeFrameRef.current);
|
||||
resizeFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
const autosizeColumn = useCallback( ({columnIndex}) => {
|
||||
const column = columns[columnIndex];
|
||||
if(column?.disableDoubleClick || column?.disableAutosize){
|
||||
return
|
||||
}
|
||||
const currentColumnWidths = columnWidthsRef.current;
|
||||
const updatedColumnWidths = currentColumnWidths.map((columnWidth, index) => {
|
||||
if (columnIndex === index) {
|
||||
return Math.floor(Math.max(columnWidth + x, MIN_COLUMN_WIDTH));
|
||||
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
|
||||
}
|
||||
return Math.floor(columnWidth);
|
||||
});
|
||||
setColumnWidths(updatedWidths);
|
||||
};
|
||||
|
||||
const autosizeColumn = (columnIndex) => {
|
||||
const longestElementInColumn = Math.max(...items.map((itemRow) => {
|
||||
if(columns[columnIndex].key){
|
||||
//console.log(columns[columnIndex].key)
|
||||
//console.log(itemRow[columnIndex]?.props?.rowData)
|
||||
return String(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key]).length || -1;
|
||||
} else if(typeof(itemRow[columnIndex]?.props?.cellData) === "string"){
|
||||
return itemRow[columnIndex]?.props?.cellData.length;
|
||||
}else {
|
||||
return itemRow[columnIndex]?.props?.cellData?.length || -1;
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
|
||||
if(didUpdateWidths){
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
|
||||
}));
|
||||
const updatedWidths = columnWidths.map((columnWidth, index) => {
|
||||
if (columnIndex === index) {
|
||||
return Math.floor(Math.max(longestElementInColumn * 10 + 40, MIN_COLUMN_WIDTH));
|
||||
}
|
||||
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
|
||||
const autosizeAllColumns = useCallback( () => {
|
||||
const currentColumnWidths = columnWidthsRef.current;
|
||||
const updatedColumnWidths = currentColumnWidths.map((columnWidth, columnIndex) => {
|
||||
const column = columns[columnIndex];
|
||||
if(column?.disableDoubleClick || column?.disableAutosize){
|
||||
return Math.floor(columnWidth);
|
||||
}
|
||||
return Math.floor(columnWidth);
|
||||
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
|
||||
});
|
||||
setColumnWidths(updatedWidths);
|
||||
};
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
|
||||
if(didUpdateWidths && name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
|
||||
const resetColumnWidths = useCallback( () => {
|
||||
const updatedColumnWidths = getInitialColumnWidths({
|
||||
columns,
|
||||
savedWidths: [],
|
||||
totalWidth: AutoSizerProps.width - scrollbarWidth,
|
||||
});
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
|
||||
if(didUpdateWidths && name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
}, [AutoSizerProps.width, columns, name, scrollbarWidth, setColumnWidthsAndRef, updateSetting]);
|
||||
const headerContextMenuOptions = React.useMemo( () => {
|
||||
const sizingOptions = [
|
||||
{
|
||||
name: "Autosize Column",
|
||||
type: "item",
|
||||
icon: <FitScreenIcon style={headerMenuIconStyle} />,
|
||||
disabled: ({columnIndex}) => columns[columnIndex]?.disableDoubleClick || columns[columnIndex]?.disableAutosize,
|
||||
click: ({columnIndex}) => autosizeColumn({columnIndex}),
|
||||
},
|
||||
{
|
||||
name: "Autosize All Columns",
|
||||
type: "item",
|
||||
icon: <ViewColumnIcon style={headerMenuIconStyle} />,
|
||||
disabled: columns.every((column) => column?.disableDoubleClick || column?.disableAutosize),
|
||||
click: () => autosizeAllColumns(),
|
||||
},
|
||||
{
|
||||
name: "Reset Column Widths",
|
||||
type: "item",
|
||||
icon: <RestartAltIcon style={headerMenuIconStyle} />,
|
||||
click: () => resetColumnWidths(),
|
||||
},
|
||||
];
|
||||
return [...sizingOptions, ...(contextMenuOptions || []).map(normalizeHeaderContextMenuOption)];
|
||||
}, [autosizeAllColumns, autosizeColumn, columns, contextMenuOptions, resetColumnWidths]);
|
||||
|
||||
useEffect( () => {
|
||||
if(callbackTableGridRef){
|
||||
callbackTableGridRef.current = gridRef.current;
|
||||
}
|
||||
}, [gridRef.current])
|
||||
const itemsWithHeader = [columns, ...items];
|
||||
|
||||
const innerElementType = React.forwardRef(({ children, ...rest }, ref) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div ref={ref} {...rest}>
|
||||
{/* always render header cells */}
|
||||
<div
|
||||
className={classes.headerCellRow}
|
||||
style={{
|
||||
height: getRowHeight(0),
|
||||
}}>
|
||||
{columns.map((column, i) => {
|
||||
const leftOffset = columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<HeaderCell
|
||||
key={i}
|
||||
headerNameKey={headerNameKey}
|
||||
onClick={onClickHeader}
|
||||
onDoubleClick={(e, columnIndex) => {
|
||||
if (column.disableAutosize) return;
|
||||
autosizeColumn(columnIndex);
|
||||
}}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
sortIndicatorIndex={sortIndicatorIndex}
|
||||
sortDirection={sortDirection}
|
||||
VariableSizeGridProps={{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: leftOffset,
|
||||
height: getRowHeight(0),
|
||||
width: getColumnWidth(i),
|
||||
},
|
||||
rowIndex: 0,
|
||||
columnIndex: i,
|
||||
data: { items: itemsWithHeader },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* render other cells as usual */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const headerCellData = {
|
||||
"getRowHeight": getRowHeight,
|
||||
"columns": columns,
|
||||
"columnWidths": columnWidths,
|
||||
"headerNameKey": headerNameKey,
|
||||
"onClickHeader": onClickHeader,
|
||||
"autosizeColumn": autosizeColumn,
|
||||
"contextMenuOptions": headerContextMenuOptions,
|
||||
"sortIndicatorIndex": sortIndicatorIndex,
|
||||
"sortDirection": sortDirection,
|
||||
"getColumnWidth": getColumnWidth,
|
||||
"resizingColumnIndex": resizingColumnIndex,
|
||||
"startColumnResize": startColumnResize,
|
||||
"itemsWithHeader": itemsWithHeader,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<VariableSizeGrid
|
||||
height={AutoSizerProps.height}
|
||||
width={AutoSizerProps.width}
|
||||
columnCount={columns.length}
|
||||
columnWidth={getColumnWidth}
|
||||
headerNameKey={headerNameKey}
|
||||
rowCount={itemsWithHeader.length}
|
||||
rowHeight={getRowHeight}
|
||||
itemData={{ items: itemsWithHeader, onDoubleClickRow, gridUUID, rowContextMenuOptions}}
|
||||
innerElementType={innerElementType}
|
||||
overscanRowCount={0}
|
||||
onScroll={({ scrollLeft }) => {
|
||||
if (dragHandlesRef.current) {
|
||||
dragHandlesRef.current.scrollTo({ left: scrollLeft });
|
||||
}
|
||||
}}
|
||||
ref={gridRef}>
|
||||
{CellRenderer}
|
||||
</VariableSizeGrid>
|
||||
<DraggableHandles
|
||||
height={AutoSizerProps.height}
|
||||
rowHeight={getRowHeight(0)}
|
||||
width={AutoSizerProps.width}
|
||||
minColumnWidth={MIN_COLUMN_WIDTH}
|
||||
columnWidths={columnWidths}
|
||||
onStop={resizeColumn}
|
||||
ref={dragHandlesRef}
|
||||
/>
|
||||
<HeaderCellContext.Provider value={headerCellData}>
|
||||
<VariableSizeGrid
|
||||
className={classes.grid}
|
||||
height={Math.max(1, AutoSizerProps.height)}
|
||||
width={AutoSizerProps.width}
|
||||
columnCount={columns.length}
|
||||
columnWidth={getColumnWidth}
|
||||
headerNameKey={headerNameKey}
|
||||
rowCount={itemsWithHeader.length}
|
||||
rowHeight={getRowHeight}
|
||||
itemData={{ items: itemsWithHeader,
|
||||
onDoubleClickRow, gridUUID,
|
||||
rowContextMenuOptions, onRowClick,
|
||||
onRowContextMenuClick}}
|
||||
innerElementType={innerElementType}
|
||||
overscanRowCount={20}
|
||||
useIsScrolling={false}
|
||||
ref={gridRef}>
|
||||
{CellRenderer}
|
||||
</VariableSizeGrid>
|
||||
</HeaderCellContext.Provider>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -207,28 +584,40 @@ const MythicResizableGrid = ({
|
||||
onDoubleClickRow,
|
||||
contextMenuOptions,
|
||||
rowContextMenuOptions,
|
||||
onRowContextMenuClick,
|
||||
widthMeasureKey,
|
||||
rowHeight = 32,
|
||||
rowHeight = 20,
|
||||
headerRowHeight = 20,
|
||||
callbackTableGridRef,
|
||||
onRowClick,
|
||||
name,
|
||||
}) => {
|
||||
return (
|
||||
<AutoSizer>
|
||||
{(AutoSizerProps) => (
|
||||
<ResizableGridWrapper
|
||||
columns={columns}
|
||||
headerNameKey={headerNameKey}
|
||||
sortIndicatorIndex={sortIndicatorIndex}
|
||||
sortDirection={sortDirection}
|
||||
items={items}
|
||||
widthMeasureKey={widthMeasureKey}
|
||||
rowHeight={rowHeight}
|
||||
onClickHeader={onClickHeader}
|
||||
onDoubleClickRow={onDoubleClickRow}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
rowContextMenuOptions={rowContextMenuOptions}
|
||||
{...AutoSizerProps}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
<div className={classes.root}>
|
||||
<AutoSizer style={{height: "100%", width: "100%"}}>
|
||||
{(AutoSizerProps) => (
|
||||
<ResizableGridWrapper
|
||||
name={name}
|
||||
columns={columns}
|
||||
callbackTableGridRef={callbackTableGridRef}
|
||||
headerNameKey={headerNameKey}
|
||||
sortIndicatorIndex={sortIndicatorIndex}
|
||||
sortDirection={sortDirection}
|
||||
items={items}
|
||||
widthMeasureKey={widthMeasureKey}
|
||||
rowHeight={rowHeight}
|
||||
headerRowHeight={headerRowHeight}
|
||||
onClickHeader={onClickHeader}
|
||||
onDoubleClickRow={onDoubleClickRow}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
rowContextMenuOptions={rowContextMenuOptions}
|
||||
onRowContextMenuClick={onRowContextMenuClick}
|
||||
onRowClick={onRowClick}
|
||||
{...AutoSizerProps}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -238,6 +627,7 @@ MythicResizableGrid.propTypes = {
|
||||
name: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
disableAutosize: PropTypes.bool,
|
||||
disableResize: PropTypes.bool,
|
||||
disableSort: PropTypes.bool
|
||||
})
|
||||
).isRequired,
|
||||
@@ -249,6 +639,7 @@ MythicResizableGrid.propTypes = {
|
||||
contextMenuOptions: PropTypes.array,
|
||||
rowContextMenuOptions: PropTypes.array,
|
||||
rowHeight: PropTypes.number,
|
||||
headerRowHeight: PropTypes.number,
|
||||
headerNameKey: PropTypes.string,
|
||||
widthMeasureKey: PropTypes.string
|
||||
};
|
||||
|
||||
@@ -1,69 +1,24 @@
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
export default makeStyles((theme) => ({
|
||||
headerCellRow: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
position: 'sticky',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
zIndex: 1,
|
||||
},
|
||||
headerCell: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 0.25em',
|
||||
boxSizing: 'border-box',
|
||||
justifyContent: 'space-between',
|
||||
userSelect: 'none',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: '1px solid ' + theme.tableHover,
|
||||
borderRight: '1px solid ' + theme.tableHover,
|
||||
borderBottom: '1px solid ' + theme.tableHover,
|
||||
'&:first-child': {
|
||||
borderLeft: '1px solid ' + theme.tableHover,
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: theme.tableHover,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
hoveredRow: {
|
||||
backgroundColor: theme.tableHover,
|
||||
},
|
||||
cell: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 0.5em',
|
||||
boxSizing: 'border-box',
|
||||
fontFamily: 'monospace',
|
||||
borderBottom: '1px solid ' + theme.tableHover,
|
||||
cursor: "default !important",
|
||||
},
|
||||
cellInner: {
|
||||
width: '100%',
|
||||
whiteSpace: 'nowrap',
|
||||
overflowX: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
draggableHandlesContainer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
overflowX: 'hidden',
|
||||
},
|
||||
draggableHandlesClickArea: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '16px',
|
||||
cursor: 'col-resize',
|
||||
pointerEvents: 'initial',
|
||||
},
|
||||
draggableHandlesIndicator: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 8,
|
||||
width: '1px',
|
||||
backgroundImage: 'linear-gradient(#7f93c0, #00000000)',
|
||||
},
|
||||
}));
|
||||
const PREFIX = 'MythicResizableGrid';
|
||||
export const classes = {
|
||||
root: `${PREFIX}-root flex-fill h-full min-h-0 min-w-0 overflow-hidden relative rounded w-full`,
|
||||
grid: `${PREFIX}-grid`,
|
||||
headerCellRow: `${PREFIX}-headerCellRow flex flex-row`,
|
||||
headerCell: `${PREFIX}-headerCell cursor-pointer items-center flex justify-between min-w-0 overflow-hidden relative`,
|
||||
headerCellNoSort: `${PREFIX}-headerCellNoSort`,
|
||||
headerContent: `${PREFIX}-headerContent items-center flex gap-3 justify-between min-w-0 w-full`,
|
||||
headerLabel: `${PREFIX}-headerLabel min-w-0 truncate`,
|
||||
headerActions: `${PREFIX}-headerActions items-center inline-flex flex-none gap-2 min-w-0`,
|
||||
headerIndicator: `${PREFIX}-headerIndicator items-center inline-flex flex-none justify-center rounded`,
|
||||
headerFilterIcon: `${PREFIX}-headerFilterIcon cursor-pointer`,
|
||||
headerSortIcon: `${PREFIX}-headerSortIcon`,
|
||||
headerResizeHandle: `${PREFIX}-headerResizeHandle`,
|
||||
headerResizeHandleActive: `${PREFIX}-headerResizeHandleActive`,
|
||||
resizeGuide: `${PREFIX}-resizeGuide`,
|
||||
hoveredRow: `${PREFIX}-hoveredRow`,
|
||||
contextRow: `${PREFIX}-contextRow`,
|
||||
rowFirstCell: `${PREFIX}-rowFirstCell`,
|
||||
rowLastCell: `${PREFIX}-rowLastCell`,
|
||||
rowInteractive: `${PREFIX}-rowInteractive`,
|
||||
cell: `${PREFIX}-cell items-center flex min-w-0 relative truncate`,
|
||||
cellInner: `${PREFIX}-cellInner items-center flex min-w-0 truncate w-full whitespace-nowrap`,
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import {mePreferences, operatorSettingDefaults} from '../../cache';
|
||||
import React from 'react';
|
||||
import {useMutation, gql } from '@apollo/client';
|
||||
import {snackActions} from "../utilities/Snackbar";
|
||||
|
||||
const updatePreferences = gql`
|
||||
mutation updatePreferences($preferences: jsonb!){
|
||||
updateOperatorPreferences(preferences: $preferences){
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const GetComputedFontSize = () => {
|
||||
const element = document.getElementById('root');
|
||||
const fontSizeString = window.getComputedStyle(element).fontSize;
|
||||
return parseFloat(fontSizeString);
|
||||
}
|
||||
let latestPreferenceSnapshot = null;
|
||||
const getPreferenceSnapshot = () => {
|
||||
return latestPreferenceSnapshot || mePreferences() || {};
|
||||
};
|
||||
const areSettingValuesEqual = (first, second) => {
|
||||
if(first === second){
|
||||
return true;
|
||||
}
|
||||
if(typeof first !== "object" || typeof second !== "object" || first === null || second === null){
|
||||
return false;
|
||||
}
|
||||
try{
|
||||
return JSON.stringify(first) === JSON.stringify(second);
|
||||
}catch(error){
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/*
|
||||
setting_name options:
|
||||
taskingDisplayFields
|
||||
showMedia
|
||||
interactType
|
||||
|
||||
callbacks_table_columns
|
||||
callbacks_table_filters
|
||||
*/
|
||||
export function useGetMythicSetting({setting_name, default_value}){
|
||||
const preferences = useReactiveVar(mePreferences);
|
||||
let initialStorageSetting = GetMythicSetting({setting_name, default_value});
|
||||
|
||||
const [setting, setSetting] = React.useState(initialStorageSetting);
|
||||
|
||||
React.useEffect( () => {
|
||||
let newSetting = GetMythicSetting({setting_name, default_value});
|
||||
setSetting(newSetting);
|
||||
}, [preferences?.[setting_name]]);
|
||||
|
||||
return setting;
|
||||
}
|
||||
export function GetMythicSetting({setting_name, default_value}){
|
||||
const preferences = getPreferenceSnapshot();
|
||||
return preferences?.[setting_name] === undefined ? default_value : preferences?.[setting_name];
|
||||
}
|
||||
export function useSetMythicSetting() {
|
||||
const [updateSetting] = useMutation(updatePreferences, {
|
||||
onCompleted: (data) => {
|
||||
//console.log(data, mePreferences());
|
||||
},
|
||||
onError: (error) => {
|
||||
snackActions.error("failed to save user setting: " + error.message);
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
return [
|
||||
({setting_name, value, broadcast=true}) => {
|
||||
const currentPreferences = getPreferenceSnapshot();
|
||||
if(!areSettingValuesEqual(currentPreferences?.[setting_name], value)){
|
||||
const updatedPreferences = {
|
||||
...currentPreferences,
|
||||
[setting_name]: value,
|
||||
};
|
||||
latestPreferenceSnapshot = updatedPreferences;
|
||||
if(broadcast){
|
||||
mePreferences(updatedPreferences);
|
||||
}
|
||||
updateSetting({variables: {preferences: updatedPreferences}});
|
||||
}
|
||||
},
|
||||
({settings, broadcast=true}) => {
|
||||
const updatedPreferences = {
|
||||
...getPreferenceSnapshot(),
|
||||
...settings,
|
||||
};
|
||||
latestPreferenceSnapshot = updatedPreferences;
|
||||
if(broadcast){
|
||||
mePreferences(updatedPreferences);
|
||||
}
|
||||
updateSetting({variables: {preferences: updatedPreferences}});
|
||||
},
|
||||
() => {
|
||||
latestPreferenceSnapshot = operatorSettingDefaults;
|
||||
mePreferences(operatorSettingDefaults);
|
||||
updateSetting({variables: {preferences: operatorSettingDefaults}});
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useRef, useEffect} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -8,12 +8,9 @@ import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Input from '@mui/material/Input';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export function MythicSelectFromListDialog(props) {
|
||||
const [options, setOptions] = React.useState([]);
|
||||
@@ -57,19 +54,19 @@ export function MythicSelectFromListDialog(props) {
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{options.map( (opt) => (
|
||||
<MenuItem value={opt} key={opt[props.identifier]}>{opt[props.display]}</MenuItem>
|
||||
<MenuItem value={opt} key={opt[props.identifier]}>{opt?.[props.display]}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</React.Fragment>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} variant="contained" color="success">
|
||||
</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={handleSubmit} tone="success" variant="contained">
|
||||
{props.action}
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
@@ -77,10 +74,17 @@ export function MythicSelectFromListDialog(props) {
|
||||
|
||||
export function MythicSelectFromRawListDialog(props) {
|
||||
const [options, setOptions] = React.useState([]);
|
||||
const actionText = props.action || "Select";
|
||||
const handleSubmit = (selected) => {
|
||||
props.onSubmit(selected);
|
||||
props.onClose();
|
||||
}
|
||||
const handleKeyDown = (event, selected) => {
|
||||
if(event.key === "Enter" || event.key === " "){
|
||||
event.preventDefault();
|
||||
handleSubmit(selected);
|
||||
}
|
||||
}
|
||||
useEffect( () => {
|
||||
//expects options to be an array of dictionaries with a "display" field for what gets presented to the user
|
||||
const opts = [...props.options];
|
||||
@@ -89,24 +93,37 @@ export function MythicSelectFromRawListDialog(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle >{props.title}</DialogTitle>
|
||||
<TableContainer component={Paper} className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody style={{whiteSpace: "pre"}}>
|
||||
{options.map( choice => (
|
||||
<TableRow hover key={choice}>
|
||||
<TableCell style={{width: "5rem"}}>
|
||||
<Button onClick={() => handleSubmit(choice)} variant="contained" color="primary">Select</Button>
|
||||
</TableCell>
|
||||
<TableCell>{choice}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DialogContent dividers={true} className="mythic-raw-select-dialog-content">
|
||||
{options.length === 0 ? (
|
||||
<Box className="mythic-raw-select-empty items-center flex justify-center text-muted text-center">
|
||||
<Typography variant="body2">No options available</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack className="mythic-raw-select-list gap-4">
|
||||
{options.map( (choice, i) => (
|
||||
<Box
|
||||
key={String(choice) + i}
|
||||
className="mythic-raw-select-row items-center flex gap-6 justify-between min-w-0 w-full rounded cursor-pointer bg-surface-muted border-subtle text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSubmit(choice)}
|
||||
onKeyDown={(event) => handleKeyDown(event, choice)}
|
||||
>
|
||||
<Typography className="mythic-raw-select-value min-w-0 truncate whitespace-nowrap" title={String(choice)}>
|
||||
{String(choice)}
|
||||
</Typography>
|
||||
<MythicActionButton className="mythic-raw-select-action" colorMode="always" tone="info" variant="outlined" size="small" tabIndex={-1}>
|
||||
{actionText}
|
||||
</MythicActionButton>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@@ -1,65 +1,86 @@
|
||||
import React from 'react';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from '@mui/material';
|
||||
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
minWidth: '344px !important',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
actionRoot: {
|
||||
padding: '8px 8px 8px 16px',
|
||||
},
|
||||
icons: {
|
||||
marginLeft: 'auto',
|
||||
float: "right"
|
||||
},
|
||||
expand: {
|
||||
padding: '8px 8px',
|
||||
transform: 'rotate(0deg)',
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
expandOpen: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
collapse: {
|
||||
padding: 16,
|
||||
},
|
||||
checkIcon: {
|
||||
fontSize: 20,
|
||||
color: '#b3b3b3',
|
||||
paddingRight: 4,
|
||||
},
|
||||
button: {
|
||||
padding: 0,
|
||||
textTransform: 'none',
|
||||
},
|
||||
const PREFIX = 'MythicSnackDownload';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`,
|
||||
typography: `${PREFIX}-typography`,
|
||||
actionRoot: `${PREFIX}-actionRoot`,
|
||||
icons: `${PREFIX}-icons`,
|
||||
expand: `${PREFIX}-expand`,
|
||||
expandOpen: `${PREFIX}-expandOpen`,
|
||||
collapse: `${PREFIX}-collapse`,
|
||||
checkIcon: `${PREFIX}-checkIcon`,
|
||||
button: `${PREFIX}-button`
|
||||
};
|
||||
|
||||
const Root = styled('div')((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.root}`]: {
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
minWidth: '344px !important',
|
||||
},
|
||||
},
|
||||
|
||||
[`& .${classes.typography}`]: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
|
||||
[`& .${classes.actionRoot}`]: {
|
||||
padding: '8px 8px 8px 16px',
|
||||
},
|
||||
|
||||
[`& .${classes.icons}`]: {
|
||||
marginLeft: 'auto',
|
||||
float: "right"
|
||||
},
|
||||
|
||||
[`& .${classes.expand}`]: {
|
||||
padding: '8px 8px',
|
||||
transform: 'rotate(0deg)',
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
|
||||
[`& .${classes.expandOpen}`]: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
|
||||
[`& .${classes.collapse}`]: {
|
||||
padding: 16,
|
||||
},
|
||||
|
||||
[`& .${classes.checkIcon}`]: {
|
||||
fontSize: 20,
|
||||
color: theme.palette.text.secondary,
|
||||
paddingRight: 4,
|
||||
},
|
||||
|
||||
[`& .${classes.button}`]: {
|
||||
padding: 0,
|
||||
textTransform: 'none',
|
||||
}
|
||||
}));
|
||||
|
||||
export const MythicSnackDownload = (props) => {
|
||||
|
||||
const theme = useTheme();
|
||||
const classes = useStyles(theme);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Root>
|
||||
<Typography variant="subtitle2" className={classes.typography}>
|
||||
{props.title}
|
||||
</Typography>
|
||||
<React.Fragment>
|
||||
<Typography gutterBottom>File ready for download</Typography>
|
||||
<Link download={true} href={"/direct/download/" + props.file_id} target="_blank">
|
||||
<FileDownloadLinkWithAuth color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
|
||||
Download here
|
||||
</Link>
|
||||
</FileDownloadLinkWithAuth>
|
||||
</React.Fragment>
|
||||
</React.Fragment>
|
||||
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Skeleton from '@mui/material/Skeleton';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {alpha, useTheme} from '@mui/material/styles';
|
||||
import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
|
||||
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
|
||||
import InboxOutlinedIcon from '@mui/icons-material/InboxOutlined';
|
||||
import SearchOffOutlinedIcon from '@mui/icons-material/SearchOffOutlined';
|
||||
|
||||
const stateIcons = {
|
||||
empty: <InboxOutlinedIcon />,
|
||||
error: <ErrorOutlineOutlinedIcon />,
|
||||
loading: <HourglassEmptyOutlinedIcon />,
|
||||
search: <SearchOffOutlinedIcon />,
|
||||
};
|
||||
|
||||
export function MythicStateDisplay({
|
||||
action,
|
||||
actionLabel,
|
||||
compact = false,
|
||||
description,
|
||||
icon,
|
||||
loading = false,
|
||||
minHeight,
|
||||
onAction,
|
||||
severity = "empty",
|
||||
sx = {},
|
||||
title,
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const defaultIcon = stateIcons[severity] || stateIcons.empty;
|
||||
const iconNode = loading ? (
|
||||
<CircularProgress size={compact ? 18 : 22} color="inherit" disableShrink />
|
||||
) : React.cloneElement(icon || defaultIcon, {fontSize: compact ? "small" : "medium"});
|
||||
const resolvedTitle = title || (loading ? "Loading" : "Nothing to show");
|
||||
const resolvedMinHeight = minHeight || (compact ? 112 : 176);
|
||||
return (
|
||||
<Box sx={{
|
||||
alignItems: "center",
|
||||
color: theme.palette.text.secondary,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
minHeight: resolvedMinHeight,
|
||||
p: compact ? 1.5 : 2.5,
|
||||
width: "100%",
|
||||
...sx,
|
||||
}}>
|
||||
<Box sx={{
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: compact ? 0.75 : 1,
|
||||
maxWidth: compact ? 360 : 460,
|
||||
textAlign: "center",
|
||||
}}>
|
||||
<Box sx={{
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.055) : alpha(theme.palette.common.black, 0.035),
|
||||
border: `1px solid ${theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.09) : alpha(theme.palette.common.black, 0.08)}`,
|
||||
borderRadius: "6px",
|
||||
color: severity === "error" ? theme.palette.error.main : theme.palette.text.secondary,
|
||||
display: "inline-flex",
|
||||
height: compact ? 32 : 40,
|
||||
justifyContent: "center",
|
||||
width: compact ? 32 : 40,
|
||||
}}>
|
||||
{iconNode}
|
||||
</Box>
|
||||
<Typography component="div" sx={{
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: compact ? "0.86rem" : "0.95rem",
|
||||
fontWeight: 800,
|
||||
lineHeight: 1.25,
|
||||
}}>
|
||||
{resolvedTitle}
|
||||
</Typography>
|
||||
{description &&
|
||||
<Typography component="div" sx={{
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: compact ? "0.76rem" : "0.82rem",
|
||||
lineHeight: 1.35,
|
||||
}}>
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
{action || (actionLabel && onAction) ? (
|
||||
action || (
|
||||
<MythicActionButton size="small" variant="outlined" onClick={onAction} sx={{mt: 0.5}}>
|
||||
{actionLabel}
|
||||
</MythicActionButton>
|
||||
)
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicEmptyState(props) {
|
||||
return <MythicStateDisplay severity="empty" {...props} />;
|
||||
}
|
||||
|
||||
export function MythicSearchEmptyState(props) {
|
||||
return <MythicStateDisplay severity="search" title="No results" {...props} />;
|
||||
}
|
||||
|
||||
export function MythicLoadingState(props) {
|
||||
return <MythicStateDisplay severity="loading" loading title="Loading" {...props} />;
|
||||
}
|
||||
|
||||
export function MythicErrorState(props) {
|
||||
return <MythicStateDisplay severity="error" title="Something went wrong" {...props} />;
|
||||
}
|
||||
|
||||
export function MythicTableEmptyState({colSpan, ...props}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
|
||||
<MythicEmptyState {...props} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicTableSearchEmptyState({colSpan, ...props}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
|
||||
<MythicSearchEmptyState {...props} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicTableErrorState({colSpan, ...props}) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
|
||||
<MythicErrorState {...props} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicTableSkeletonRows({colSpan, columns = 4, rows = 4}) {
|
||||
const skeletonColumns = Math.max(1, columns);
|
||||
return (
|
||||
<>
|
||||
{[...Array(rows).keys()].map((rowIndex) => (
|
||||
<TableRow key={`mythic-table-skeleton-${rowIndex}`}>
|
||||
<TableCell colSpan={colSpan} sx={{px: 1.25, py: 0.75}}>
|
||||
<Box sx={{
|
||||
display: "grid",
|
||||
gap: 1,
|
||||
gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))`,
|
||||
}}>
|
||||
{[...Array(skeletonColumns).keys()].map((columnIndex) => (
|
||||
<Skeleton
|
||||
animation="wave"
|
||||
height={22}
|
||||
key={`mythic-table-skeleton-${rowIndex}-${columnIndex}`}
|
||||
sx={{borderRadius: 1}}
|
||||
variant="rounded"
|
||||
width={columnIndex === skeletonColumns - 1 ? "72%" : "100%"}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicTableLoadingState({colSpan, columns, rows = 4, showSkeleton = true, ...props}) {
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell colSpan={colSpan} sx={{borderBottom: showSkeleton ? undefined : 0, p: 0}}>
|
||||
<MythicLoadingState {...props} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{showSkeleton &&
|
||||
<MythicTableSkeletonRows colSpan={colSpan} columns={columns} rows={rows} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
|
||||
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
|
||||
import TimelapseIcon from '@mui/icons-material/Timelapse';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
import BlockIcon from '@mui/icons-material/Block';
|
||||
import HideSourceIcon from '@mui/icons-material/HideSource';
|
||||
import PanoramaFishEyeIcon from '@mui/icons-material/PanoramaFishEye';
|
||||
import NotificationsActiveOutlinedIcon from '@mui/icons-material/NotificationsActiveOutlined';
|
||||
import {MythicChip} from './MythicChip';
|
||||
import {MythicStyledTooltip} from './MythicStyledTooltip';
|
||||
|
||||
const statusConfig = {
|
||||
success: {tone: "success", icon: <CheckCircleOutlineIcon />, label: "Success", tooltip: "Completed successfully"},
|
||||
error: {tone: "error", icon: <ErrorOutlineIcon />, label: "Error", tooltip: "Errored"},
|
||||
warning: {tone: "warning", icon: <WarningAmberIcon />, label: "Warning"},
|
||||
info: {tone: "info", icon: <InfoOutlinedIcon />, label: "Info"},
|
||||
active: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Active"},
|
||||
inactive: {tone: "error", icon: <RadioButtonUncheckedIcon />, label: "Inactive"},
|
||||
disabled: {tone: "neutral", icon: <RadioButtonUncheckedIcon />, label: "Disabled"},
|
||||
deleted: {tone: "error", icon: <DeleteOutlineIcon />, label: "Deleted"},
|
||||
locked: {tone: "warning", icon: <LockOutlinedIcon />, label: "Locked"},
|
||||
building: {tone: "warning", icon: <TimelapseIcon />, label: "Building"},
|
||||
completed: {tone: "success", icon: <DoneAllIcon />, label: "Completed"},
|
||||
blocked: {tone: "error", icon: <BlockIcon />, label: "Blocked"},
|
||||
skipped: {tone: "secondary", icon: <HideSourceIcon />, label: "Skipped"},
|
||||
running: {tone: "info", icon: <TimelapseIcon />, label: "Running"},
|
||||
cancelled: {tone: "warning", icon: <HideSourceIcon />, label: "Cancelled"},
|
||||
queued: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Queued", tooltip: "Waiting to run"},
|
||||
awaiting_approval: {tone: "warning", icon: <TimelapseIcon />, label: "Awaiting approval"},
|
||||
input_needed: {tone: "warning", icon: <InfoOutlinedIcon />, label: "Input needed"},
|
||||
configured: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Configured"},
|
||||
waiting: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Waiting"},
|
||||
runnable: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Runnable"},
|
||||
needs_approval: {tone: "warning", icon: <TimelapseIcon />, label: "Needs approval"},
|
||||
stopped: {tone: "warning", icon: <RadioButtonUncheckedIcon />, label: "Stopped"},
|
||||
available: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Available"},
|
||||
hidden: {tone: "warning", icon: <HideSourceIcon />, label: "Hidden"},
|
||||
updating: {tone: "warning", icon: <TimelapseIcon />, label: "Updating"},
|
||||
alerting: {tone: "warning", icon: <NotificationsActiveOutlinedIcon />, label: "Alerting"},
|
||||
dead: {tone: "warning", icon: <WarningAmberIcon />, label: "Likely dead"},
|
||||
unknown: {tone: "error", icon: <ErrorOutlineIcon />, label: "Unknown"},
|
||||
secondary: {tone: "secondary", icon: <InfoOutlinedIcon />, label: "Status"},
|
||||
};
|
||||
|
||||
export function getMythicStatusConfig(status) {
|
||||
return statusConfig[status] || statusConfig.secondary;
|
||||
}
|
||||
|
||||
export function getMythicStatusFromTaskStatus(status) {
|
||||
const normalized = `${status || ""}`.toLowerCase();
|
||||
if(normalized.includes("error") || normalized.includes("failed")){
|
||||
return "error";
|
||||
}
|
||||
if(normalized.includes("warning")){
|
||||
return "warning";
|
||||
}
|
||||
if(normalized.includes("success") || normalized.includes("completed")){
|
||||
return "success";
|
||||
}
|
||||
if(normalized.includes("processing") || normalized.includes("building")){
|
||||
return "building";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
export function MythicStatusIcon({
|
||||
className = "",
|
||||
status = "secondary",
|
||||
tooltip = false,
|
||||
tooltipStyle = {},
|
||||
tooltipTitle,
|
||||
...props
|
||||
}) {
|
||||
const config = getMythicStatusConfig(status);
|
||||
const icon = React.cloneElement(config.icon, {
|
||||
...props,
|
||||
className: `mythic-icon-tone text-tone mythic-tone-${config.tone}${className ? ` ${className}` : ""}`,
|
||||
});
|
||||
|
||||
return tooltip ? (
|
||||
<MythicStyledTooltip
|
||||
title={tooltipTitle || config.tooltip || config.label}
|
||||
tooltipStyle={{display: "inline-flex", ...tooltipStyle}}
|
||||
>
|
||||
{icon}
|
||||
</MythicStyledTooltip>
|
||||
) : icon;
|
||||
}
|
||||
|
||||
export function MythicStatusChip({
|
||||
label,
|
||||
status = "secondary",
|
||||
className = "",
|
||||
icon,
|
||||
showIcon = true,
|
||||
size = "small",
|
||||
variant = "soft",
|
||||
sx = {},
|
||||
...props
|
||||
}) {
|
||||
const config = getMythicStatusConfig(status);
|
||||
const chipIcon = showIcon ? (icon || config.icon) : undefined;
|
||||
|
||||
return (
|
||||
<MythicChip
|
||||
className={className}
|
||||
label={label || config.label}
|
||||
icon={chipIcon}
|
||||
size={size}
|
||||
tone={config.tone}
|
||||
variant={variant}
|
||||
sx={sx}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import React from 'react';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import { Tooltip } from '@mui/material';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
tooltip: {
|
||||
backgroundColor: theme.palette.background.contrast,
|
||||
color: theme.palette.text.contrast,
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 13,
|
||||
},
|
||||
arrow: {
|
||||
color: theme.palette.background.contrast,
|
||||
}
|
||||
}));
|
||||
|
||||
export function MythicStyledTooltip(props){
|
||||
const { children, title, style, ...other} = props;
|
||||
const classes = useStyles();
|
||||
export function MythicStyledTooltip({ children, title, enterDelay, tooltipStyle}){
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Tooltip title={title} arrow classes={{tooltip: classes.tooltip, arrow: classes.arrow}} {...other}>
|
||||
{<span style={{...style, display: "inline-block"}}>{children}</span>}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
<span style={{display: "inline-block", ...tooltipStyle}}
|
||||
data-tooltip-id={"my-tooltip"}
|
||||
data-tooltip-content={title}
|
||||
data-tooltip-variant={theme.palette.mode === 'dark' ? 'light' : 'dark'}
|
||||
data-tooltip-delay-show={enterDelay ? enterDelay : 750}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import { IconButton } from '@mui/material';
|
||||
import {MythicActionButton} from "./MythicActionButton";
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import React from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import {alpha, useTheme} from '@mui/material/styles';
|
||||
import {
|
||||
Draggable,
|
||||
} from "@hello-pangea/dnd";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
|
||||
export function MythicTabPanel(props) {
|
||||
const { children, value, index, maxHeight, tabInfo, getCallbackData, queryParams, changeSearchParam, ...other } =
|
||||
const { children, value, index, maxHeight, tabInfo, getCallbackData, queryParams, changeSearchParam, showDeleted, ...other } =
|
||||
props;
|
||||
const style =
|
||||
props.style === undefined
|
||||
@@ -20,6 +24,7 @@ export function MythicTabPanel(props) {
|
||||
display: value === index ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
overflowY: "auto",
|
||||
@@ -37,57 +42,12 @@ export function MythicTabPanel(props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function a11yProps(index) {
|
||||
export function a11yProps(index) {
|
||||
return {
|
||||
id: `scrollable-auto-tab-${index}`,
|
||||
'aria-controls': `scrollable-auto-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
function allowDrop(ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function drag(ev) {
|
||||
ev.dataTransfer.setData("text", ev.target.id);
|
||||
ev.dataTransfer.setData("opacity", ev.target.style.opacity);
|
||||
ev.target.style.opacity = "0.2";
|
||||
}
|
||||
|
||||
function onDragEnter(ev){
|
||||
let node = ev.target.getAttribute("role") === "tab" ? ev.target : ev.target.closest('[role="tab"]');
|
||||
node.style.borderLeft = "3px dotted red";
|
||||
//console.log("ondragenter");
|
||||
}
|
||||
|
||||
function onDragLeave(ev){
|
||||
let node = ev.target.closest('[role="tab"]');
|
||||
if(ev.target.getAttribute("role") === "tab"){
|
||||
//console.log("leaving tab", ev.target)
|
||||
node.style.border = "";
|
||||
} else if(node.contains(ev.target)){
|
||||
//console.log("onDragLeave, nodeContains", node, ev.target)
|
||||
}else{
|
||||
node.style.border = "";
|
||||
//console.log("onDragLeave, not contains", node, ev.target)
|
||||
}
|
||||
}
|
||||
|
||||
function drop(ev) {
|
||||
// shares dataTransfer with drag function
|
||||
ev.preventDefault();
|
||||
const data = ev.dataTransfer.getData("text");
|
||||
const tabList = ev.target.closest("div[role='tablist']");
|
||||
let node = ev.target.getAttribute("role") === "tab" ? ev.target : ev.target.closest('[role="tab"]');
|
||||
//console.log(tabList, data, node.nextSibling);
|
||||
node.style.border = "";
|
||||
document.getElementById(data).style.opacity = ev.dataTransfer.getData("opacity");
|
||||
for(let i = 0; i < tabList.children.length; i++){
|
||||
tabList.children[i].style.border = "";
|
||||
}
|
||||
//tabList.insertBefore(document.getElementById(data), node.nextSibling);
|
||||
//console.log("selected", data, "toLeftOf", node.id);
|
||||
return {"selected": data, "toLeftOf": node.id};
|
||||
}
|
||||
export function MythicTabLabel(props) {
|
||||
const {
|
||||
label,
|
||||
@@ -103,17 +63,22 @@ export function MythicTabLabel(props) {
|
||||
onEditTabDescription,
|
||||
getCallbackData,
|
||||
onDragTab,
|
||||
selectedIndex,
|
||||
highlight,
|
||||
newDataForTab,
|
||||
...other
|
||||
} = props;
|
||||
const onClick = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onCloseTab({ tabID: tabInfo.tabID, index: index });
|
||||
};
|
||||
const theme = useTheme();
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const handleContextClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if(contextMenuOptions && contextMenuOptions.length > 0){
|
||||
setOpenContextMenu(true);
|
||||
}
|
||||
@@ -129,51 +94,75 @@ export function MythicTabLabel(props) {
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
return (
|
||||
<Tab
|
||||
onDrop={(ev) => onDragTab(drop(ev))}
|
||||
onDragOver={allowDrop}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
draggable={onDragTab ? true : false}
|
||||
onDragStart={drag}
|
||||
label={
|
||||
<span onContextMenu={handleContextClick} style={{ display: 'inline-block', zIndex: 1}} ref={dropdownAnchorRef}>
|
||||
{label}
|
||||
<IconButton component='div' size='small' onClick={onClick} {...other}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Draggable draggableId={`callbacks-tab-${tabInfo.tabID}`} index={index} disableInteractiveElementBlocking={true}>
|
||||
{(provided, snapshot) => (
|
||||
<div ref={provided.innerRef}
|
||||
{...provided.draggableProps}>
|
||||
{(() => {
|
||||
const selected = selectedIndex === index || snapshot.isDragging;
|
||||
const tabAccent = tabInfo?.color || theme.palette.primary.main;
|
||||
const selectedTabSurface = theme.palette.background.paper;
|
||||
const selectedTabAccent = alpha(tabAccent, theme.palette.mode === "dark" ? 0.34 : 0.2);
|
||||
return (
|
||||
<Tab
|
||||
label={
|
||||
<span onContextMenu={handleContextClick} style={{}} ref={dropdownAnchorRef}>
|
||||
<Badge color="success" variant="dot" invisible={!highlight} >
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
{...other}
|
||||
{...provided.dragHandleProps}
|
||||
style={{
|
||||
padding: "0rem .5rem 0rem 0.5rem",
|
||||
margin: "0 0.125rem 0 0",
|
||||
minHeight: "2rem",
|
||||
borderRadius: `${theme.shape.borderRadius}px`,
|
||||
borderTop: selected ? `3px solid ${tabAccent}` : `1px solid ${theme.borderColor}`,
|
||||
borderRight: `1px solid ${theme.borderColor}`,
|
||||
borderBottom: `1px solid ${theme.borderColor}`,
|
||||
borderLeft: `1px solid ${theme.borderColor}`,
|
||||
backgroundColor: selected ? selectedTabSurface : theme.surfaces?.muted,
|
||||
backgroundImage: selected ?
|
||||
`linear-gradient(180deg, ${selectedTabAccent} 0%, ${selectedTabSurface} 82%)` :
|
||||
"none",
|
||||
color: theme.palette.text.primary,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition style={{zIndex: 40}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</span>
|
||||
}
|
||||
|
||||
{...a11yProps(index)}
|
||||
{...other}
|
||||
style={{padding: "0px 5px 0px 5px"}}
|
||||
/>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" className={"dropdownMenuColored bg-surface rounded border"}>
|
||||
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name + index}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={onClick}>Close Tab</MenuItem>
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
export function MythicSearchTabLabel(props) {
|
||||
@@ -182,10 +171,9 @@ export function MythicSearchTabLabel(props) {
|
||||
return (
|
||||
<Tab
|
||||
label={
|
||||
<span>
|
||||
{label}
|
||||
<br />
|
||||
<span className="mythic-search-tab-label items-center inline-flex gap-3 whitespace-nowrap">
|
||||
{iconComponent}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import {styled} from '@mui/material/styles';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
const MythicStyledTableCell = styled(TableCell, {name: "StyledTableRow", slot: "Wrapper"})({
|
||||
padding: "0 0 0 10px"
|
||||
});
|
||||
const MythicStyledTableCell = styled(TableCell, {})(({theme}) => ({
|
||||
borderBottom: `1px solid ${theme.table?.borderSoft || theme.borderColor}`,
|
||||
lineHeight: 1.35,
|
||||
padding: "6px 10px !important",
|
||||
verticalAlign: "middle",
|
||||
"&.MuiTableCell-head": {
|
||||
borderBottom: `1px solid ${theme.table?.border || theme.borderColor}`,
|
||||
padding: "6px 10px !important",
|
||||
},
|
||||
"&:first-of-type": {
|
||||
paddingLeft: "12px !important",
|
||||
},
|
||||
"&:last-of-type": {
|
||||
paddingRight: "12px !important",
|
||||
},
|
||||
}));
|
||||
export default MythicStyledTableCell;
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Pagination from '@mui/material/Pagination';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
export const MythicTablePagination = ({
|
||||
count,
|
||||
totalCount,
|
||||
fetchLimit,
|
||||
onChange,
|
||||
page,
|
||||
id,
|
||||
color = "primary",
|
||||
label = "Total Results",
|
||||
summary,
|
||||
className = "",
|
||||
containerStyle = {},
|
||||
paginationStyle = {},
|
||||
...paginationProps
|
||||
}) => {
|
||||
const pageCount = count ?? Math.ceil((totalCount || 0) / (fetchLimit || 1));
|
||||
const controlledPage = page === undefined ? {} : {page};
|
||||
|
||||
return (
|
||||
<Box className={`mythic-table-footer flex justify-center items-center flex-wrap gap-4 rounded bg-surface-muted border-subtle ${className} w-full`.trim()} id={id} style={containerStyle}>
|
||||
<Pagination
|
||||
count={pageCount}
|
||||
variant="outlined"
|
||||
color={color}
|
||||
boundaryCount={1}
|
||||
siblingCount={1}
|
||||
onChange={onChange}
|
||||
showFirstButton={true}
|
||||
showLastButton={true}
|
||||
style={paginationStyle}
|
||||
{...controlledPage}
|
||||
{...paginationProps}
|
||||
/>
|
||||
{summary !== undefined && summary !== null ? (
|
||||
<Typography className="mythic-table-total text-sm font-650 text-muted whitespace-nowrap">{summary}</Typography>
|
||||
) : totalCount !== undefined && (
|
||||
<Typography className="mythic-table-total text-sm font-650 text-muted whitespace-nowrap">{label}: {totalCount}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMythicClientPagination = ({
|
||||
items = [],
|
||||
resetKey = "",
|
||||
rowsPerPage = 25,
|
||||
}) => {
|
||||
const safeItems = React.useMemo(() => {
|
||||
return Array.isArray(items) ? items : [];
|
||||
}, [items]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const totalCount = safeItems.length;
|
||||
const pageCount = Math.max(1, Math.ceil(totalCount / rowsPerPage));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pageStart = (safePage - 1) * rowsPerPage;
|
||||
const pageData = React.useMemo(() => {
|
||||
return safeItems.slice(pageStart, pageStart + rowsPerPage);
|
||||
}, [pageStart, rowsPerPage, safeItems]);
|
||||
const pageEnd = totalCount === 0 ? 0 : Math.min(pageStart + rowsPerPage, totalCount);
|
||||
const pageStartDisplay = totalCount === 0 ? 0 : pageStart + 1;
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [resetKey, rowsPerPage, totalCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if(page > pageCount){
|
||||
setPage(pageCount);
|
||||
}
|
||||
}, [page, pageCount]);
|
||||
|
||||
const onChangePage = React.useCallback((event, nextPage) => {
|
||||
setPage(nextPage);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
onChangePage,
|
||||
page: safePage,
|
||||
pageCount,
|
||||
pageData,
|
||||
pageEnd,
|
||||
pageStart,
|
||||
pageStartDisplay,
|
||||
rangeLabel: `${pageStartDisplay}-${pageEnd} of ${totalCount}`,
|
||||
rowsPerPage,
|
||||
showPagination: totalCount > rowsPerPage,
|
||||
totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
export const MythicClientSideTablePagination = ({
|
||||
id,
|
||||
pagination,
|
||||
selectedCount,
|
||||
selectedLabel = "selected",
|
||||
...paginationProps
|
||||
}) => {
|
||||
if(!pagination?.showPagination){
|
||||
return null;
|
||||
}
|
||||
const summary = selectedCount === undefined || selectedCount === null ?
|
||||
pagination.rangeLabel :
|
||||
`${pagination.rangeLabel}, ${selectedCount} ${selectedLabel}`;
|
||||
return (
|
||||
<MythicTablePagination
|
||||
count={pagination.pageCount}
|
||||
id={id}
|
||||
onChange={pagination.onChangePage}
|
||||
page={pagination.page}
|
||||
summary={summary}
|
||||
{...paginationProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import MythicTextField from './MythicTextField';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
|
||||
export const MythicTableToolbar = ({children, className = "", style = {}, variant}) => {
|
||||
return (
|
||||
<Box className={`mythic-table-toolbar flex flex-none flex-wrap gap-4 w-full rounded bg-surface-muted border-subtle${variant ? ` mythic-table-toolbar-${variant}` : ""} ${className}`.trim()} style={style}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicTableToolbarGroup = ({children, grow = false, label, className = "", style = {}}) => {
|
||||
return (
|
||||
<Box className={`mythic-table-toolbar-group items-center flex flex-wrap gap-3 ${grow ? "mythic-table-toolbar-group-grow" : ""} ${className} max-w-full`.trim()} style={style}>
|
||||
{label &&
|
||||
<span className="mythic-table-toolbar-group-label text-2xs font-800 leading-100 text-muted">{label}</span>
|
||||
}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarSelect = ({children, className = "", style = {}, ...props}) => {
|
||||
return (
|
||||
<Select
|
||||
className={`mythic-toolbar-select ${className} w-full`.trim()}
|
||||
size="small"
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarMenuItem = MenuItem;
|
||||
|
||||
export const MythicSearchField = ({
|
||||
value,
|
||||
onChange,
|
||||
onEnter,
|
||||
onSearch,
|
||||
disabled = false,
|
||||
placeholder = "Search...",
|
||||
name = "Search",
|
||||
showLabel = false,
|
||||
autoFocus,
|
||||
inputProps = {},
|
||||
endAdornment = null,
|
||||
}) => {
|
||||
return (
|
||||
<MythicTextField
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
marginTop="0px"
|
||||
marginBottom="0px"
|
||||
showLabel={showLabel}
|
||||
onChange={onChange}
|
||||
onEnter={onEnter || onSearch}
|
||||
name={name}
|
||||
autoFocus={autoFocus}
|
||||
InputProps={{
|
||||
endAdornment: onSearch ? (
|
||||
<React.Fragment>
|
||||
{endAdornment}
|
||||
<MythicActionButton disabled={disabled} icon={<SearchIcon />} iconOnly onClick={onSearch} tone="primary" tooltip="Search" />
|
||||
</React.Fragment>
|
||||
) : endAdornment,
|
||||
style: {padding: 0},
|
||||
...inputProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarButton = ({children, tone, ...props}) => {
|
||||
return (
|
||||
<MythicActionButton tone={tone} {...props}>
|
||||
{children}
|
||||
</MythicActionButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarToggle = ({
|
||||
checked,
|
||||
onClick,
|
||||
label,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon,
|
||||
inactiveIcon,
|
||||
value = "toggle",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ToggleButton
|
||||
className={`mythic-toolbar-toggle border-subtle text-muted gap-3 rounded whitespace-nowrap ${className}`.trim()}
|
||||
value={value}
|
||||
selected={checked}
|
||||
onClick={onClick}
|
||||
size="small"
|
||||
{...props}
|
||||
>
|
||||
{checked ? activeIcon : inactiveIcon}
|
||||
<span>{checked ? (activeLabel || label) : (inactiveLabel || label)}</span>
|
||||
</ToggleButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {getReadableTextColor, isValidHexColor} from './MythicColorInput';
|
||||
import {MythicChip} from './MythicChip';
|
||||
|
||||
export const getTagReadableTextColor = (theme, color) => {
|
||||
return isValidHexColor(color) ? getReadableTextColor(color) : theme.palette.text.primary;
|
||||
}
|
||||
|
||||
export const TagTypeChip = ({tagtype, label, sx={}}) => {
|
||||
const theme = useTheme();
|
||||
const color = tagtype?.color || "";
|
||||
const textColor = getTagReadableTextColor(theme, color);
|
||||
return (
|
||||
<MythicChip
|
||||
color={color}
|
||||
label={label || tagtype?.name || "Tag"}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: color || "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: theme.borderColor,
|
||||
color: textColor,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import React, {useState} from 'react';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -9,35 +9,36 @@ import 'ace-builds/src-noconflict/theme-monokai';
|
||||
import 'ace-builds/src-noconflict/theme-xcode';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
export function MythicTextEditDialog(props) {
|
||||
const theme = useTheme();
|
||||
|
||||
export function PreviewFileStringDialog(props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.filename}'s first 512KB</DialogTitle>
|
||||
<DialogContent style={{height: "calc(80vh)", margin: 0, padding: 0}}>
|
||||
<AceEditor
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true} style={{padding: 0}}>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
onChange={() => {}}
|
||||
fontSize={14}
|
||||
height="inherit"
|
||||
showGutter={true}
|
||||
highlightActiveLine={true}
|
||||
value={atob(props.contents)}
|
||||
value={props.value}
|
||||
showPrintMargin={false}
|
||||
wrapEnabled={true}
|
||||
focus={true}
|
||||
width={"100%"}
|
||||
minLines={2}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false
|
||||
useWorker: false,
|
||||
tabSize: 4
|
||||
}}/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={props.onClose} color="primary">
|
||||
<MythicActionButton colorMode="always" variant="contained" onClick={props.onClose} tone="primary">
|
||||
Close
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,149 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import {TextField} from '@mui/material';
|
||||
import withStyles from '@mui/styles/withStyles';
|
||||
const ValidationTextField = withStyles({
|
||||
root: {
|
||||
'& input:valid + fieldset': {
|
||||
borderColor: 'grey',
|
||||
borderWidth: 1,
|
||||
import {useDebounce} from "../utilities/useDebounce";
|
||||
const PREFIX = 'MythicTextField';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}Div-root`,
|
||||
textFieldRoot: `${PREFIX}-root`
|
||||
};
|
||||
|
||||
const Root = styled('div')(({theme}) => ({
|
||||
[`&.${classes.root}`]: {
|
||||
|
||||
},
|
||||
}));
|
||||
|
||||
const ValidationTextField = styled(TextField)(({theme}) => ({
|
||||
[`&.${classes.textFieldRoot}`]: {
|
||||
'& .MuiInputBase-root': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
'& fieldset': {
|
||||
borderColor: theme.borderColor,
|
||||
borderWidth: 1,
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: theme.palette.text.secondary,
|
||||
},
|
||||
'& .Mui-focused fieldset': {
|
||||
borderColor: `${theme.palette.primary.main} !important`,
|
||||
borderWidth: '1px !important',
|
||||
},
|
||||
'& input:invalid + fieldset': {
|
||||
borderColor: 'red',
|
||||
borderWidth: 2,
|
||||
borderColor: theme.palette.error.main,
|
||||
borderWidth: 2,
|
||||
},
|
||||
'& input:valid:focus + fieldset': {
|
||||
borderLeftWidth: 6,
|
||||
padding: '4px !important', // override inline-style
|
||||
borderLeftWidth: 2,
|
||||
padding: '4px !important', // override inline-style
|
||||
},
|
||||
},
|
||||
})(TextField);
|
||||
'& textarea:focus + textarea + fieldset': {
|
||||
borderLeftWidth: 2,
|
||||
//padding: '4px !important', // override inline-style
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
class MythicTextField extends React.Component {
|
||||
|
||||
static propTypes = {
|
||||
placeholder: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
validate: PropTypes.func,
|
||||
width: PropTypes.number,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
requiredValue: PropTypes.bool,
|
||||
type: PropTypes.string,
|
||||
onEnter: PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
autoComplete: PropTypes.bool,
|
||||
showLabel: PropTypes.bool
|
||||
}
|
||||
onChange = evt => {
|
||||
const name = this.props.name;
|
||||
const value = evt.target.value;
|
||||
const error = this.props.validate ? this.props.validate(value) : false;
|
||||
this.props.onChange(name, value, error);
|
||||
}
|
||||
checkError = () => {
|
||||
return this.props.validate ? this.props.validate(this.props.value) : false
|
||||
}
|
||||
onKeyPress = (event) => {
|
||||
if(event.key === "Enter"){
|
||||
if(this.props.onEnter !== undefined){
|
||||
this.props.onEnter(event);
|
||||
}
|
||||
const MythicTextField = ({
|
||||
placeholder,
|
||||
name,
|
||||
validate,
|
||||
width,
|
||||
onChange,
|
||||
requiredValue=false,
|
||||
type = "text",
|
||||
onEnter,
|
||||
autoFocus,
|
||||
autoComplete=false,
|
||||
showLabel = true,
|
||||
variant = "outlined",
|
||||
inline,
|
||||
marginBottom = "5px",
|
||||
value,
|
||||
disabled = false,
|
||||
marginTop = "5px",
|
||||
InputProps = {},
|
||||
inputProps = {},
|
||||
inputLabelProps = {},
|
||||
multiline = false,
|
||||
maxRows = 10,
|
||||
errorText = "",
|
||||
helperText = "",
|
||||
debounceDelay = 100,
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = React.useState({value: value, event: null});
|
||||
const [localError, setLocalError] = React.useState(false);
|
||||
const debouncedLocalInput = useDebounce(localValue, debounceDelay);
|
||||
React.useEffect( () => {
|
||||
const error = validate ? validate(debouncedLocalInput.value) : false;
|
||||
onChange(name, debouncedLocalInput.value, error, debouncedLocalInput.event);
|
||||
}, [debouncedLocalInput]);
|
||||
React.useEffect( () => {
|
||||
setLocalValue({value: value, event: null});
|
||||
}, [value]);
|
||||
const handleChange = (evt) => {
|
||||
const newValue = evt.target.value;
|
||||
// Update local state immediately for responsive UI
|
||||
setLocalValue({value: newValue, event: evt});
|
||||
setLocalError(validate ? validate(newValue) : false);
|
||||
};
|
||||
const onKeyPress = (event) => {
|
||||
if(event.key === "Enter") {
|
||||
if(event.shiftKey){
|
||||
handleChange(event);
|
||||
return;
|
||||
}
|
||||
if (onEnter !== undefined) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onEnter(event);
|
||||
}
|
||||
}else{
|
||||
this.onChange(event);
|
||||
handleChange(event);
|
||||
}
|
||||
}
|
||||
render(){
|
||||
return (
|
||||
<div style={{width: this.props.width ? this.props.width + "rem" : "100%"}}>
|
||||
<ValidationTextField
|
||||
fullWidth={true}
|
||||
placeholder={this.props.placeholder}
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}
|
||||
onKeyPress={this.onKeyPress}
|
||||
label={this.props.showLabel === undefined ? this.props.name : this.props.showLabel ? this.props.name : undefined}
|
||||
autoFocus={this.props.autoFocus ? true : false}
|
||||
variant="outlined"
|
||||
data-lpignore={true}
|
||||
autoComplete={this.props.autoComplete === undefined ? "off" : (this.props.autoComplete ? "on" : "off")}
|
||||
disabled={this.props.disabled === undefined ? false : this.props.disabled}
|
||||
required={this.props.requiredValue ? this.props.requiredValue : false}
|
||||
InputLabelProps={this.props.inputLabelProps}
|
||||
multiline={this.props.multiline ? this.props.multiline : false}
|
||||
maxRows={this.props.maxRows}
|
||||
error={this.checkError()}
|
||||
type={this.props.type === undefined ? "text" : this.props.type}
|
||||
InputProps={this.props.InputProps}
|
||||
helperText={this.checkError() ? this.props.errorText : this.props.helperText}
|
||||
style={{
|
||||
padding:0,
|
||||
marginBottom: this.props.marginBottom ? this.props.marginBottom : "10px"
|
||||
}}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const resolvedAutoComplete = autoComplete === undefined ? "new-password" : (
|
||||
typeof autoComplete === "string" ? autoComplete : (autoComplete ? "on" : "off")
|
||||
);
|
||||
|
||||
return (
|
||||
<Root style={{width: width ? width + "rem" : "100%", display: inline ? "inline-block": "",}}>
|
||||
<ValidationTextField
|
||||
fullWidth={true}
|
||||
placeholder={placeholder}
|
||||
value={localValue.value}
|
||||
onChange={handleChange}
|
||||
color={"secondary"}
|
||||
onKeyDown={onKeyPress}
|
||||
label={showLabel ? name : undefined}
|
||||
autoFocus={autoFocus}
|
||||
variant={variant}
|
||||
data-lpignore={true}
|
||||
autoComplete={resolvedAutoComplete}
|
||||
disabled={disabled}
|
||||
required={requiredValue}
|
||||
InputLabelProps={inputLabelProps}
|
||||
multiline={multiline}
|
||||
maxRows={maxRows}
|
||||
error={localError}
|
||||
type={type}
|
||||
onWheel={ event => event.target.blur() }
|
||||
InputProps={{...InputProps, spellCheck: false}}
|
||||
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
|
||||
helperText={localError ? errorText : helperText}
|
||||
style={{
|
||||
padding:0,
|
||||
marginBottom: marginBottom ? marginBottom : "5px",
|
||||
marginTop: marginTop ? marginTop: "5px",
|
||||
display: inline ? "inline-block": "",
|
||||
}}
|
||||
classes={{
|
||||
root: classes.textFieldRoot
|
||||
}} />
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
export default MythicTextField;
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { MythicStyledTooltip } from './MythicStyledTooltip';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.extend(utc);
|
||||
|
||||
const formatDiff = (ms) => {
|
||||
if (ms < 0) ms = 0;
|
||||
const s = Math.trunc(ms / 1000) % 60;
|
||||
const m = Math.trunc(ms / 60000) % 60;
|
||||
const h = Math.trunc(ms / 3600000) % 24;
|
||||
const d = Math.trunc(ms / 86400000);
|
||||
return (d ? d + "d " : "") + (h ? h + "h " : "") + (m ? m + "m " : "") + s + "s";
|
||||
};
|
||||
|
||||
export const MythicTimestamp = ({
|
||||
children,
|
||||
fromNow = false,
|
||||
duration = false,
|
||||
endTime,
|
||||
interval = 0,
|
||||
withTitle = false,
|
||||
titleFormat = "YYYY-MM-DD HH:mm:ss",
|
||||
serverSkew = 0,
|
||||
}) => {
|
||||
const [now, setNow] = useState(Date.now);
|
||||
useEffect(() => {
|
||||
if (!interval) return;
|
||||
const id = setInterval(() => setNow(Date.now()), interval);
|
||||
return () => clearInterval(id);
|
||||
}, [interval]);
|
||||
|
||||
// Mythic's server timestamps without an explicit offset represent UTC.
|
||||
const t = dayjs.utc(children);
|
||||
const compute = () => {
|
||||
if (!t.isValid()) return "";
|
||||
const serverNow = dayjs(now).add(serverSkew, 'ms');
|
||||
if (duration) {
|
||||
const end = endTime != null ? dayjs.utc(endTime) : serverNow;
|
||||
if (!end.isValid()) return "";
|
||||
return formatDiff(end.diff(t));
|
||||
}
|
||||
if (fromNow) {
|
||||
const seconds = Math.round(Math.abs(serverNow.diff(t)) / 1000);
|
||||
if (seconds < 60) return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
|
||||
return t.from(serverNow, true);
|
||||
}
|
||||
return t.local().format(titleFormat);
|
||||
};
|
||||
const display = compute();
|
||||
|
||||
const title = withTitle && t.isValid() ? t.local().format(titleFormat) : "";
|
||||
if (withTitle) return (
|
||||
<MythicStyledTooltip title={title}>
|
||||
<span>{display}</span>
|
||||
</MythicStyledTooltip>
|
||||
);
|
||||
return <span>{display}</span>;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, {act} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {MythicTimestamp} from './MythicTimestamp';
|
||||
|
||||
jest.mock('./MythicStyledTooltip', () => ({
|
||||
MythicStyledTooltip: ({children, title}) => <div title={title}>{children}</div>,
|
||||
}));
|
||||
|
||||
describe('MythicTimestamp', () => {
|
||||
let container;
|
||||
let root;
|
||||
const start = '2026-01-15T12:00:00.000000';
|
||||
|
||||
const render = (props) => {
|
||||
act(() => root.render(<MythicTimestamp {...props} />));
|
||||
return container.textContent;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2026-01-15T12:00:10Z'));
|
||||
global.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement('div');
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
jest.useRealTimers();
|
||||
delete global.IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
it('uses UTC for timezone-less end timestamps and respects explicit offsets', () => {
|
||||
const props = {children: start + 'Z', duration: true};
|
||||
expect(render({...props, endTime: '2026-01-15T12:01:00.000000'})).toBe('1m 0s');
|
||||
expect(render({...props, endTime: '2026-01-15T04:01:00-08:00'})).toBe('1m 0s');
|
||||
});
|
||||
|
||||
it('recomputes when only the end time changes', () => {
|
||||
const props = {children: start + 'Z', duration: true};
|
||||
render({...props, endTime: '2026-01-15T12:01:00'});
|
||||
expect(render({...props, endTime: '2026-01-15T12:02:00'})).toBe('2m 0s');
|
||||
});
|
||||
|
||||
it('uses updated server skew immediately and on subsequent ticks', () => {
|
||||
const props = {children: start + 'Z', fromNow: true, interval: 1000};
|
||||
expect(render(props)).toBe('10 seconds');
|
||||
expect(render({...props, serverSkew: 3600000})).toBe('an hour');
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(container.textContent).toBe('an hour');
|
||||
});
|
||||
|
||||
it('keeps numeric seconds through 59 seconds and updates every tick', () => {
|
||||
const props = {children: start + 'Z', fromNow: true, interval: 1000};
|
||||
expect(render(props)).toBe('10 seconds');
|
||||
act(() => jest.advanceTimersByTime(49000));
|
||||
expect(container.textContent).toBe('59 seconds');
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(container.textContent).toBe('a minute');
|
||||
});
|
||||
|
||||
it('shows zero seconds for fresh check-ins, including after the age resets', () => {
|
||||
const props = {fromNow: true, interval: 1000};
|
||||
expect(render({...props, children: new Date(Date.now() - 200).toISOString()})).toBe('0 seconds');
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(container.textContent).toBe('1 second');
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(container.textContent).toBe('2 seconds');
|
||||
expect(render({...props, children: new Date(Date.now()).toISOString()})).toBe('0 seconds');
|
||||
act(() => jest.advanceTimersByTime(1000));
|
||||
expect(container.textContent).toBe('1 second');
|
||||
});
|
||||
|
||||
it('recomputes when display mode or format changes', () => {
|
||||
const props = {children: start + 'Z'};
|
||||
expect(render({...props, fromNow: true})).toBe('10 seconds');
|
||||
expect(render({...props, titleFormat: 'YYYY'})).toBe('2026');
|
||||
expect(render({...props, titleFormat: 'MM'})).toBe('01');
|
||||
});
|
||||
|
||||
it('renders invalid timestamps as empty text and clears timers on unmount', () => {
|
||||
expect(render({children: 'invalid', fromNow: true, interval: 1000})).toBe('');
|
||||
expect(jest.getTimerCount()).toBe(1);
|
||||
render({children: start, duration: true, endTime: 'invalid'});
|
||||
expect(container.textContent).toBe('');
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,220 +1,244 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: 'auto',
|
||||
paddingBottom: "10px"
|
||||
},
|
||||
paper: {
|
||||
width: 200,
|
||||
height: 230,
|
||||
overflow: 'auto',
|
||||
},
|
||||
button: {
|
||||
margin: theme.spacing(0.5, 0),
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: "rgb(100, 170, 204)",
|
||||
border: "2px solid rgba(100, 170, 204)"
|
||||
}
|
||||
const PREFIX = 'MythicTransferList';
|
||||
|
||||
export const classes = {
|
||||
divider: `${PREFIX}-divider`
|
||||
};
|
||||
|
||||
export const StyledDivider = styled(Divider)((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`&.${classes.divider}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: `2px solid ${theme.palette.primary.main}`
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
|
||||
export function MythicTransferListDialog(props) {
|
||||
const classes = useStyles();
|
||||
const [checked, setChecked] = React.useState([]);
|
||||
const [left, setLeft] = React.useState([]);
|
||||
const [right, setRight] = React.useState([]);
|
||||
const [leftTitle, setLeftTitle] = React.useState("");
|
||||
const [rightTitle, setRightTitle] = React.useState("");
|
||||
const leftChecked = intersection(checked, left);
|
||||
const rightChecked = intersection(checked, right);
|
||||
function not(a, b) {
|
||||
if(props.itemKey){
|
||||
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) === undefined)
|
||||
}
|
||||
return a.filter((value) => b.indexOf(value) === -1);
|
||||
}
|
||||
|
||||
function intersection(a, b) {
|
||||
if(props.itemKey){
|
||||
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) !== undefined)
|
||||
}
|
||||
return a.filter((value) => b.indexOf(value) !== -1);
|
||||
}
|
||||
const handleToggle = (value) => () => {
|
||||
let currentIndex = -1;
|
||||
if(props.itemKey){
|
||||
currentIndex = checked.findIndex( (element) => element[props.itemKey] === value[props.itemKey]);
|
||||
}else{
|
||||
currentIndex = checked.indexOf(value);
|
||||
}
|
||||
|
||||
const newChecked = [...checked];
|
||||
|
||||
if (currentIndex === -1) {
|
||||
newChecked.push(value);
|
||||
} else {
|
||||
newChecked.splice(currentIndex, 1);
|
||||
}
|
||||
|
||||
setChecked(newChecked);
|
||||
};
|
||||
|
||||
const handleAllRight = () => {
|
||||
setRight(right.concat(left));
|
||||
setLeft([]);
|
||||
};
|
||||
|
||||
const handleCheckedRight = () => {
|
||||
setRight(right.concat(leftChecked));
|
||||
setLeft(not(left, leftChecked));
|
||||
setChecked(not(checked, leftChecked));
|
||||
};
|
||||
|
||||
const handleCheckedLeft = () => {
|
||||
setLeft(left.concat(rightChecked));
|
||||
setRight(not(right, rightChecked));
|
||||
setChecked(not(checked, rightChecked));
|
||||
};
|
||||
|
||||
const handleAllLeft = () => {
|
||||
setLeft(left.concat(right));
|
||||
setRight([]);
|
||||
};
|
||||
useEffect( () => {
|
||||
const left = props.left.reduce( (prev, cur) => {
|
||||
if(props.itemKey === undefined){
|
||||
if(props.right.includes(cur)){
|
||||
return [...prev];
|
||||
}
|
||||
return [...prev, cur];
|
||||
}else{
|
||||
if(props.right.find( element => element[props.itemKey] === cur[props.itemKey])){
|
||||
return [...prev]
|
||||
}
|
||||
return [...prev, cur];
|
||||
}
|
||||
|
||||
}, [])
|
||||
setLeft(left);
|
||||
setRight(props.right);
|
||||
setLeftTitle(props.leftTitle);
|
||||
setRightTitle(props.rightTitle);
|
||||
}, [props.left, props.right, props.leftTitle, props.rightTitle, props.itemKey]);
|
||||
const customList = (title, items) => (
|
||||
<Paper className={classes.paper} style={{width:"100%", height: "calc(50vh)"}}>
|
||||
<Card>
|
||||
<CardHeader
|
||||
className={classes.cardHeader}
|
||||
title={title}
|
||||
/>
|
||||
<Divider classes={{root: classes.divider}}/>
|
||||
<List dense component="div" role="list" style={{padding:0}}>
|
||||
{items.map((valueObj) => {
|
||||
const value = props.itemKey === undefined ? valueObj : valueObj[props.itemKey];
|
||||
const labelId = `transfer-list-item-${value}-label`;
|
||||
return (
|
||||
<ListItem style={{padding:0}} key={value} role="listitem" button onClick={handleToggle(valueObj)}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={props.itemKey === undefined ? checked.indexOf(value) !== -1 : checked.findIndex( (element) => element[props.itemKey] === value) !== -1}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={value} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
<ListItem />
|
||||
</List>
|
||||
</Card>
|
||||
</Paper>
|
||||
const CustomListElement = ({value, onClick, itemKey}) => {
|
||||
const displayValue = itemKey ? value[itemKey] : value.value;
|
||||
const labelId = `transfer-list-item-${displayValue}-label`;
|
||||
return (
|
||||
<ListItem style={{padding:0}} key={displayValue} role="listitem" button onClick={() => onClick(value)}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={value.checked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={displayValue} />
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
const CustomList = ({title, items, left, onClick, itemKey}) => {
|
||||
|
||||
return (
|
||||
<div className="mythic-transfer-list flex flex-column min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
|
||||
<div className="mythic-transfer-list-header bg-table-header text-xs font-700 leading-125 border-b text-primary">{title}</div>
|
||||
<div className="mythic-transfer-list-body flex-fill overflow-auto">
|
||||
<List dense component="div" role="list" style={{padding:0, width: "100%"}}>
|
||||
{items.map((value, index) => (
|
||||
<div key={index}>
|
||||
{
|
||||
left && value.left &&
|
||||
<CustomListElement itemKey={itemKey} value={value} onClick={onClick}/>
|
||||
}
|
||||
{
|
||||
!left && value.right &&
|
||||
<CustomListElement itemKey={itemKey} value={value} onClick={onClick} />
|
||||
}
|
||||
</div>
|
||||
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData, parentRightData, itemKey}) => {
|
||||
|
||||
const [data, setData] = React.useState(initialData);
|
||||
const handleToggle = (value) => {
|
||||
const updatedData = data.map(d => {
|
||||
const key = itemKey ? itemKey : "value";
|
||||
if(value[key] === d[key]){
|
||||
return {...d, checked: !d.checked}
|
||||
} else {
|
||||
return {...d}
|
||||
}
|
||||
});
|
||||
setData(updatedData);
|
||||
};
|
||||
const handleAllRight = () => {
|
||||
const updatedData = data.map( d => {
|
||||
return {...d, checked: false, left: false, right: true}
|
||||
})
|
||||
setData(updatedData);
|
||||
};
|
||||
const handleCheckedRight = () => {
|
||||
const updatedData = data.map( d => {
|
||||
if(d.checked && d.left){
|
||||
return {...d, checked: false, left: false, right: true};
|
||||
} else {
|
||||
return {...d};
|
||||
}
|
||||
})
|
||||
setData(updatedData);
|
||||
};
|
||||
const handleCheckedLeft = () => {
|
||||
const updatedData = data.map( d => {
|
||||
if(d.checked && d.right){
|
||||
return {...d, checked: false, left: true, right: false};
|
||||
} else {
|
||||
return {...d};
|
||||
}
|
||||
})
|
||||
setData(updatedData);
|
||||
};
|
||||
const handleAllLeft =() => {
|
||||
const updatedData = data.map( d => {
|
||||
return {...d, checked: false, left: true, right: false}
|
||||
})
|
||||
setData(updatedData);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
parentLeftData.current = data.reduce( (prev, cur) => {
|
||||
if(cur.left){return [...prev, cur]}
|
||||
return [...prev];
|
||||
}, []);
|
||||
parentRightData.current = data.reduce( (prev, cur) => {
|
||||
if(cur.right){return [...prev, cur]}
|
||||
return [...prev];
|
||||
}, []);
|
||||
}, [data]);
|
||||
React.useEffect( () => {
|
||||
setData(initialData);
|
||||
}, [initialData]);
|
||||
return (
|
||||
<div style={{display: "flex", flexDirection: "row", overflowY: "auto", flexGrow: 1, minHeight: 0}}>
|
||||
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<CustomList title={leftTitle} left={true} items={data} onClick={handleToggle} itemKey={itemKey} />
|
||||
</div>
|
||||
<div className="mythic-transfer-controls py-0 px-4 items-center flex flex-column gap-6 justify-center">
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAllRight}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={data.filter( x => x.checked && x.left).length === 0}
|
||||
onClick={handleCheckedRight}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={data.filter( x => x.checked && x.right).length === 0}
|
||||
onClick={handleCheckedLeft}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAllLeft}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</MythicActionButton>
|
||||
|
||||
</div>
|
||||
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", overflowY: "auto", flexDirection: "column" }}>
|
||||
<CustomList title={rightTitle} left={false} items={data} onClick={handleToggle} itemKey={itemKey} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function MythicTransferListDialog(props) {
|
||||
const [initialData, setInitialData] = React.useState([]);
|
||||
const leftData = React.useRef([]);
|
||||
const rightData = React.useRef([]);
|
||||
const itemKey = React.useRef(props.itemKey);
|
||||
const leftTitle = React.useRef(props.leftTitle);
|
||||
const rightTitle = React.useRef(props.rightTitle);
|
||||
|
||||
useEffect( () => {
|
||||
const leftData = props.left.map(c => {
|
||||
if(props.itemKey){
|
||||
return {...c, checked: false, left: true, right: false};
|
||||
}
|
||||
return {value: c, checked: false, left: true, right: false};
|
||||
})
|
||||
const rightData = props.right.map(c => {
|
||||
if(props.itemKey){
|
||||
return {...c, checked: false, left: false, right: true};
|
||||
}
|
||||
return {value: c, checked: false, left: false, right: true};
|
||||
})
|
||||
setInitialData([...leftData, ...rightData]);
|
||||
}, [props.left, props.right, props.itemKey]);
|
||||
const setFinalTags = () => {
|
||||
props.onSubmit({left, right});
|
||||
const finalLeft = leftData.current.map( c => {
|
||||
const key = itemKey.current ? itemKey.current : "value";
|
||||
return c[key];
|
||||
});
|
||||
const finalRight = rightData.current.map( c => {
|
||||
const key = itemKey.current ? itemKey.current : "value";
|
||||
return c[key];
|
||||
})
|
||||
props.onSubmit({left: finalLeft, right: finalRight});
|
||||
props.onClose();
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<>
|
||||
<DialogTitle id="form-dialog-title">{props.dialogTitle}</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<Grid container spacing={0} justifyContent="center" alignItems="center" className={classes.root}>
|
||||
<Grid item xs={5} style={{paddingLeft: 0, marginLeft: 0}}>{customList(leftTitle, left)}</Grid>
|
||||
<Grid item>
|
||||
<Grid container direction="column" alignItems="center">
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
disabled={leftChecked.length === 0}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
disabled={rightChecked.length === 0}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllLeft}
|
||||
disabled={right.length === 0}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={5}>{customList(rightTitle, right)}</Grid>
|
||||
</Grid>
|
||||
<CustomTransferList initialData={initialData}
|
||||
parentLeftData={leftData}
|
||||
parentRightData={rightData}
|
||||
rightTitle={rightTitle.current}
|
||||
leftTitle={leftTitle.current}
|
||||
itemKey={itemKey.current}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={setFinalTags} variant="contained" color="success">
|
||||
</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={setFinalTags} tone="success" variant="contained">
|
||||
Submit
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import {MythicActionButton} from './MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
import 'ace-builds/src-noconflict/theme-xcode';
|
||||
import {ResponseDisplayMedia} from "../pages/Callbacks/ResponseDisplayMedia";
|
||||
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
|
||||
|
||||
export function PreviewFileMediaDialog({agent_file_id, filename, onClose, editable=false}) {
|
||||
const onClick = (e) => {
|
||||
if(e){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MythicDraggableDialogTitle>
|
||||
Previewing <b>{filename}</b>
|
||||
</MythicDraggableDialogTitle>
|
||||
<DialogContent onClick={onClick} style={{height: "calc(80vh)", margin: 0, padding: 0}}>
|
||||
<ResponseDisplayMedia media={{agent_file_id, filename, editable}} expand={true} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MythicActionButton colorMode="always" variant="contained" onClick={onClose} tone="primary">
|
||||
Close
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {milisecondsToString, useInterval } from './utilities/Time';
|
||||
import {JWTTimeLeft, GetNewToken} from '../index';
|
||||
import {FailedRefresh} from '../cache';
|
||||
import { Button } from '@mui/material';
|
||||
import {MythicActionButton} from './MythicComponents/MythicActionButton';
|
||||
import { snackActions } from './utilities/Snackbar';
|
||||
|
||||
|
||||
@@ -43,14 +43,13 @@ export function RefreshTokenDialog(props) {
|
||||
Your Session will expire in {timeLeft}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
|
||||
Logout
|
||||
</Button>
|
||||
<Button onClick={onCommitSubmit} variant="contained" color="success">
|
||||
</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={onCommitSubmit} tone="success" variant="contained">
|
||||
Extend
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import InboxIcon from '@mui/icons-material/MoveToInbox';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import { meState, menuOpen, FailedRefresh } from '../cache';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { TopAppBarNotifications } from './TopAppBarNotifications';
|
||||
import { EventFeedNotifications } from './EventFeedNotifications';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import HelpIcon from '@mui/icons-material/Help';
|
||||
import PhoneCallbackIcon from '@mui/icons-material/PhoneCallback';
|
||||
import {ReactComponent as ReactLogo} from './mythic_red_small.svg';
|
||||
import HomeIcon from '@mui/icons-material/Home';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import LayersIcon from '@mui/icons-material/Layers';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import PostAddIcon from '@mui/icons-material/PostAdd';
|
||||
import SupervisorAccountIcon from '@mui/icons-material/SupervisorAccount';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { Typography } from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import BarChartIcon from '@mui/icons-material/BarChart';
|
||||
import HeadsetIcon from '@mui/icons-material/Headset';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {faBiohazard, faFlagCheckered} from '@fortawesome/free-solid-svg-icons';
|
||||
import AttachmentIcon from '@mui/icons-material/Attachment';
|
||||
import FingerprintIcon from '@mui/icons-material/Fingerprint';
|
||||
import {faSocks} from '@fortawesome/free-solid-svg-icons';
|
||||
import {faCamera} from '@fortawesome/free-solid-svg-icons';
|
||||
import {mythicVersion, mythicUIVersion} from '../index';
|
||||
import {MythicStyledTooltip} from './MythicComponents/MythicStyledTooltip';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
import ThumbDownOffAltIcon from '@mui/icons-material/ThumbDownOffAlt';
|
||||
import { MythicDialog } from './MythicComponents/MythicDialog';
|
||||
import {MythicFeedbackDialog} from './MythicComponents/MythicFeedbackDialog';
|
||||
import LocalOfferIcon from '@mui/icons-material/LocalOffer';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
width: "100%",
|
||||
},
|
||||
title: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
hide: {
|
||||
display: 'none',
|
||||
},
|
||||
drawer: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
},
|
||||
drawerPaper: {
|
||||
width: drawerWidth,
|
||||
},
|
||||
drawerHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(0, 1),
|
||||
// necessary for content to be below app bar
|
||||
...theme.mixins.toolbar,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
listSubHeader: {
|
||||
backgroundColor: theme.palette.listSubHeader.default
|
||||
},
|
||||
appBar: {
|
||||
width: "100%",
|
||||
backgroundColor: theme.topAppBarColor,
|
||||
transition: theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
},
|
||||
appBarShift: {
|
||||
maxWidth: `calc(100% - ${drawerWidth}px)`,
|
||||
marginLeft: drawerWidth,
|
||||
transition: theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
nested: {
|
||||
paddingLeft: theme.spacing(4),
|
||||
},
|
||||
mythicElement: {},
|
||||
}));
|
||||
|
||||
export function TopAppBar(props) {
|
||||
const theme = useTheme();
|
||||
const classes = useStyles(theme);
|
||||
const feedbackRef = React.useRef(null);
|
||||
const settingsRef = React.useRef(null);
|
||||
const documentationRef = React.useRef(null);
|
||||
const [anchorEl, setAnchorEl] = React.useState(false);
|
||||
const [documentationAnchorEl, setDocumentationAnchorEl] = React.useState(false);
|
||||
const me = props.me;
|
||||
const isOpen = useReactiveVar(menuOpen);
|
||||
const [openGlobal, setOpenGlobal] = React.useState(false);
|
||||
const [openCreate, setOpenCreate] = React.useState(false);
|
||||
const [openOperations, setOpenOperations] = React.useState(false);
|
||||
const [openData, setOpenData] = React.useState(false);
|
||||
const [openFeedbackForm, setOpenFeedbackForm] = React.useState(false);
|
||||
const handleDrawerOpen = () => {
|
||||
menuOpen(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
menuOpen(false);
|
||||
};
|
||||
|
||||
const handleMenu = (event) => {
|
||||
setAnchorEl(true);
|
||||
};
|
||||
|
||||
const handleClose = (evt) => {
|
||||
setAnchorEl(false);
|
||||
};
|
||||
const handleDocumentationMenu = (event) => {
|
||||
setDocumentationAnchorEl(true);
|
||||
};
|
||||
const handleDocumentationClose = (evt) => {
|
||||
setDocumentationAnchorEl(false);
|
||||
};
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setOpenGlobal(!openGlobal);
|
||||
}
|
||||
const handleToggleCreate = () => {
|
||||
setOpenCreate(!openCreate);
|
||||
}
|
||||
const handleToggleOperations = () => {
|
||||
setOpenOperations(!openOperations);
|
||||
}
|
||||
const handleToggleData = () => {
|
||||
setOpenData(!openData);
|
||||
}
|
||||
const handleLogout = () => {
|
||||
menuOpen(false);
|
||||
console.log("clicked logout, calling FailedRefresh");
|
||||
FailedRefresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment >
|
||||
|
||||
{me?.user?.current_operation_id ? (<EventFeedNotifications me={me} />) : (null) }
|
||||
<AppBar className={clsx(classes.appBar, {[classes.appBarShift]: isOpen,})}>
|
||||
|
||||
{ me?.loggedIn ? (
|
||||
<Toolbar variant="dense" >
|
||||
<IconButton
|
||||
edge="start"
|
||||
className={clsx(classes.menuButton, isOpen && classes.hide)}
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
onClick={handleDrawerOpen}
|
||||
size="large">
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<div style={{width: "100%"}}>
|
||||
<MythicStyledTooltip title="C2 Profiles and Payload Types">
|
||||
<IconButton component={Link} to='/new/payloadtypes' color="inherit" size="large">
|
||||
<HeadsetIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Payloads">
|
||||
<IconButton component={Link} to='/new/payloads' color="inherit" size="medium">
|
||||
<FontAwesomeIcon icon={faBiohazard} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Search Operation">
|
||||
<IconButton component={Link} to='/new/search' color="inherit" size="large">
|
||||
<SearchIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Files">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Filename&tab=files&location=Downloads'
|
||||
color="inherit"
|
||||
size="large">
|
||||
<AttachmentIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Artifacts">
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Artifact&tab=artifacts'
|
||||
color="inherit"
|
||||
size="large">
|
||||
<FingerprintIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Proxies">
|
||||
<IconButton component={Link} to='/new/search?tab=socks' color="inherit" size="medium">
|
||||
<FontAwesomeIcon icon={faSocks} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Screenshots" arrow >
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Filename&tab=files&location=Screenshots'
|
||||
color="inherit"
|
||||
size="medium">
|
||||
<FontAwesomeIcon icon={faCamera} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Credentials" arrow >
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Account&tab=credentials'
|
||||
color="inherit"
|
||||
size="large">
|
||||
<VpnKeyIcon className="mythicElement" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Active Callbacks">
|
||||
<IconButton component={Link} to='/new/callbacks' color="inherit" size="large">
|
||||
<PhoneCallbackIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Reporting" >
|
||||
<IconButton component={Link} to='/new/reporting' color="inherit" size="medium">
|
||||
<FontAwesomeIcon icon={faFlagCheckered} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="MITRE ATT&CK" >
|
||||
<IconButton component={Link} to='/new/mitre' color="inherit" size="medium">
|
||||
<TableChartIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Operation Tags" >
|
||||
<IconButton component={Link} to='/new/tagtypes' color="inherit" size="medium">
|
||||
<LocalOfferIcon className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<Link style={{display: "inline-flex", alignItems: "center", paddingRight: "10px", color: "white", textDecoration: "none"}} to="/new/operations">
|
||||
{me?.user?.current_operation || "No Operation Set"}
|
||||
</Link>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
nodeRef={settingsRef}
|
||||
anchorEl={()=>settingsRef.current}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
getContentAnchorEl={null}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={anchorEl}
|
||||
onClose={handleClose}
|
||||
MenuListProps={{style: {backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}}
|
||||
>
|
||||
<MenuItem divider={true} style={{display: "block"}} component={Link} to="/new/settings" onClick={handleClose} name="settings">
|
||||
<Typography paragraph={true} variant="caption" style={{marginBottom: "0", color: "white"}}>Signed in as:</Typography>
|
||||
<Typography paragraph={true} variant="body1" style={{marginBottom: "0", fontWeight: 600, color: "white"}}> {me?.user?.username || "" } </Typography>
|
||||
</MenuItem>
|
||||
<MenuItem component={Link} to="/new/login" onClick={handleLogout}>Logout</MenuItem>
|
||||
</Menu>
|
||||
<div style={{display: "inline-flex", justifyContent: "flex-end", float: "right"}}>
|
||||
<IconButton
|
||||
aria-label="feedback"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={() => setOpenFeedbackForm(true)}
|
||||
ref={feedbackRef}
|
||||
color="inherit"
|
||||
style={{float: "right", }}
|
||||
size="large">
|
||||
<MythicStyledTooltip title="Send Support Feedback">
|
||||
<ThumbDownOffAltIcon className="mythicElement" />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
{openFeedbackForm &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openFeedbackForm}
|
||||
onClose={()=>{setOpenFeedbackForm(false);}}
|
||||
innerDialog={<MythicFeedbackDialog
|
||||
title={"Submit Feedback via Webhook"}
|
||||
onClose={()=>{setOpenFeedbackForm(false);}} />}
|
||||
/>
|
||||
}
|
||||
<IconButton
|
||||
aria-label="documentation links"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleDocumentationMenu}
|
||||
ref={documentationRef}
|
||||
color="inherit"
|
||||
style={{float:"right"}}
|
||||
size="large">
|
||||
<MythicStyledTooltip title="Help">
|
||||
<HelpIcon className="mythicElement"/>
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
nodeRef={documentationRef}
|
||||
anchorEl={()=>documentationRef.current}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
getContentAnchorEl={null}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={documentationAnchorEl}
|
||||
onClose={handleDocumentationClose}
|
||||
MenuListProps={{style: {backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}}
|
||||
>
|
||||
<MenuItem component={Link} target="_blank" to="/docs/agents" onClick={handleDocumentationClose}>Agent Documentation</MenuItem>
|
||||
<MenuItem component={Link} target="_blank" to="/docs/wrappers" onClick={handleDocumentationClose}>Wrapper Documentation</MenuItem>
|
||||
<MenuItem component={Link} target="_blank" to="/docs/c2-profiles" onClick={handleDocumentationClose}>C2 Profile Documentation</MenuItem>
|
||||
<MenuItem component={Link} to={{pathname: "https://docs.mythic-c2.net"}} target="_blank" onClick={handleDocumentationClose}>Mythic Documentation</MenuItem>
|
||||
</Menu>
|
||||
<TopAppBarNotifications />
|
||||
<IconButton
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
ref={settingsRef}
|
||||
color="inherit"
|
||||
style={{float: "right", }}
|
||||
size="large">
|
||||
<MythicStyledTooltip title="Settings or Logout">
|
||||
<ManageAccountsIcon className="mythicElement" />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
) : null
|
||||
}
|
||||
</AppBar>
|
||||
<Drawer
|
||||
className={classes.drawer}
|
||||
anchor="left"
|
||||
open={isOpen}
|
||||
classes={{
|
||||
paper: classes.drawerPaper,
|
||||
}}
|
||||
onClose={handleDrawerClose}
|
||||
>
|
||||
<div className={classes.drawerHeader} role="presentation">
|
||||
<ReactLogo style={{width: "80%", display: "inline-block"}}/>
|
||||
|
||||
<IconButton onClick={handleDrawerClose} size="large">
|
||||
{theme.direction === 'ltr' ? <ChevronLeftIcon className="mythicElement"/> : <ChevronRightIcon className="mythicElement"/>}
|
||||
</IconButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Home
|
||||
</ListSubheader>
|
||||
}>
|
||||
<div style={{marginLeft: "15px"}}>
|
||||
<b>Mythic Version:</b> v{mythicVersion}<br/>
|
||||
<b>UI Version:</b> v{mythicUIVersion}<br/>
|
||||
</div>
|
||||
|
||||
<ListItem button component={Link} to='/new' key={"home"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon ><HomeIcon className="mythicElement" /></ListItemIcon>
|
||||
<ListItemText primary={"Home"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Global Configurations
|
||||
</ListSubheader>
|
||||
}>
|
||||
<ListItem button onClick={handleToggleGlobal}>
|
||||
<ListItemIcon><LayersIcon /></ListItemIcon>
|
||||
<ListItemText>Services</ListItemText>
|
||||
{openGlobal ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openGlobal} unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
|
||||
<ListItem button className={classes.nested} target="_blank" component={Link} to='/jupyter' key={"jupyter"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><CodeIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Jupyter Notebooks"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} target="_blank" component={Link} to='/console' key={"console"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><StorageIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"GraphQL Console"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/consuming_services' key={"consuming"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PublicIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Consuming Services"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem button onClick={handleToggleCreate}>
|
||||
<ListItemIcon><PostAddIcon /></ListItemIcon>
|
||||
<ListItemText>Create</ListItemText>
|
||||
{openCreate ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openCreate} unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/createpayload' key={"createpayload"} onClick={handleDrawerClose} state={{from: 'TopAppBar'}}>
|
||||
<ListItemIcon><PostAddIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Create Payload"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/createwrapper' key={"createwrapper"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PostAddIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Create Wrapper"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem button onClick={handleToggleOperations}>
|
||||
<ListItemIcon><SupervisorAccountIcon /></ListItemIcon>
|
||||
<ListItemText>Operation Config</ListItemText>
|
||||
{openOperations ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openOperations} unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/payloadtypes' key={"payloadtypes"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><InboxIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Agents & C2"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/operations' key={"modifyoperations"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><EditIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Modify Operations"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/browserscripts' key={"browserscripts"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><CodeIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"BrowserScripts"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
<Divider />
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Operational Views
|
||||
</ListSubheader>
|
||||
}>
|
||||
<ListItem button onClick={handleToggleData}>
|
||||
<ListItemIcon><BarChartIcon /></ListItemIcon>
|
||||
<ListItemText>Operational Data</ListItemText>
|
||||
{openData ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openData} unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/payloads' key={"payloads"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FontAwesomeIcon icon={faBiohazard} size="lg"/></ListItemIcon>
|
||||
<ListItemText primary={"Payloads"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search' key={"search"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><SearchIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Search"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem button component={Link} to='/new/callbacks' key={"callbacks"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PhoneCallbackIcon className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Active Callbacks"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Optional Configurations
|
||||
</ListSubheader>
|
||||
}>
|
||||
<ListItem>
|
||||
<Switch
|
||||
checked={props.theme === 'dark'}
|
||||
onChange={props.toggleTheme}
|
||||
color="primary"
|
||||
inputProps={{ 'aria-label': 'primary checkbox' }}
|
||||
name="darkMode"
|
||||
/>
|
||||
<div style={{display: "inline-block"}}> Enable Dark Mode </div>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
</Drawer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import {useSubscription, gql } from '@apollo/client';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {alertCount} from "../cache";
|
||||
import {MeContext} from "./App";
|
||||
import {requestCurrentOperationSync} from "./utilities/MythicConnection";
|
||||
|
||||
const SUB_Event_Logs = gql`
|
||||
subscription OperationAlertCounts($operation_id: Int!, $operator_id: Int!){
|
||||
operation_by_pk(id: $operation_id) {
|
||||
id
|
||||
alert_count
|
||||
operators(where: {id: {_eq: $operator_id}}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function TopAppBarVerticalEventLogNotifications() {
|
||||
const theme = useTheme();
|
||||
const me = React.useContext(MeContext);
|
||||
const [alerts, setAlerts] = React.useState(alertCount());
|
||||
const syncRequestedForOperation = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
syncRequestedForOperation.current = null;
|
||||
}, [me?.user?.current_operation_id]);
|
||||
const {error} = useSubscription(SUB_Event_Logs, {
|
||||
variables: {
|
||||
operation_id: me?.user?.current_operation_id || 0,
|
||||
operator_id: me?.user?.user_id || 0,
|
||||
},
|
||||
skip: !me?.user?.current_operation_id || !me?.user?.user_id,
|
||||
onError: data => {
|
||||
console.log("vertical event log error")
|
||||
console.error(data);
|
||||
},
|
||||
onData: ({data}) => {
|
||||
const operation = data.data?.operation_by_pk;
|
||||
if(!operation){
|
||||
return;
|
||||
}
|
||||
if(operation.operators.length === 0 &&
|
||||
syncRequestedForOperation.current !== operation.id){
|
||||
syncRequestedForOperation.current = operation.id;
|
||||
requestCurrentOperationSync();
|
||||
return;
|
||||
}
|
||||
const newAlertCount = operation.alert_count;
|
||||
if(newAlertCount !== alerts){
|
||||
setAlerts(newAlertCount);
|
||||
alertCount(newAlertCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
error ? (
|
||||
<Badge color="secondary" badgeContent={"X"}>
|
||||
<NotificationsActiveTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"} />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeContent={alerts}
|
||||
color="error" max={99}
|
||||
>
|
||||
<NotificationsActiveTwoToneIcon style={{color: theme.navBarTextIconColor}} fontSize={"medium"}/>
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import React from 'react';
|
||||
import {useSubscription, gql } from '@apollo/client';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconButton, Tooltip } from '@mui/material';
|
||||
import {snackActions} from './utilities/Snackbar';
|
||||
import { meState } from '../cache';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
|
||||
const SUB_Event_Logs = gql`
|
||||
subscription MySubscription($operation_id: Int!) {
|
||||
operation_by_pk(id: $operation_id) {
|
||||
id
|
||||
alert_count
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
tooltip: {
|
||||
backgroundColor: theme.palette.background.contrast,
|
||||
color: theme.palette.text.contrast,
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 13
|
||||
},
|
||||
arrow: {
|
||||
color: theme.palette.background.contrast,
|
||||
}
|
||||
}));
|
||||
export function TopAppBarNotifications(props) {
|
||||
const me = useReactiveVar(meState);
|
||||
const classes = useStyles();
|
||||
const { loading, error, data } = useSubscription(SUB_Event_Logs, {
|
||||
variables: {operation_id: me?.user?.current_operation_id || 0},
|
||||
onError: data => {
|
||||
snackActions.error("Mythic encountered an error getting event log messages: " + data.toString());
|
||||
console.error(data);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
component={Link}
|
||||
to='/new/EventFeed'
|
||||
style={{float: "right"}}
|
||||
size="large">
|
||||
<Tooltip title="Event Feed" arrow classes={{tooltip: classes.tooltip, arrow: classes.arrow}}>
|
||||
{
|
||||
error ? (
|
||||
<Badge color="secondary" badgeContent={0}>
|
||||
<NotificationsActiveIcon />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeContent={data?.operation_by_pk?.alert_count || 0} color="error">
|
||||
<NotificationsActiveIcon />
|
||||
</Badge>
|
||||
)
|
||||
|
||||
}
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { gql, useMutation, useSubscription } from '@apollo/client';
|
||||
import {BrowserScriptsTable} from './BrowserScriptsTable';
|
||||
import {BrowserScriptsOperationsTable} from './BrowserScriptsOperationsTable';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import { Backdrop } from '@mui/material';
|
||||
import {CircularProgress} from '@mui/material';
|
||||
import {MythicPageBody} from "../../MythicComponents/MythicPageBody";
|
||||
import {MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
|
||||
|
||||
|
||||
const SUB_BrowserScripts = gql`
|
||||
@@ -26,42 +26,10 @@ subscription SubscribeBrowserScripts($operator_id: Int!) {
|
||||
cmd
|
||||
id
|
||||
}
|
||||
browserscriptoperations {
|
||||
operation_id
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const SUB_OperationBrowserScripts = gql`
|
||||
subscription SubscribeOperationBrowserScripts($operation_id: Int!) {
|
||||
browserscriptoperation(where: {operation_id: {_eq: $operation_id}, browserscript: {for_new_ui: {_eq: true}}}){
|
||||
browserscript{
|
||||
active
|
||||
author
|
||||
user_modified
|
||||
script
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
}
|
||||
id
|
||||
creation_time
|
||||
container_version_author
|
||||
container_version
|
||||
command {
|
||||
cmd
|
||||
id
|
||||
}
|
||||
}
|
||||
operation{
|
||||
admin{
|
||||
username
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const updateBrowserScriptActive = gql`
|
||||
mutation updateBrowserScriptActive($browserscript_id: Int!, $active: Boolean!) {
|
||||
update_browserscript_by_pk(pk_columns: {id: $browserscript_id}, _set: {active: $active}) {
|
||||
@@ -70,8 +38,8 @@ mutation updateBrowserScriptActive($browserscript_id: Int!, $active: Boolean!) {
|
||||
}
|
||||
`;
|
||||
const updateBrowserScriptScript = gql`
|
||||
mutation updateBrowserScriptScript($browserscript_id: Int!, $script: String!, $command_id: Int!, $payload_type_id: Int!, $author: String!) {
|
||||
update_browserscript_by_pk(pk_columns: {id: $browserscript_id}, _set: {script: $script, author: $author, user_modified: true, command_id: $command_id, payload_type_id: $payload_type_id}) {
|
||||
mutation updateBrowserScriptScript($browserscript_id: Int!, $script: String!, $command_id: Int!, $payload_type_id: Int!) {
|
||||
update_browserscript_by_pk(pk_columns: {id: $browserscript_id}, _set: {script: $script, user_modified: true, command_id: $command_id, payload_type_id: $payload_type_id}) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -84,15 +52,8 @@ mutation updateBrowserScriptRevert($browserscript_id: Int!, $script: String!) {
|
||||
}
|
||||
`;
|
||||
const addBrowserScript = gql`
|
||||
mutation insertNewBrowserScript($script: String!, $author: String!, $payload_type_id: Int!, $command_id: Int!){
|
||||
insert_browserscript_one(object: {script: $script, author: $author, payload_type_id: $payload_type_id, command_id: $command_id}){
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
const deleteBrowserScriptMutation = gql`
|
||||
mutation deleteBrowserScriptMutation($browserscript_id: Int!){
|
||||
delete_browserscript_by_pk(id: $browserscript_id){
|
||||
mutation insertNewBrowserScript($script: String!, $payload_type_id: Int!, $command_id: Int!, $author: String!){
|
||||
insert_browserscript_one(object: {script: $script, payload_type_id: $payload_type_id, command_id: $command_id, author: $author}){
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -101,7 +62,6 @@ mutation deleteBrowserScriptMutation($browserscript_id: Int!){
|
||||
|
||||
export function BrowserScripts({me}){
|
||||
const [browserScripts, setBrowserScripts] = React.useState([]);
|
||||
const [operationBrowserScripts, setOperationBrowserScripts] = React.useState({"browserscriptoperation": []});
|
||||
const mountedRef = React.useRef(true);
|
||||
const [backdropOpen, setBackdropOpen] = React.useState(true);
|
||||
useSubscription(SUB_BrowserScripts, {
|
||||
@@ -119,17 +79,9 @@ export function BrowserScripts({me}){
|
||||
}
|
||||
} )
|
||||
setBrowserScripts(scripts);
|
||||
if(backdropOpen){setBackdropOpen(false);}
|
||||
setBackdropOpen(false);
|
||||
}
|
||||
});
|
||||
useSubscription(SUB_OperationBrowserScripts, {
|
||||
variables: {operation_id: me?.user?.current_operation_id || 0}, fetchPolicy: "no-cache",
|
||||
shouldResubscribe: true,
|
||||
onSubscriptionData: ({subscriptionData}) => {
|
||||
if(!mountedRef.current){return}
|
||||
setOperationBrowserScripts(subscriptionData.data);
|
||||
}
|
||||
});
|
||||
const [toggleActive] = useMutation(updateBrowserScriptActive, {
|
||||
onCompleted: data => {
|
||||
snackActions.success("Successfully Updated!", {autoHideDuration: 1000});
|
||||
@@ -165,34 +117,18 @@ export function BrowserScripts({me}){
|
||||
snackActions.error("Failed to create new script: " + data);
|
||||
}
|
||||
});
|
||||
const [deleteBrowserScript] = useMutation(deleteBrowserScriptMutation, {
|
||||
onCompleted: data => {
|
||||
snackActions.success("Successfully deleted browser script");
|
||||
},
|
||||
onError: data => {
|
||||
snackActions.error("Failed to delete browser script: " + data);
|
||||
}
|
||||
})
|
||||
const onToggleActive = ({browserscript_id, active}) => {
|
||||
setBackdropOpen(true);
|
||||
toggleActive({variables: {browserscript_id, active}});
|
||||
}
|
||||
const onSubmitEdit = ({browserscript_id, script, command_id, payload_type_id, author}) => {
|
||||
updateScript({variables: {browserscript_id, script, command_id, payload_type_id, author}});
|
||||
const onSubmitEdit = ({browserscript_id, script, command_id, payload_type_id}) => {
|
||||
updateScript({variables: {browserscript_id, script, command_id, payload_type_id}});
|
||||
}
|
||||
const onRevert = ({browserscript_id, script}) => {
|
||||
revertScript({variables:{browserscript_id, script}});
|
||||
}
|
||||
const onSubmitApplyToOperation = ({browserscript_id}) => {
|
||||
snackActions.warning("Not Implemented Yet!", {autoHideDuration: 1000});
|
||||
}
|
||||
const onSubmitRemoveFromOperation = ({browserscript_id}) => {
|
||||
snackActions.warning("Not Implemented Yet!", {autoHideDuration: 1000});
|
||||
}
|
||||
const onSubmitCreateNewBrowserScript = ({script, author, payload_type_id, command_id}) => {
|
||||
createBrowserScript({variables: {author, script, payload_type_id, command_id}});
|
||||
}
|
||||
const onDelete = ({browserscript_id}) => {
|
||||
deleteBrowserScript({variables: {browserscript_id}});
|
||||
const onSubmitCreateNewBrowserScript = ({script, payload_type_id, command_id}) => {
|
||||
createBrowserScript({variables: {author: me?.user?.username || "", script, payload_type_id, command_id}});
|
||||
}
|
||||
React.useRef( () => {
|
||||
return () => {
|
||||
@@ -200,17 +136,16 @@ export function BrowserScripts({me}){
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={false}>
|
||||
<CircularProgress color="inherit" />
|
||||
<MythicPageBody>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 1300, position: "absolute"}} invisible={false}>
|
||||
<MythicLoadingState compact title="Loading browser scripts" description="Fetching scripts for this operator." sx={{color: "inherit"}} />
|
||||
</Backdrop>
|
||||
<BrowserScriptsTable browserscripts={browserScripts} operation_id={me?.user?.current_operation_id || 0} onToggleActive={onToggleActive}
|
||||
onSubmitEdit={onSubmitEdit} onRevert={onRevert} onSubmitNew={onSubmitCreateNewBrowserScript} onDelete={onDelete}
|
||||
onSubmitApplyToOperation={onSubmitApplyToOperation}
|
||||
onSubmitRemoveFromOperation={onSubmitRemoveFromOperation}
|
||||
<BrowserScriptsTable
|
||||
browserscripts={browserScripts} operation_id={me?.user?.current_operation_id || 0} onToggleActive={onToggleActive}
|
||||
me={me}
|
||||
onSubmitEdit={onSubmitEdit} onRevert={onRevert} onSubmitNew={onSubmitCreateNewBrowserScript}
|
||||
/>
|
||||
|
||||
</React.Fragment>
|
||||
</MythicPageBody>
|
||||
);
|
||||
}
|
||||
/*<BrowserScriptsOperationsTable {...operationBrowserScripts} /> */
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { BrowserScriptsOperationsTableRow } from './BrowserScriptsOperationsTableRow';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
|
||||
export function BrowserScriptsOperationsTable(props){
|
||||
const theme = useTheme();
|
||||
return (
|
||||
props.browserscriptoperation.length > 0 &&
|
||||
<React.Fragment>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main, marginBottom: "5px", marginTop: "10px", marginRight: "5px"}} variant={"elevation"}>
|
||||
<Typography variant="h3" style={{textAlign: "left", display: "inline-block", marginLeft: "20px"}}>
|
||||
Operation Browser Scripts
|
||||
</Typography>
|
||||
</Paper>
|
||||
<TableContainer component={Paper} className="mythicElement" style={{maxHeight: "calc(30vh)"}}>
|
||||
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: "10em"}}>Payload</TableCell>
|
||||
<TableCell style={{}}>Command</TableCell>
|
||||
<TableCell style={{width: "5em"}}>User Modified?</TableCell>
|
||||
<TableCell style={{}}>Operator</TableCell>
|
||||
<TableCell style={{width: "5em"}}>View Script</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.browserscriptoperation.map( (op) => (
|
||||
<BrowserScriptsOperationsTableRow
|
||||
key={"opscriptrow" + op.browserscript.id}
|
||||
{...op}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {Button} from '@mui/material';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import AceEditor from 'react-ace';
|
||||
import "ace-builds/src-noconflict/mode-javascript";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
|
||||
export function BrowserScriptsOperationsTableRow(props){
|
||||
const [openViewScript, setOpenViewScript] = React.useState(false);
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
|
||||
useEffect( () => {
|
||||
setDisplayName(props.browserscript.command === null ? "Support Script: " + props.browserscript.name : props.browserscript.command.cmd);
|
||||
}, [props.browserscript]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow key={"opscript" + props.browserscript.id}>
|
||||
<TableCell>{props.browserscript.payloadtype.ptype}</TableCell>
|
||||
<TableCell>{displayName}</TableCell>
|
||||
<TableCell>{props.browserscript.user_modified ? "User Modified" : "" } </TableCell>
|
||||
<TableCell>{props.operation.admin.username}</TableCell>
|
||||
<TableCell><Button size="small" variant="contained" color="info" onClick={() => {setOpenViewScript(true);}}> View </Button></TableCell>
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openViewScript}
|
||||
onClose={()=>{setOpenViewScript(false);}}
|
||||
innerDialog={
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme="github"
|
||||
width="100%"
|
||||
value={atob(props.browserscript.script)}
|
||||
focus={true}
|
||||
readOnly={true}
|
||||
onChange={() => {}}
|
||||
setOptions={{
|
||||
|
||||
}}
|
||||
/>
|
||||
} />
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,142 @@
|
||||
import React from 'react';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { BrowserScriptsTableRow } from './BrowserScriptsTableRow';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
|
||||
import Button from '@mui/material/Button';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {EditScriptDialog} from './EditScriptDialog';
|
||||
import {MythicPageHeader, MythicPageHeaderChip} from "../../MythicComponents/MythicPageHeader";
|
||||
import {
|
||||
MythicSearchField,
|
||||
MythicTableToolbar,
|
||||
MythicTableToolbarGroup,
|
||||
MythicToolbarButton,
|
||||
MythicToolbarMenuItem,
|
||||
MythicToolbarSelect
|
||||
} from "../../MythicComponents/MythicTableToolbar";
|
||||
import {MythicTableEmptyState} from "../../MythicComponents/MythicStateDisplay";
|
||||
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
|
||||
|
||||
|
||||
export function BrowserScriptsTable(props){
|
||||
const theme = useTheme();
|
||||
const [openNewScriptDialog, setOpenNewScriptDialog] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
const visibleScripts = React.useMemo(() => {
|
||||
return props.browserscripts.filter((script) => {
|
||||
const matchesStatus = statusFilter === "all" ||
|
||||
(statusFilter === "active" && script.active) ||
|
||||
(statusFilter === "disabled" && !script.active) ||
|
||||
(statusFilter === "modified" && script.user_modified) ||
|
||||
(statusFilter === "default" && !script.user_modified);
|
||||
if(!matchesStatus){
|
||||
return false;
|
||||
}
|
||||
if(normalizedSearch === ""){
|
||||
return true;
|
||||
}
|
||||
return [
|
||||
script.payloadtype?.name,
|
||||
script.command?.cmd,
|
||||
script.author,
|
||||
].some((value) => (value || "").toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
}, [normalizedSearch, props.browserscripts, statusFilter]);
|
||||
const scriptCountLabel = props.browserscripts.length === 1 ? "1 script" : `${props.browserscripts.length} scripts`;
|
||||
const visibleCountLabel = visibleScripts.length === 1 ? "1 shown" : `${visibleScripts.length} shown`;
|
||||
const activeCount = props.browserscripts.filter((script) => script.active).length;
|
||||
const activeCountLabel = activeCount === 1 ? "1 active" : `${activeCount} active`;
|
||||
const modifiedCount = props.browserscripts.filter((script) => script.user_modified).length;
|
||||
const hasFilter = normalizedSearch !== "" || statusFilter !== "all";
|
||||
const onChangeSearch = (name, value) => {
|
||||
setSearch(value);
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main, marginBottom: "5px", marginTop: "10px", marginRight: "5px"}} variant={"elevation"}>
|
||||
<Typography variant="h3" style={{textAlign: "left", display: "inline-block", marginLeft: "20px"}}>
|
||||
Browser Scripts
|
||||
</Typography>
|
||||
<Button size="small" onClick={() => setOpenNewScriptDialog(true)} style={{float: "right", marginTop: "10px", marginRight: "30px"}} startIcon={<AddCircleOutlineOutlinedIcon/>} color="primary" variant="contained">New Script</Button>
|
||||
{openNewScriptDialog ? (
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openNewScriptDialog}
|
||||
onClose={()=>{setOpenNewScriptDialog(false);}}
|
||||
innerDialog={
|
||||
<EditScriptDialog onClose={()=>{setOpenNewScriptDialog(false);}} title="Create New Browser Script" new={true} onSubmitEdit={props.onSubmitNew} />
|
||||
} />
|
||||
) : (null)
|
||||
<>
|
||||
<MythicPageHeader
|
||||
title={"Browser Scripts"}
|
||||
subtitle={"Manage custom browser script renderers for task output in the current UI."}
|
||||
meta={
|
||||
<>
|
||||
<MythicPageHeaderChip label={scriptCountLabel} />
|
||||
{hasFilter && <MythicPageHeaderChip label={visibleCountLabel} />}
|
||||
<MythicPageHeaderChip label={activeCountLabel} />
|
||||
{modifiedCount > 0 && <MythicPageHeaderChip label={`${modifiedCount} modified`} />}
|
||||
</>
|
||||
}
|
||||
</Paper>
|
||||
<TableContainer component={Paper} className="mythicElement" style={{maxHeight: "90%"}}>
|
||||
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
actions={
|
||||
<MythicToolbarButton variant="contained" colorMode="hover" tone="success" onClick={() => setOpenNewScriptDialog(true)} startIcon={<AddCircleIcon />}>
|
||||
Script
|
||||
</MythicToolbarButton>
|
||||
}
|
||||
/>
|
||||
{openNewScriptDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openNewScriptDialog}
|
||||
onClose={()=>{setOpenNewScriptDialog(false);}}
|
||||
innerDialog={
|
||||
<EditScriptDialog me={props.me} onClose={()=>{setOpenNewScriptDialog(false);}} title="Create New Browser Script" new={true} onSubmitEdit={props.onSubmitNew} />
|
||||
} />
|
||||
}
|
||||
<MythicTableToolbar variant="search">
|
||||
<MythicTableToolbarGroup grow>
|
||||
<MythicSearchField
|
||||
name="Search browser scripts"
|
||||
placeholder="Search payload, command, or author..."
|
||||
value={search}
|
||||
onChange={onChangeSearch}
|
||||
/>
|
||||
</MythicTableToolbarGroup>
|
||||
<MythicTableToolbarGroup>
|
||||
<MythicToolbarSelect
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
>
|
||||
<MythicToolbarMenuItem value="all">All scripts</MythicToolbarMenuItem>
|
||||
<MythicToolbarMenuItem value="active">Active</MythicToolbarMenuItem>
|
||||
<MythicToolbarMenuItem value="disabled">Disabled</MythicToolbarMenuItem>
|
||||
<MythicToolbarMenuItem value="modified">User modified</MythicToolbarMenuItem>
|
||||
<MythicToolbarMenuItem value="default">Container default</MythicToolbarMenuItem>
|
||||
</MythicToolbarSelect>
|
||||
</MythicTableToolbarGroup>
|
||||
</MythicTableToolbar>
|
||||
<TableContainer style={{flexGrow: 1, minHeight: 0, overflow: "auto"}}>
|
||||
<Table className="mythic-browser-scripts-table table-fixed max-w-full w-full" stickyHeader={true} size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell style={{width: "5rem"}}>Delete</TableCell>
|
||||
<TableCell style={{width: "5rem"}}>Active</TableCell>
|
||||
<TableCell >Payload</TableCell>
|
||||
<TableCell >Command</TableCell>
|
||||
<TableCell> Author</TableCell>
|
||||
<TableCell style={{width: "15em"}}>User Modified?</TableCell>
|
||||
<TableCell style={{width: "8em"}}> Edit</TableCell>
|
||||
<MythicStyledTableCell style={{width: "24rem"}}>Script</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{width: "10rem"}}>Author</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{width: "9.5rem"}}>Active</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{width: "12rem"}}>Source</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{width: "4rem", textAlign: "center"}}>Actions</MythicStyledTableCell>
|
||||
<MythicStyledTableCell className="mythic-browser-script-spacer-cell min-w-0" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{props.browserscripts.map( (op) => (
|
||||
<BrowserScriptsTableRow onSubmitApplyToOperation={props.onSubmitApplyToOperation}
|
||||
onSubmitRemoveFromOperation={props.onSubmitRemoveFromOperation}
|
||||
operation_id={props.operation_id} onToggleActive={props.onToggleActive}
|
||||
onSubmitEdit={props.onSubmitEdit} onRevert={props.onRevert}
|
||||
onToggleOperation={props.onToggleOperation}
|
||||
onDelete={props.onDelete}
|
||||
{visibleScripts.length === 0 &&
|
||||
<MythicTableEmptyState
|
||||
colSpan={6}
|
||||
compact
|
||||
title={props.browserscripts.length === 0 ? "No browser scripts" : "No browser scripts match"}
|
||||
description={props.browserscripts.length === 0 ? "Create or import browser scripts to customize task output rendering." : "Try changing the search text or status filter."}
|
||||
/>
|
||||
}
|
||||
{visibleScripts.map( (op) => (
|
||||
<BrowserScriptsTableRow
|
||||
me={props.me}
|
||||
operation_id={props.operation_id} onToggleActive={props.onToggleActive}
|
||||
onSubmitEdit={props.onSubmitEdit} onRevert={props.onRevert}
|
||||
key={"script" + op.id}
|
||||
{...op}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
</TableContainer>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +1,73 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {Button, Switch} from '@mui/material';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {Switch} from '@mui/material';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {EditScriptDialog} from './EditScriptDialog';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import {MythicConfirmDialog} from '../../MythicComponents/MythicConfirmDialog';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
|
||||
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
|
||||
import {MythicChip} from "../../MythicComponents/MythicChip";
|
||||
|
||||
export function BrowserScriptsTableRow(props){
|
||||
const theme = useTheme();
|
||||
const [openEdit, setOpenEdit] = React.useState(false);
|
||||
const [isApplied, setIsApplied] = React.useState(false);
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = React.useState(false);
|
||||
const onSubmitEdit = ({script, command_id, payload_type_id, author}) => {
|
||||
props.onSubmitEdit({browserscript_id: props.id, script, command_id, payload_type_id, author});
|
||||
const onSubmitEdit = ({script, command_id, payload_type_id}) => {
|
||||
props.onSubmitEdit({browserscript_id: props.id, script, command_id, payload_type_id});
|
||||
}
|
||||
const onRevert = () => {
|
||||
props.onRevert({browserscript_id: props.id, script: props.container_version});
|
||||
}
|
||||
const onSubmitApplyToOperation = () => {
|
||||
props.onSubmitApplyToOperation({browserscript_id: props.id});
|
||||
}
|
||||
const onSubmitRemoveFromOperation = () => {
|
||||
props.onSubmitRemoveFromOperation({browserscript_id: props.id});
|
||||
}
|
||||
useEffect( () => {
|
||||
let found = false;
|
||||
props.browserscriptoperations.forEach( (op) => {
|
||||
if(op.operation_id === props.operation_id){
|
||||
found = true;
|
||||
}
|
||||
} );
|
||||
setIsApplied(found);
|
||||
}, [props.browserscriptoperations, props.operation_id]);
|
||||
const onToggleActive = () => {
|
||||
props.onToggleActive({browserscript_id: props.id, active: !props.active});
|
||||
}
|
||||
const onConfirmDelete = () => {
|
||||
props.onDelete({browserscript_id: props.id})
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow key={"payload" + props.id} hover>
|
||||
<TableCell>
|
||||
<IconButton size="small" onClick={()=>{setOpenDeleteDialog(true);}} style={{color: theme.palette.error.main}} variant="contained"><DeleteIcon/></IconButton>
|
||||
<MythicConfirmDialog onClose={() => {setOpenDeleteDialog(false);}} onSubmit={onConfirmDelete} open={openDeleteDialog}/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={props.active}
|
||||
onChange={onToggleActive}
|
||||
color="primary"
|
||||
inputProps={{ 'aria-label': 'checkbox', "track": "white" }}
|
||||
name="Active"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{props.payloadtype.name}</TableCell>
|
||||
<TableCell>{props.command.cmd}</TableCell>
|
||||
<TableCell>{props.author}</TableCell>
|
||||
<TableCell>{props.user_modified ? "User Modified" : "" } </TableCell>
|
||||
<TableCell><Button size="small" variant="contained" onClick={ () => {setOpenEdit(true);} } color="primary"> Edit </Button></TableCell>
|
||||
<TableRow className={props.active ? "" : "mythic-browser-script-row-disabled"} key={"payload" + props.id} hover>
|
||||
<MythicStyledTableCell>
|
||||
<Box className="mythic-browser-script-script-cell items-center flex gap-5 min-w-0">
|
||||
<MythicStyledTooltip title={props.payloadtype.name}>
|
||||
<Box className="mythic-browser-script-payload-icon items-center inline-flex flex-none justify-center rounded bg-neutral-1 border-subtle">
|
||||
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "32px", height: "32px"}} />
|
||||
</Box>
|
||||
</MythicStyledTooltip>
|
||||
<Box className="mythic-browser-script-script-copy flex flex-column gap-1 min-w-0">
|
||||
<Box className="mythic-browser-script-command-name text-sm font-750 leading-125 truncate text-primary whitespace-nowrap">{props.command.cmd}</Box>
|
||||
<Box className="mythic-browser-script-payload-name text-xs font-650 leading-125 truncate text-muted whitespace-nowrap">{props.payloadtype.name}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>{props.author}</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<Box className="mythic-browser-script-active-cell items-center flex gap-3 min-w-0">
|
||||
<Switch
|
||||
checked={props.active}
|
||||
onChange={onToggleActive}
|
||||
color="success"
|
||||
inputProps={{ 'aria-label': 'Toggle browser script active state', "track": "white" }}
|
||||
name="Active"
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicChip label={props.user_modified ? "User modified" : "Container default"} tone={props.user_modified ? "warning" : "secondary"} />
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{textAlign: "center"}}>
|
||||
<Box className="items-center flex flex-wrap gap-3 flex-nowrap mythic-browser-script-actions justify-center">
|
||||
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="info" size="small" onClick={()=>{setOpenEdit(true);}}>
|
||||
<EditIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell className="mythic-browser-script-spacer-cell min-w-0" />
|
||||
|
||||
{openEdit &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openEdit}
|
||||
onClose={()=>{setOpenEdit(false);}}
|
||||
innerDialog={
|
||||
<EditScriptDialog onClose={()=>{setOpenEdit(false);}} payload_type_id={props.payloadtype.id} command_id={props.command.id}
|
||||
<EditScriptDialog me={props.me} onClose={()=>{setOpenEdit(false);}} payload_type_id={props.payloadtype.id} command_id={props.command.id}
|
||||
script={props.script} onSubmitEdit={onSubmitEdit} onRevert={onRevert} author={props.author}/>
|
||||
} />
|
||||
}
|
||||
@@ -74,4 +75,3 @@ export function BrowserScriptsTableRow(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Box from '@mui/material/Box';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import AceEditor from 'react-ace';
|
||||
@@ -9,18 +10,32 @@ import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-monokai";
|
||||
import "ace-builds/src-noconflict/ext-searchbox";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import { gql, useQuery } from '@apollo/client';
|
||||
import { gql, useQuery, useLazyQuery } from '@apollo/client';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Input from '@mui/material/Input';
|
||||
import MythicTextField from '../../MythicComponents/MythicTextField';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Split from 'react-split';
|
||||
import {TaskDisplay} from "../Callbacks/TaskDisplay";
|
||||
import {MythicChip} from "../../MythicComponents/MythicChip";
|
||||
import {taskingDataFragment} from '../Callbacks/CallbackMutations'
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicFormField,
|
||||
MythicFormGrid,
|
||||
MythicFormNote
|
||||
} from "../../MythicComponents/MythicDialogLayout";
|
||||
import {MythicEmptyState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
|
||||
|
||||
|
||||
const getCommandsAndPayloadTypesQuery = gql`
|
||||
query getCommandsAndPayloadTypes{
|
||||
payloadtype(order_by: {name: asc}){
|
||||
payloadtype(where: {deleted: {_eq: false}, wrapper: {_eq: false}}, order_by: {name: asc}){
|
||||
id
|
||||
name
|
||||
commands(order_by: {cmd: asc}){
|
||||
@@ -30,6 +45,46 @@ query getCommandsAndPayloadTypes{
|
||||
}
|
||||
}
|
||||
`;
|
||||
const getExistingTasksForCommand = gql`
|
||||
${taskingDataFragment}
|
||||
query getAvailableTasks($command_id: Int!){
|
||||
task(order_by: {display_id: desc}, where: {command_id: {_eq: $command_id}}) {
|
||||
...taskData
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY = "browserScriptWorkbenchSplitSizes";
|
||||
const BROWSER_SCRIPT_TOP_SPLIT_KEY = "browserScriptTopSplitSizes";
|
||||
const defaultWorkbenchSplitSizes = [48, 52];
|
||||
const defaultTopSplitSizes = [68, 32];
|
||||
|
||||
const getStoredSplitSizes = (storageKey, fallback) => {
|
||||
try {
|
||||
if(typeof window === "undefined"){
|
||||
return fallback;
|
||||
}
|
||||
const storedSizes = window.localStorage.getItem(storageKey);
|
||||
if(!storedSizes){
|
||||
return fallback;
|
||||
}
|
||||
const parsedSizes = JSON.parse(storedSizes);
|
||||
if(!Array.isArray(parsedSizes) || parsedSizes.length !== fallback.length || !parsedSizes.every(Number.isFinite)){
|
||||
return fallback;
|
||||
}
|
||||
return parsedSizes;
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const storeSplitSizes = (storageKey, sizes) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(sizes));
|
||||
} catch (error) {
|
||||
// Best effort only; layout still works without persisted sizes.
|
||||
}
|
||||
}
|
||||
|
||||
export function EditScriptDialog(props) {
|
||||
const theme = useTheme();
|
||||
@@ -38,10 +93,19 @@ export function EditScriptDialog(props) {
|
||||
const [selectedCommand, setSelectedCommand] = React.useState('');
|
||||
const [payloadTypeCmdOptions, setPayloadTypeCmdOptions] = React.useState([]);
|
||||
const [commandOptions, setCommandOptions] = React.useState([]);
|
||||
const [author, setAuthor] = React.useState('');
|
||||
const inputPTRef = useRef(null);
|
||||
const inputCMDRef = useRef(null);
|
||||
useQuery(getCommandsAndPayloadTypesQuery, {
|
||||
const [availableTasks, setAvailableTasks] = React.useState([]);
|
||||
const [selectedTask, setSelectedTask] = React.useState('');
|
||||
const [workbenchSplitSizes, setWorkbenchSplitSizes] = React.useState(() => getStoredSplitSizes(BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY, defaultWorkbenchSplitSizes));
|
||||
const [topSplitSizes, setTopSplitSizes] = React.useState(() => getStoredSplitSizes(BROWSER_SCRIPT_TOP_SPLIT_KEY, defaultTopSplitSizes));
|
||||
const [targetOpen, setTargetOpen] = React.useState(() => props.new === true);
|
||||
const selectedPayloadTypeOption = React.useMemo(() => {
|
||||
return payloadTypeCmdOptions.find((payloadType) => payloadType.id === selectedPayloadType);
|
||||
}, [payloadTypeCmdOptions, selectedPayloadType]);
|
||||
const selectedCommandOption = React.useMemo(() => {
|
||||
return commandOptions.find((command) => command.id === selectedCommand);
|
||||
}, [commandOptions, selectedCommand]);
|
||||
const taskSelectorValue = selectedTask || "";
|
||||
const {loading: targetLoading} = useQuery(getCommandsAndPayloadTypesQuery, {
|
||||
onCompleted: data => {
|
||||
setPayloadTypeCmdOptions(data.payloadtype);
|
||||
if(props.payload_type_id !== undefined){
|
||||
@@ -53,11 +117,8 @@ export function EditScriptDialog(props) {
|
||||
}
|
||||
if(props.command_id !== undefined){
|
||||
setSelectedCommand(props.command_id);
|
||||
for(let i = 0; i < data.payloadtype.length; i++){
|
||||
if(props.payload_type_id === data.payloadtype[i].id){
|
||||
setCommandOptions(data.payloadtype[i].commands)
|
||||
}
|
||||
}
|
||||
const payloadType = data.payloadtype.find((payloadType) => payloadType.id === props.payload_type_id);
|
||||
setCommandOptions(payloadType?.commands || []);
|
||||
}else{
|
||||
if(data.payloadtype.length > 0){
|
||||
if(data.payloadtype[0].commands.length > 0){
|
||||
@@ -66,14 +127,37 @@ export function EditScriptDialog(props) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if(props.author !== undefined){
|
||||
setAuthor(props.author);
|
||||
}
|
||||
},
|
||||
onError: data => {
|
||||
|
||||
}
|
||||
})
|
||||
});
|
||||
const [getAvailableTasks, {loading: tasksLoading}] = useLazyQuery(getExistingTasksForCommand, {
|
||||
onCompleted: data => {
|
||||
setAvailableTasks(data.task);
|
||||
if(data.task.length > 0){
|
||||
setSelectedTask(data.task[0]);
|
||||
}else{
|
||||
setSelectedTask('');
|
||||
}
|
||||
},
|
||||
onError: data => {
|
||||
|
||||
}
|
||||
});
|
||||
const editorRef = useRef(null);
|
||||
const outputRef = useRef(null);
|
||||
const [logOutput, setLogOutput] = React.useState("console.log messages:\n");
|
||||
const logStreamRef = React.useRef("console.log messages:\n");
|
||||
useEffect( () => {
|
||||
if(selectedCommand !== ""){
|
||||
getAvailableTasks({variables: {command_id: selectedCommand}})
|
||||
}else{
|
||||
setAvailableTasks([]);
|
||||
setSelectedTask('');
|
||||
}
|
||||
|
||||
}, [selectedCommand, getAvailableTasks]);
|
||||
useEffect( () => {
|
||||
if(props.script !== undefined){
|
||||
try{
|
||||
@@ -88,96 +172,308 @@ export function EditScriptDialog(props) {
|
||||
setScript(value);
|
||||
}
|
||||
const onSubmit = () => {
|
||||
let newScript = window.btoa(encodeURIComponent(script));
|
||||
props.onSubmitEdit({script: newScript, command_id: selectedCommand, payload_type_id: selectedPayloadType, author});
|
||||
//let newScript = window.btoa(encodeURIComponent(script));
|
||||
//props.onSubmitEdit({script: newScript, command_id: selectedCommand, payload_type_id: selectedPayloadType});
|
||||
props.onSubmitEdit({script: script, command_id: selectedCommand, payload_type_id: selectedPayloadType});
|
||||
props.onClose();
|
||||
}
|
||||
const onTest = () => {
|
||||
//let newScript = window.btoa(encodeURIComponent(script));
|
||||
//props.onSubmitEdit({script: newScript, command_id: selectedCommand, payload_type_id: selectedPayloadType});
|
||||
props.onSubmitEdit({script: script, command_id: selectedCommand, payload_type_id: selectedPayloadType});
|
||||
logStreamRef.current = "console.log messages:\n";
|
||||
setLogOutput(logStreamRef.current);
|
||||
}
|
||||
const onRevert = () => {
|
||||
props.onRevert();
|
||||
props.onClose();
|
||||
}
|
||||
const onChangeSelectedPayloadType = (event) => {
|
||||
setSelectedPayloadType(event.target.value);
|
||||
const cmds = payloadTypeCmdOptions.filter( (p) => p.id === event.target.value);
|
||||
setCommandOptions(cmds[0].commands);
|
||||
setSelectedCommand(cmds[0].commands[0].id);
|
||||
const selectedPayload = payloadTypeCmdOptions.find( (p) => p.id === event.target.value);
|
||||
const cmds = selectedPayload?.commands || [];
|
||||
setCommandOptions(cmds);
|
||||
setSelectedCommand(cmds[0]?.id || '');
|
||||
setAvailableTasks([]);
|
||||
setSelectedTask('');
|
||||
}
|
||||
const onChangeTask = (event) => {
|
||||
setSelectedTask(event.target.value);
|
||||
}
|
||||
const onChangeSelectedCommand = (event) => {
|
||||
setSelectedCommand(event.target.value);
|
||||
}
|
||||
const onChangeAuthor = (name, value, error) => {
|
||||
setAuthor(value);
|
||||
const onLoad = (editor) => {
|
||||
editorRef.current = editor;
|
||||
requestAnimationFrame(() => editor.resize());
|
||||
}
|
||||
const onOutputLoad = (editor) => {
|
||||
outputRef.current = editor;
|
||||
requestAnimationFrame(() => editor.resize());
|
||||
}
|
||||
React.useEffect( () => {
|
||||
const logBackup = console.log;
|
||||
const formatConsoleArg = (arg) => {
|
||||
if(typeof arg === "string"){
|
||||
return arg;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch (error) {
|
||||
return String(arg);
|
||||
}
|
||||
};
|
||||
console.log = function(...args) {
|
||||
const message = args.map(formatConsoleArg).join(" ");
|
||||
logStreamRef.current += "\n" + message;
|
||||
setLogOutput(logStreamRef.current);
|
||||
logBackup.apply(console, args);
|
||||
};
|
||||
return () => {
|
||||
console.log = logBackup;
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if(editorRef.current){
|
||||
editorRef.current.resize();
|
||||
}
|
||||
if(outputRef.current){
|
||||
outputRef.current.resize();
|
||||
}
|
||||
});
|
||||
}, [selectedTask, tasksLoading]);
|
||||
|
||||
const resizeEditors = () => {
|
||||
if(editorRef.current){
|
||||
editorRef.current.resize();
|
||||
}
|
||||
if(outputRef.current){
|
||||
outputRef.current.resize();
|
||||
}
|
||||
}
|
||||
const onWorkbenchDragEnd = (sizes) => {
|
||||
setWorkbenchSplitSizes(sizes);
|
||||
storeSplitSizes(BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY, sizes);
|
||||
resizeEditors();
|
||||
}
|
||||
const onTopDragEnd = (sizes) => {
|
||||
setTopSplitSizes(sizes);
|
||||
storeSplitSizes(BROWSER_SCRIPT_TOP_SPLIT_KEY, sizes);
|
||||
resizeEditors();
|
||||
}
|
||||
const onToggleTargetOpen = () => {
|
||||
setTargetOpen((current) => !current);
|
||||
}
|
||||
const selectedTaskLabel = (task) => {
|
||||
return `${task.command_name} / ${task.display_id} / ${task.display_params}`;
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle >
|
||||
{props.title ? props.title : "Edit BrowserScript Code"}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "calc(80vh)"}}>
|
||||
<FormControl style={{width: "50%"}}>
|
||||
<InputLabel ref={inputPTRef}>Payload Type</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selectedPayloadType}
|
||||
onChange={onChangeSelectedPayloadType}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
>
|
||||
{payloadTypeCmdOptions.map( (opt) => (
|
||||
<MenuItem value={opt.id} key={"payloadtype" + opt.id}>{opt.name}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl style={{width: "50%", paddingBottom: "10px"}}>
|
||||
<InputLabel ref={inputCMDRef}>Command</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selectedCommand}
|
||||
onChange={onChangeSelectedCommand}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
>
|
||||
{commandOptions.map( (opt) => (
|
||||
<MenuItem value={opt.id} key={"command" + opt.id}>{opt.cmd}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<MythicTextField value={author} onChange={onChangeAuthor} name="Author" fullWidth />
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
value={script}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
|
||||
}}
|
||||
/>
|
||||
<DialogTitle>
|
||||
<Box className="mythic-dialog-title-row items-center flex flex-wrap gap-5 justify-between min-w-0">
|
||||
<Box component="span">{props.title ? props.title : "Edit " + props.author + "'s BrowserScript Code"}</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent className="mythic-browser-script-dialog-content flex flex-column overflow-hidden" dividers={true}>
|
||||
<MythicDialogBody className="mythic-browser-script-dialog-body flex-fill min-h-0 overflow-hidden">
|
||||
<Box className={`mythic-browser-script-target-panel rounded bg-surface-raised border-subtle shadow-1${targetOpen ? " mythic-browser-script-target-panel-open" : ""} overflow-hidden`}>
|
||||
<Box className="mythic-browser-script-target-summary items-center flex gap-5 justify-between">
|
||||
<Box className="mythic-browser-script-target-copy items-center flex gap-5 min-w-0">
|
||||
<Box component="span" className="mythic-browser-script-target-label text-xs font-800 leading-100 flex-none text-muted">Script Target</Box>
|
||||
<Box className="mythic-browser-script-target-chips items-center flex flex-wrap gap-3 min-w-0">
|
||||
<MythicChip size="small" label={selectedPayloadTypeOption?.name || (targetLoading ? "Loading payload types" : "No payload type")} />
|
||||
<MythicChip size="small" label={selectedCommandOption?.cmd || "No command"} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Tooltip title={targetOpen ? "Collapse target settings" : "Edit target settings"}>
|
||||
<MythicActionButton iconOnly
|
||||
aria-expanded={targetOpen}
|
||||
aria-label={targetOpen ? "Collapse target settings" : "Edit target settings"}
|
||||
appearance="raised" colorMode="hover" tone="primary"
|
||||
onClick={onToggleTargetOpen}
|
||||
size="small"
|
||||
>
|
||||
{targetOpen ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</MythicActionButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Collapse
|
||||
in={targetOpen}
|
||||
onEntered={resizeEditors}
|
||||
onExited={resizeEditors}
|
||||
timeout="auto"
|
||||
unmountOnExit
|
||||
>
|
||||
<Box className="mythic-browser-script-target-details p-5 flex flex-column gap-4 bg-surface-muted">
|
||||
<MythicFormGrid minWidth="16rem">
|
||||
<MythicFormField label="Payload Type">
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="browser-script-payload-type-label">Payload Type</InputLabel>
|
||||
<Select
|
||||
label="Payload Type"
|
||||
labelId="browser-script-payload-type-label"
|
||||
value={selectedPayloadType}
|
||||
onChange={onChangeSelectedPayloadType}
|
||||
disabled={targetLoading}
|
||||
>
|
||||
{payloadTypeCmdOptions.map( (opt) => (
|
||||
<MenuItem value={opt.id} key={"payloadtype" + opt.id}>{opt.name}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</MythicFormField>
|
||||
<MythicFormField label="Command">
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="browser-script-command-label">Command</InputLabel>
|
||||
<Select
|
||||
label="Command"
|
||||
labelId="browser-script-command-label"
|
||||
value={selectedCommand}
|
||||
onChange={onChangeSelectedCommand}
|
||||
disabled={targetLoading || commandOptions.length === 0}
|
||||
>
|
||||
{commandOptions.map( (opt) => (
|
||||
<MenuItem value={opt.id} key={"command" + opt.id}>{opt.cmd}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</MythicFormField>
|
||||
</MythicFormGrid>
|
||||
<MythicFormNote>
|
||||
Use Save for Testing to update the script without closing the editor. Select an already executed matching task below, then collapse and re-expand that task if you need it to reload the updated renderer.
|
||||
</MythicFormNote>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
<Split
|
||||
className="mythic-browser-script-workbench flex flex-column flex-fill min-h-0 min-w-0 w-full"
|
||||
direction="vertical"
|
||||
gutterSize={8}
|
||||
minSize={[210, 300]}
|
||||
onDrag={resizeEditors}
|
||||
onDragEnd={onWorkbenchDragEnd}
|
||||
sizes={workbenchSplitSizes}
|
||||
>
|
||||
<Split
|
||||
className="mythic-browser-script-top-split flex min-h-0 min-w-0 w-full"
|
||||
gutterSize={8}
|
||||
minSize={[320, 220]}
|
||||
onDrag={resizeEditors}
|
||||
onDragEnd={onTopDragEnd}
|
||||
sizes={topSplitSizes}
|
||||
>
|
||||
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-editor-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
|
||||
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
|
||||
<span>Script Code</span>
|
||||
<span>JavaScript</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-editor-frame flex-fill min-h-0 min-w-0">
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
onLoad={onLoad}
|
||||
height="100%"
|
||||
value={script}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
displayIndentGuides: true,
|
||||
fontSize: 13,
|
||||
showPrintMargin: false,
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-console-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
|
||||
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
|
||||
<span>Console Output</span>
|
||||
<span>console.log</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-editor-frame flex-fill min-h-0 min-w-0">
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
onLoad={onOutputLoad}
|
||||
height="100%"
|
||||
value={logOutput}
|
||||
focus={false}
|
||||
readOnly
|
||||
setOptions={{
|
||||
fontSize: 12,
|
||||
highlightActiveLine: false,
|
||||
showGutter: false,
|
||||
showPrintMargin: false,
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Split>
|
||||
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-preview-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
|
||||
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
|
||||
<span>Test Preview</span>
|
||||
<span>{availableTasks.length === 1 ? "1 task" : `${availableTasks.length} tasks`}</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-preview-controls bg-header flex-none border-b-subtle">
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="browser-script-test-task-label">Test Script With Task</InputLabel>
|
||||
<Select
|
||||
label="Test Script With Task"
|
||||
labelId="browser-script-test-task-label"
|
||||
value={taskSelectorValue}
|
||||
onChange={onChangeTask}
|
||||
disabled={tasksLoading || availableTasks.length === 0}
|
||||
onOpen={resizeEditors}
|
||||
renderValue={(task) => task ? selectedTaskLabel(task) : ""}
|
||||
>
|
||||
{availableTasks.map( (opt) => (
|
||||
<MenuItem value={opt} key={"task" + opt.id}>{selectedTaskLabel(opt)}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="mythic-browser-script-preview-frame p-4 flex-fill min-h-0 min-w-0 overflow-auto">
|
||||
{tasksLoading ? (
|
||||
<MythicLoadingState compact title="Loading test tasks" description="Fetching previous tasks for this command." />
|
||||
) : selectedTask !== "" ? (
|
||||
<TaskDisplay me={props.me} task={selectedTask} command_id={selectedTask.command == null ? 0 : selectedTask.command.id} />
|
||||
) : (
|
||||
<MythicEmptyState compact title="No matching tasks" description="Run this command at least once to preview this browser script against task output." />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Split>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
{props.new ? (
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
|
||||
Create
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button onClick={onRevert} variant="contained" color="warning">
|
||||
<MythicDialogButton intent="warning" onClick={onRevert}>
|
||||
Revert
|
||||
</Button>
|
||||
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
Save
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton intent="info" disabled={selectedCommand === "" || selectedPayloadType === ""} onClick={onTest}>
|
||||
Save For Testing
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
|
||||
Save and Exit
|
||||
</MythicDialogButton>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
</DialogActions>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,19 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import React from 'react';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import {gql, useQuery} from '@apollo/client';
|
||||
import { CardContent } from '@mui/material';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: 'auto',
|
||||
paddingBottom: "10px"
|
||||
},
|
||||
paper: {
|
||||
width: 200,
|
||||
height: 230,
|
||||
overflow: 'auto',
|
||||
},
|
||||
button: {
|
||||
margin: theme.spacing(0.5, 0),
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: "rgb(100, 170, 204)",
|
||||
border: "2px solid rgba(100, 170, 204)"
|
||||
}
|
||||
}));
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import {
|
||||
MythicDialogButton,
|
||||
MythicDialogBody,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection
|
||||
} from '../../MythicComponents/MythicDialogLayout';
|
||||
|
||||
const getCommandsQuery = gql`
|
||||
query GetCallbackDetails($callback_id: Int!) {
|
||||
@@ -39,85 +21,129 @@ query GetCallbackDetails($callback_id: Int!) {
|
||||
loadedcommands {
|
||||
command {
|
||||
cmd
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
payload {
|
||||
payloadtype {
|
||||
commands {
|
||||
name
|
||||
commands(where: {deleted: {_eq: false}}) {
|
||||
cmd
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
command(where: {deleted: {_eq: false}, payloadtype: {agent_type: {_eq: "command_augment"}}}){
|
||||
cmd
|
||||
id
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CustomListElement = React.memo(({valueObj, handleToggle}) => {
|
||||
return (
|
||||
<ListItem style={{padding: 0}} key={valueObj.display_value} role="listitem" button
|
||||
onClick={() => handleToggle(valueObj)}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={valueObj.checked}
|
||||
disableRipple
|
||||
inputProps={{'aria-labelledby': valueObj.label}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={valueObj.label} primary={valueObj.display_value}/>
|
||||
</ListItem>
|
||||
)
|
||||
});
|
||||
const CustomList = ({title, items, handleToggle}) => {
|
||||
const renderedList = React.useMemo( () => {
|
||||
return items.map((valueObj) => (
|
||||
<CustomListElement key={valueObj.display_value} valueObj={valueObj} handleToggle={handleToggle} />
|
||||
))
|
||||
}, [items, handleToggle]);
|
||||
return (
|
||||
<div className="mythic-transfer-list flex flex-column min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
|
||||
<div className="mythic-transfer-list-header bg-table-header text-xs font-700 leading-125 border-b text-primary">{title}</div>
|
||||
<div className="mythic-transfer-list-body flex-fill overflow-auto">
|
||||
<List dense component="div" role="list" style={{padding: 0, width: "100%"}}>
|
||||
{renderedList}
|
||||
</List>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
export function AddRemoveCallbackCommandsDialog(props) {
|
||||
const classes = useStyles();
|
||||
const [checked, setChecked] = React.useState([]);
|
||||
const [left, setLeft] = React.useState([]);
|
||||
const [originalLeft, setOriginalLeft] = React.useState([]);
|
||||
const [originalRight, setOriginalRight] = React.useState([]);
|
||||
const [right, setRight] = React.useState([]);
|
||||
const [leftTitle, setLeftTitle] = React.useState("Commands Not Included");
|
||||
const [rightTitle, setRightTitle] = React.useState("Commands Included");
|
||||
const leftChecked = intersection(checked, left);
|
||||
const rightChecked = intersection(checked, right);
|
||||
const leftTitle = "Commands Not Included";
|
||||
const rightTitle = "Commands Included";
|
||||
|
||||
useQuery(getCommandsQuery, {variables: {callback_id: props.callback_id},
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
setOriginalLeft(data.callback_by_pk.payload.payloadtype.commands);
|
||||
let originalLeftFromQuery = data.callback_by_pk.payload.payloadtype.commands.map(c => {
|
||||
return {...c, payloadtype: {name: data.callback_by_pk.payload.payloadtype.name}}
|
||||
})
|
||||
originalLeftFromQuery = [...originalLeftFromQuery, ...data.command];
|
||||
setOriginalRight(data.callback_by_pk.loadedcommands);
|
||||
const leftData = data.callback_by_pk.payload.payloadtype.commands.reduce( (prev, cur) => {
|
||||
if( data.callback_by_pk.loadedcommands.filter(c => c.command.cmd === cur.cmd).length === 0){
|
||||
|
||||
let leftData = originalLeftFromQuery.reduce( (prev, cur) => {
|
||||
if( data.callback_by_pk.loadedcommands.filter(c => c.command.cmd === cur.cmd && c.command.payloadtype.name === cur.payloadtype.name).length === 0){
|
||||
return [...prev, cur];
|
||||
} else {
|
||||
return [...prev];
|
||||
}
|
||||
}, []);
|
||||
|
||||
leftData.sort( (a,b) => a.cmd < b.cmd ? -1 : 1);
|
||||
leftData = leftData.map( c => {return {...c,
|
||||
checked: false,
|
||||
display_value: c.cmd + " (" + c?.payloadtype?.name + ")",
|
||||
label: `transfer-list-item-${c.cmd + " (" + c?.payloadtype?.name + ")"}-label`
|
||||
}});
|
||||
setLeft(leftData);
|
||||
const rightData = data.callback_by_pk.loadedcommands.map( c => c.command);
|
||||
let rightData = data.callback_by_pk.loadedcommands.map( c => c.command);
|
||||
rightData.sort( (a,b) => a.cmd < b.cmd ? -1 : 1);
|
||||
rightData = rightData.map( c => {return {...c,
|
||||
checked: false,
|
||||
display_value: c.cmd + " (" + c?.payloadtype?.name + ")",
|
||||
label: `transfer-list-item-${c.cmd + " (" + c?.payloadtype?.name + ")"}-label`
|
||||
}});
|
||||
setRight(rightData);
|
||||
},
|
||||
onError: (data) => {
|
||||
|
||||
}
|
||||
})
|
||||
function not(a, b) {
|
||||
if(props.itemKey){
|
||||
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) === undefined)
|
||||
}
|
||||
return a.filter((value) => b.indexOf(value) === -1);
|
||||
}
|
||||
|
||||
function intersection(a, b) {
|
||||
if(props.itemKey){
|
||||
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) !== undefined)
|
||||
}
|
||||
return a.filter((value) => b.indexOf(value) !== -1);
|
||||
}
|
||||
const handleToggle = (value) => () => {
|
||||
let currentIndex = -1;
|
||||
if(props.itemKey){
|
||||
currentIndex = checked.findIndex( (element) => element[props.itemKey] === value[props.itemKey]);
|
||||
}else{
|
||||
currentIndex = checked.indexOf(value);
|
||||
}
|
||||
|
||||
const newChecked = [...checked];
|
||||
|
||||
if (currentIndex === -1) {
|
||||
newChecked.push(value);
|
||||
} else {
|
||||
newChecked.splice(currentIndex, 1);
|
||||
const handleToggle = (value) => {
|
||||
let found = false;
|
||||
const newLeft = left.map( c => {
|
||||
if(c.display_value === value.display_value){
|
||||
c.checked = !c.checked;
|
||||
found = true;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
if(found){
|
||||
setLeft(newLeft);
|
||||
return
|
||||
}
|
||||
|
||||
setChecked(newChecked);
|
||||
const newRight = right.map( c => {
|
||||
if(c.display_value === value.display_value){
|
||||
c.checked = !c.checked;
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setRight(newRight);
|
||||
};
|
||||
|
||||
const handleAllRight = () => {
|
||||
@@ -126,127 +152,110 @@ export function AddRemoveCallbackCommandsDialog(props) {
|
||||
};
|
||||
|
||||
const handleCheckedRight = () => {
|
||||
setRight(right.concat(leftChecked));
|
||||
setLeft(not(left, leftChecked));
|
||||
setChecked(not(checked, leftChecked));
|
||||
let leftChecked = left.filter(c => c.checked);
|
||||
leftChecked = leftChecked.map(c => {return {...c, checked: false}})
|
||||
let leftNotChecked = left.filter(c => !c.checked);
|
||||
setRight(right.concat(leftChecked));
|
||||
setLeft(leftNotChecked);
|
||||
};
|
||||
|
||||
const handleCheckedLeft = () => {
|
||||
setLeft(left.concat(rightChecked));
|
||||
setRight(not(right, rightChecked));
|
||||
setChecked(not(checked, rightChecked));
|
||||
let rightChecked = right.filter(c => c.checked);
|
||||
rightChecked = rightChecked.map(c => {return {...c, checked: false}})
|
||||
let rightNotChecked = right.filter(c => !c.checked);
|
||||
setLeft(left.concat(rightChecked));
|
||||
setRight(rightNotChecked);
|
||||
};
|
||||
|
||||
const handleAllLeft = () => {
|
||||
setLeft(left.concat(right));
|
||||
setRight([]);
|
||||
};
|
||||
const customList = (title, items) => (
|
||||
<React.Fragment>
|
||||
<CardHeader className={classes.cardHeader} title={title} />
|
||||
<Divider classes={{root: classes.divider}}/>
|
||||
<CardContent style={{flexGrow: 1, overflowY: "auto", padding: 0}}>
|
||||
<List dense component="div" role="list" style={{padding:0, width: "100%"}}>
|
||||
{items.map((valueObj) => {
|
||||
const value = valueObj.cmd;
|
||||
const labelId = `transfer-list-item-${value}-label`;
|
||||
return (
|
||||
<ListItem style={{padding:0}} key={value} role="listitem" button onClick={handleToggle(valueObj)}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={checked.findIndex( (element) => element.cmd === value) !== -1}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText id={labelId} primary={value} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
<ListItem />
|
||||
</List>
|
||||
</CardContent>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
const setFinalTags = () => {
|
||||
// things to add are in the `right` now but weren't for `originalRight`
|
||||
const commandsToAdd = right.filter( (command) => {
|
||||
return originalRight.filter(orig => orig.command.cmd == command.cmd).length == 0;
|
||||
return originalRight.filter(orig => orig.command.cmd === command.cmd && orig.command.payloadtype.name === command.payloadtype.name).length === 0;
|
||||
});
|
||||
const commandsToRemove = originalRight.filter( (command) => {
|
||||
return right.filter(newCommand => newCommand.cmd == command.command.cmd).length == 0;
|
||||
return right.filter(newCommand => newCommand.cmd === command.command.cmd && newCommand.payloadtype.name === command.command.payloadtype.name).length === 0;
|
||||
})
|
||||
props.onSubmit({commandsToAdd, commandsToRemove});
|
||||
props.onClose();
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Add or Remove Commands for Callback {props.display_id} </DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", display: "flex", flexDirection: "column", position: "relative", maxHeight: "100%"}}>
|
||||
This will add or remove commands associated with this callback from Mythic's perspective.
|
||||
This does NOT add or remove commands within the payload itself that's beaconing out to Mythic.
|
||||
<div style={{display: "flex", flexDirection: "row", overflowY: "auto", flexGrow: 1, minHeight: 0}}>
|
||||
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
|
||||
{customList(leftTitle, left)}
|
||||
</div>
|
||||
<div style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
disabled={leftChecked.length === 0}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
disabled={rightChecked.length === 0}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllLeft}
|
||||
disabled={right.length === 0}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</Button>
|
||||
|
||||
</div>
|
||||
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||
{customList(rightTitle, right)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle>Add or Remove Commands for Callback {props.display_id} </DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", display: "flex", flexDirection: "column", position: "relative", overflowY: "auto"}}>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Command Availability"
|
||||
description="Updates Mythic's callback command association without changing commands inside the active payload."
|
||||
>
|
||||
<div style={{display: "flex", flexDirection: "row", overflowY: "auto", flexGrow: 1, minHeight: 0}}>
|
||||
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
|
||||
<CustomList title={leftTitle} items={left} handleToggle={handleToggle}/>
|
||||
</div>
|
||||
<div className="mythic-transfer-controls py-0 px-4 items-center flex flex-column gap-6 justify-center">
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleCheckedRight}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleCheckedLeft}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={handleAllLeft}
|
||||
disabled={right.length === 0}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</MythicActionButton>
|
||||
|
||||
</div>
|
||||
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||
<CustomList title={rightTitle} items={right} handleToggle={handleToggle} />
|
||||
</div>
|
||||
</div>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={setFinalTags} variant="contained" color="success">
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton intent="primary" onClick={setFinalTags}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import {createSandboxedWorker} from "./BrowserScriptSandbox";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10000;
|
||||
|
||||
const toError = (errorData, phase) => {
|
||||
const error = new Error(errorData?.message || "Browser script failed");
|
||||
error.name = errorData?.name || "Error";
|
||||
error.stack = errorData?.stack || error.stack;
|
||||
error.phase = phase;
|
||||
return error;
|
||||
};
|
||||
|
||||
export class BrowserScriptRunner {
|
||||
constructor({workerFactory=createSandboxedWorker, timeoutMS=DEFAULT_TIMEOUT_MS} = {}) {
|
||||
this.workerFactory = workerFactory;
|
||||
this.timeoutMS = timeoutMS;
|
||||
this.nextRequestID = 1;
|
||||
this.latestRequestID = 0;
|
||||
this.activeRequest = undefined;
|
||||
this.queuedRequest = undefined;
|
||||
this.workerScript = undefined;
|
||||
this.disposed = false;
|
||||
}
|
||||
|
||||
startWorker = () => {
|
||||
this.worker = this.workerFactory();
|
||||
this.worker.onmessage = this.handleMessage;
|
||||
this.worker.onerror = this.handleWorkerError;
|
||||
};
|
||||
|
||||
restartWorker = () => {
|
||||
if(this.worker){
|
||||
this.worker.onmessage = null;
|
||||
this.worker.onerror = null;
|
||||
this.worker.terminate();
|
||||
this.worker = undefined;
|
||||
}
|
||||
this.workerScript = undefined;
|
||||
};
|
||||
|
||||
run = ({script, task, responses, onResult, onError}) => {
|
||||
if(this.disposed){
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: this.nextRequestID++,
|
||||
script,
|
||||
task,
|
||||
responses,
|
||||
onResult,
|
||||
onError,
|
||||
};
|
||||
this.latestRequestID = request.id;
|
||||
this.queuedRequest = request;
|
||||
if(!this.activeRequest){
|
||||
this.runQueuedRequest();
|
||||
}
|
||||
return request.id;
|
||||
};
|
||||
|
||||
cancel = (requestID) => {
|
||||
if(this.queuedRequest?.id === requestID){
|
||||
this.queuedRequest = undefined;
|
||||
}
|
||||
if(this.activeRequest?.id === requestID){
|
||||
this.activeRequest.cancelled = true;
|
||||
}
|
||||
if(this.latestRequestID === requestID){
|
||||
this.latestRequestID = 0;
|
||||
}
|
||||
};
|
||||
|
||||
runQueuedRequest = () => {
|
||||
if(this.disposed || this.activeRequest || !this.queuedRequest){
|
||||
return;
|
||||
}
|
||||
|
||||
const request = this.queuedRequest;
|
||||
this.queuedRequest = undefined;
|
||||
this.activeRequest = request;
|
||||
const message = {
|
||||
type: "render",
|
||||
requestID: request.id,
|
||||
task: request.task,
|
||||
responses: request.responses,
|
||||
};
|
||||
if(request.script !== this.workerScript){
|
||||
message.script = request.script;
|
||||
this.workerScript = request.script;
|
||||
}
|
||||
|
||||
request.timeout = setTimeout(() => {
|
||||
this.finishActiveRequest({
|
||||
error: new Error(`Browser script exceeded the ${this.timeoutMS}ms time limit`),
|
||||
restartWorker: true,
|
||||
});
|
||||
}, this.timeoutMS);
|
||||
|
||||
try {
|
||||
if(!this.worker){
|
||||
this.startWorker();
|
||||
}
|
||||
this.worker.postMessage(message);
|
||||
} catch (error) {
|
||||
this.finishActiveRequest({error, restartWorker: true});
|
||||
}
|
||||
};
|
||||
|
||||
handleMessage = ({data}) => {
|
||||
const request = this.activeRequest;
|
||||
if(!request || data?.requestID !== request.id){
|
||||
return;
|
||||
}
|
||||
if(data.type === "log"){
|
||||
if(!request.cancelled && request.id === this.latestRequestID && !this.queuedRequest){
|
||||
console.log(...(Array.isArray(data.args) ? data.args : [data.args]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(data.type === "error"){
|
||||
if(data.phase === "compile"){
|
||||
this.workerScript = undefined;
|
||||
}
|
||||
this.finishActiveRequest({error: toError(data.error, data.phase)});
|
||||
return;
|
||||
}
|
||||
if(data.type === "result"){
|
||||
this.finishActiveRequest({result: data.result});
|
||||
}
|
||||
};
|
||||
|
||||
handleWorkerError = (event) => {
|
||||
event?.preventDefault?.();
|
||||
if(!this.activeRequest){
|
||||
this.restartWorker();
|
||||
return;
|
||||
}
|
||||
this.finishActiveRequest({
|
||||
error: new Error(event?.message || "Browser script worker failed"),
|
||||
restartWorker: true,
|
||||
});
|
||||
};
|
||||
|
||||
finishActiveRequest = ({result, error, restartWorker=false}) => {
|
||||
const request = this.activeRequest;
|
||||
if(!request){
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(request.timeout);
|
||||
this.activeRequest = undefined;
|
||||
const isCurrent = !request.cancelled && request.id === this.latestRequestID && !this.queuedRequest;
|
||||
|
||||
if(restartWorker){
|
||||
this.restartWorker();
|
||||
}
|
||||
if(isCurrent){
|
||||
if(error){
|
||||
request.onError?.(error);
|
||||
}else{
|
||||
request.onResult?.(result);
|
||||
}
|
||||
}
|
||||
this.runQueuedRequest();
|
||||
};
|
||||
|
||||
dispose = () => {
|
||||
this.disposed = true;
|
||||
if(this.activeRequest){
|
||||
clearTimeout(this.activeRequest.timeout);
|
||||
}
|
||||
this.activeRequest = undefined;
|
||||
this.queuedRequest = undefined;
|
||||
this.restartWorker();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import {BrowserScriptRunner} from "./BrowserScriptRunner";
|
||||
|
||||
class MockWorker {
|
||||
constructor() {
|
||||
this.messages = [];
|
||||
this.terminated = false;
|
||||
}
|
||||
|
||||
postMessage = (message) => {
|
||||
this.messages.push(message);
|
||||
};
|
||||
|
||||
terminate = () => {
|
||||
this.terminated = true;
|
||||
};
|
||||
|
||||
respond = (data) => {
|
||||
this.onmessage?.({data});
|
||||
};
|
||||
}
|
||||
|
||||
const createRunner = (options = {}) => {
|
||||
const workers = [];
|
||||
const workerFactory = jest.fn(() => {
|
||||
const worker = new MockWorker();
|
||||
workers.push(worker);
|
||||
return worker;
|
||||
});
|
||||
return {
|
||||
runner: new BrowserScriptRunner({workerFactory, ...options}),
|
||||
workerFactory,
|
||||
workers,
|
||||
};
|
||||
};
|
||||
|
||||
const makeRequest = (overrides = {}) => ({
|
||||
script: "function(task, responses) { return {plaintext: responses.join('')}; }",
|
||||
task: {id: 1},
|
||||
responses: ["one"],
|
||||
onResult: jest.fn(),
|
||||
onError: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("BrowserScriptRunner", () => {
|
||||
test("creates its worker lazily and reuses the compiled script", () => {
|
||||
const {runner, workerFactory, workers} = createRunner();
|
||||
expect(workerFactory).not.toHaveBeenCalled();
|
||||
|
||||
const first = makeRequest();
|
||||
const firstID = runner.run(first);
|
||||
expect(workerFactory).toHaveBeenCalledTimes(1);
|
||||
expect(workers[0].messages[0]).toMatchObject({
|
||||
type: "render",
|
||||
requestID: firstID,
|
||||
script: first.script,
|
||||
});
|
||||
workers[0].respond({type: "result", requestID: firstID, result: {plaintext: "one"}});
|
||||
|
||||
const second = makeRequest({responses: ["two"]});
|
||||
const secondID = runner.run(second);
|
||||
expect(workerFactory).toHaveBeenCalledTimes(1);
|
||||
expect(workers[0].messages[1]).toEqual({
|
||||
type: "render",
|
||||
requestID: secondID,
|
||||
task: second.task,
|
||||
responses: second.responses,
|
||||
});
|
||||
workers[0].respond({type: "result", requestID: secondID, result: {plaintext: "two"}});
|
||||
|
||||
expect(first.onResult).toHaveBeenCalledWith({plaintext: "one"});
|
||||
expect(second.onResult).toHaveBeenCalledWith({plaintext: "two"});
|
||||
runner.dispose();
|
||||
});
|
||||
|
||||
test("coalesces updates while a render is running", () => {
|
||||
const {runner, workers} = createRunner();
|
||||
const first = makeRequest({responses: ["one"]});
|
||||
const second = makeRequest({responses: ["two"]});
|
||||
const latest = makeRequest({responses: ["three"]});
|
||||
const firstID = runner.run(first);
|
||||
runner.run(second);
|
||||
const latestID = runner.run(latest);
|
||||
|
||||
expect(workers[0].messages).toHaveLength(1);
|
||||
workers[0].respond({type: "result", requestID: firstID, result: {plaintext: "one"}});
|
||||
|
||||
expect(first.onResult).not.toHaveBeenCalled();
|
||||
expect(second.onResult).not.toHaveBeenCalled();
|
||||
expect(workers[0].messages).toHaveLength(2);
|
||||
expect(workers[0].messages[1]).toMatchObject({
|
||||
requestID: latestID,
|
||||
responses: ["three"],
|
||||
});
|
||||
|
||||
workers[0].respond({type: "result", requestID: latestID, result: {plaintext: "three"}});
|
||||
expect(latest.onResult).toHaveBeenCalledWith({plaintext: "three"});
|
||||
runner.dispose();
|
||||
});
|
||||
|
||||
test("restarts a timed-out worker and reports the current failure", () => {
|
||||
jest.useFakeTimers();
|
||||
const {runner, workers} = createRunner({timeoutMS: 25});
|
||||
const request = makeRequest();
|
||||
runner.run(request);
|
||||
|
||||
jest.advanceTimersByTime(25);
|
||||
|
||||
expect(workers[0].terminated).toBe(true);
|
||||
expect(request.onError).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: "Browser script exceeded the 25ms time limit",
|
||||
}));
|
||||
|
||||
const next = makeRequest({responses: ["after timeout"]});
|
||||
runner.run(next);
|
||||
expect(workers).toHaveLength(2);
|
||||
expect(workers[1].messages[0].script).toBe(next.script);
|
||||
runner.dispose();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test("returns worker errors and reloads scripts after compile failures", () => {
|
||||
const {runner, workers} = createRunner();
|
||||
const request = makeRequest();
|
||||
const requestID = runner.run(request);
|
||||
workers[0].respond({
|
||||
type: "error",
|
||||
requestID,
|
||||
phase: "compile",
|
||||
error: {name: "SyntaxError", message: "Unexpected token"},
|
||||
});
|
||||
|
||||
expect(request.onError).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: "SyntaxError",
|
||||
message: "Unexpected token",
|
||||
phase: "compile",
|
||||
}));
|
||||
|
||||
const retry = makeRequest();
|
||||
runner.run(retry);
|
||||
expect(workers[0].messages[1].script).toBe(retry.script);
|
||||
runner.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// The frame owns only a worker and a private message port. It never evaluates
|
||||
// service code in its window. The opaque origin denies application storage;
|
||||
// the blob worker inherits this CSP, which denies all network destinations.
|
||||
export const sandboxDocument = `<!doctype html>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; worker-src blob:; connect-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<script>
|
||||
addEventListener('message', function initialize(event) {
|
||||
if (event.source !== parent || !event.ports[0]) return;
|
||||
removeEventListener('message', initialize);
|
||||
const port = event.ports[0];
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([event.data], {type: 'text/javascript'}));
|
||||
const worker = new Worker(url);
|
||||
URL.revokeObjectURL(url);
|
||||
worker.onmessage = event => port.postMessage(event.data);
|
||||
worker.onerror = event => {
|
||||
event.preventDefault();
|
||||
port.postMessage({type: 'sandbox-error', message: event.message});
|
||||
};
|
||||
port.onmessage = event => worker.postMessage(event.data);
|
||||
port.start();
|
||||
} catch (error) {
|
||||
port.postMessage({type: 'sandbox-error', message: error.message});
|
||||
}
|
||||
});
|
||||
</script>`;
|
||||
|
||||
let workerSource;
|
||||
const loadWorkerSource = () => {
|
||||
if (!workerSource) {
|
||||
workerSource = fetch("/new/browser-script-worker.js").then(response => {
|
||||
if (!response.ok) throw new Error("Failed to load browser script worker");
|
||||
return response.text();
|
||||
}).catch(error => {
|
||||
workerSource = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return workerSource;
|
||||
};
|
||||
|
||||
// Worker-compatible adapter preserves the runner's queue, cancellation and
|
||||
// timeout behavior. Removing the frame also terminates its worker.
|
||||
export const createSandboxedWorker = () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute("sandbox", "allow-scripts");
|
||||
frame.hidden = true;
|
||||
frame.title = "Browser script sandbox";
|
||||
const channel = new MessageChannel();
|
||||
let disposed = false;
|
||||
const adapter = {
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
postMessage: message => channel.port1.postMessage(message),
|
||||
terminate: () => {
|
||||
disposed = true;
|
||||
frame.remove();
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
},
|
||||
};
|
||||
channel.port1.onmessage = event => {
|
||||
if (disposed) return;
|
||||
if (event.data?.type === "sandbox-error") {
|
||||
adapter.onerror?.({message: event.data.message});
|
||||
} else {
|
||||
adapter.onmessage?.(event);
|
||||
}
|
||||
};
|
||||
const loaded = new Promise(resolve => { frame.onload = resolve; });
|
||||
frame.srcdoc = sandboxDocument;
|
||||
document.body.appendChild(frame);
|
||||
Promise.all([loaded, loadWorkerSource()]).then(([, source]) => {
|
||||
if (!disposed) frame.contentWindow.postMessage(source, "*", [channel.port2]);
|
||||
}).catch(error => {
|
||||
if (!disposed) adapter.onerror?.(error);
|
||||
});
|
||||
return adapter;
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import vm from "vm";
|
||||
|
||||
const workerSource = fs.readFileSync(
|
||||
path.resolve(__dirname, "../../../../public/browser-script-worker.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const createWorkerContext = () => {
|
||||
const messages = [];
|
||||
const context = {
|
||||
console: {log: jest.fn()},
|
||||
self: {
|
||||
postMessage: (message) => messages.push(message),
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(workerSource, context);
|
||||
return {messages, worker: context.self};
|
||||
};
|
||||
|
||||
test("browser scripts run without page globals or local storage", () => {
|
||||
const {messages, worker} = createWorkerContext();
|
||||
worker.onmessage({
|
||||
data: {
|
||||
type: "render",
|
||||
requestID: 1,
|
||||
script: `function() {
|
||||
return {
|
||||
plaintext: [typeof window, typeof document, typeof localStorage].join(","),
|
||||
};
|
||||
}`,
|
||||
task: {id: 1},
|
||||
responses: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(messages).toEqual([{
|
||||
type: "result",
|
||||
requestID: 1,
|
||||
result: {plaintext: "undefined,undefined,undefined"},
|
||||
}]);
|
||||
});
|
||||
|
||||
test("the worker reuses its loaded function for later responses", () => {
|
||||
const {messages, worker} = createWorkerContext();
|
||||
worker.onmessage({
|
||||
data: {
|
||||
type: "render",
|
||||
requestID: 1,
|
||||
script: "function(task, responses) { return {plaintext: task.id + responses.join('')}; }",
|
||||
task: {id: 7},
|
||||
responses: ["first"],
|
||||
},
|
||||
});
|
||||
worker.onmessage({
|
||||
data: {
|
||||
type: "render",
|
||||
requestID: 2,
|
||||
task: {id: 8},
|
||||
responses: ["second"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(messages[1]).toEqual({
|
||||
type: "result",
|
||||
requestID: 2,
|
||||
result: {plaintext: "8second"},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
import React from "react";
|
||||
import {gql, useQuery, useSubscription} from "@apollo/client";
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
|
||||
const browserTreeFragment = gql`
|
||||
fragment callbackBrowserTreeData on mythictree {
|
||||
comment
|
||||
deleted
|
||||
task_id
|
||||
filemeta {
|
||||
id
|
||||
agent_file_id
|
||||
filename_text
|
||||
}
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
host
|
||||
id
|
||||
os
|
||||
can_have_children
|
||||
has_children
|
||||
success
|
||||
full_path_text
|
||||
display_path_text
|
||||
name_text
|
||||
timestamp
|
||||
parent_path_text
|
||||
tree_type
|
||||
metadata
|
||||
callback {
|
||||
id
|
||||
display_id
|
||||
mythictree_groups
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const rootBrowserTreeQuery = gql`
|
||||
${browserTreeFragment}
|
||||
query callbackBrowserTreeRoots($tree_type: String!) {
|
||||
mythictree(
|
||||
where: {parent_path_text: {_eq: ""}, tree_type: {_eq: $tree_type}}
|
||||
order_by: {id: asc}
|
||||
) {
|
||||
...callbackBrowserTreeData
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const browserTreeSubscription = gql`
|
||||
${browserTreeFragment}
|
||||
subscription callbackBrowserTreeStream($now: timestamp!, $tree_type: String!) {
|
||||
mythictree_stream(
|
||||
batch_size: 1000
|
||||
cursor: {initial_value: {timestamp: $now}}
|
||||
where: {tree_type: {_eq: $tree_type}}
|
||||
) {
|
||||
...callbackBrowserTreeData
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const processTreeSubscription = gql`
|
||||
${browserTreeFragment}
|
||||
subscription callbackProcessTreeStream($now: timestamp!) {
|
||||
mythictree_stream(
|
||||
batch_size: 1000
|
||||
cursor: {initial_value: {timestamp: $now}}
|
||||
where: {tree_type: {_eq: "process"}, deleted: {_eq: false}}
|
||||
) {
|
||||
...callbackBrowserTreeData
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const getGroups = (row) => row?.callback?.mythictree_groups || ["Unknown Callbacks"];
|
||||
|
||||
const decodeFilename = (value) => {
|
||||
if(!value){return "";}
|
||||
try{
|
||||
const text = window.atob(value);
|
||||
const bytes = Uint8Array.from(text, (character) => character.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}catch(error){
|
||||
try{
|
||||
return decodeURIComponent(window.atob(value));
|
||||
}catch(secondError){
|
||||
return value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uniqueBy = (values = [], getKey = (value) => value?.id) => {
|
||||
const result = [];
|
||||
const indexes = new Map();
|
||||
values.forEach((value) => {
|
||||
const key = getKey(value);
|
||||
if(key === undefined || key === null){
|
||||
result.push(value);
|
||||
return;
|
||||
}
|
||||
if(indexes.has(key)){
|
||||
result[indexes.get(key)] = value;
|
||||
}else{
|
||||
indexes.set(key, result.length);
|
||||
result.push(value);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const uniquePermissions = (values = []) => uniqueBy(values, (value) => {
|
||||
if(value && typeof value === "object"){
|
||||
const keys = Object.keys(value).sort();
|
||||
return JSON.stringify(value, keys);
|
||||
}
|
||||
return `${typeof value}:${value}`;
|
||||
});
|
||||
|
||||
const decodeFilemeta = (values = []) => values.map((file) => ({
|
||||
...file,
|
||||
filename_text: decodeFilename(file.filename_text),
|
||||
}));
|
||||
const uniqueFilemeta = (values = []) => uniqueBy(values).sort((left, right) => left.id - right.id);
|
||||
|
||||
const rowFingerprint = (row) => JSON.stringify({
|
||||
id: row.id,
|
||||
task_id: row.task_id,
|
||||
timestamp: row.timestamp,
|
||||
deleted: row.deleted,
|
||||
success: row.success,
|
||||
comment: row.comment,
|
||||
has_children: row.has_children,
|
||||
metadata: row.metadata,
|
||||
tags: (row.tags || []).map((tag) => tag.id),
|
||||
filemeta: (row.filemeta || []).map((file) => [file.id, file.filename_text]),
|
||||
callback: row.callback?.id,
|
||||
callbackGroups: row.callback?.mythictree_groups,
|
||||
});
|
||||
|
||||
const cloneRow = (row, mode) => ({
|
||||
...row,
|
||||
metadata: {...(row.metadata || {})},
|
||||
tags: uniqueBy(row.tags || []),
|
||||
filemeta: mode === "file" ? uniqueFilemeta(decodeFilemeta(row.filemeta || [])) : [...(row.filemeta || [])],
|
||||
...(mode === "process" ? {callbacks: row.callback ? [row.callback] : []} : {}),
|
||||
});
|
||||
|
||||
const mergeFileRow = (existing, row) => {
|
||||
const next = {...existing};
|
||||
if((existing.task_id ?? -1) <= (row.task_id ?? -1)){
|
||||
next.deleted = row.deleted;
|
||||
}
|
||||
if((existing.success === null || !existing.success) && row.success !== null){
|
||||
next.success = row.success;
|
||||
next.task_id = row.task_id;
|
||||
}
|
||||
next.comment = row.comment;
|
||||
next.tags = uniqueBy([...(existing.tags || []), ...(row.tags || [])]);
|
||||
next.has_children = row.has_children || existing.has_children;
|
||||
next.metadata = {...(existing.metadata || {})};
|
||||
["size", "access_time", "modify_time"].forEach((key) => {
|
||||
next.metadata[key] = row.metadata?.[key];
|
||||
});
|
||||
if(row.metadata?.permissions !== undefined && row.metadata?.permissions !== null){
|
||||
if(Array.isArray(existing.metadata?.permissions) && Array.isArray(row.metadata.permissions)){
|
||||
next.metadata.permissions = uniquePermissions([
|
||||
...existing.metadata.permissions,
|
||||
...row.metadata.permissions,
|
||||
]);
|
||||
}else if(Array.isArray(existing.metadata?.permissions) !== Array.isArray(row.metadata.permissions)){
|
||||
next.metadata.permissions = row.metadata.permissions;
|
||||
}else{
|
||||
next.metadata.permissions = {
|
||||
...(existing.metadata?.permissions || {}),
|
||||
...(row.metadata.permissions || {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
next.filemeta = uniqueFilemeta([...(existing.filemeta || []), ...decodeFilemeta(row.filemeta || [])]);
|
||||
return next;
|
||||
};
|
||||
|
||||
const mergeCustomRow = (existing, row) => {
|
||||
if((existing.task_id ?? -1) >= (row.task_id ?? -1)){
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
deleted: row.deleted,
|
||||
success: ((existing.success === null || !existing.success) && row.success !== null) ? row.success : existing.success,
|
||||
comment: row.comment,
|
||||
tags: uniqueBy(row.tags || []),
|
||||
has_children: row.has_children || existing.has_children,
|
||||
metadata: {...(existing.metadata || {}), ...(row.metadata || {})},
|
||||
display_path_text: row.display_path_text,
|
||||
task_id: row.task_id,
|
||||
};
|
||||
};
|
||||
|
||||
const mergeProcessRow = (existing, row) => {
|
||||
if((existing.task_id ?? -1) <= (row.task_id ?? -1) && row.deleted){
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
has_children: row.has_children || existing.has_children,
|
||||
callbacks: uniqueBy([...(existing.callbacks || []), ...(row.callback ? [row.callback] : [])]),
|
||||
comment: `${existing.comment || ""}${row.comment || ""}`,
|
||||
tags: uniqueBy([...(existing.tags || []), ...(row.tags || [])]),
|
||||
metadata: (existing.task_id ?? -1) > (row.task_id ?? -1) ?
|
||||
{...(row.metadata || {}), ...(existing.metadata || {})} :
|
||||
{...(existing.metadata || {}), ...(row.metadata || {})},
|
||||
task_id: Math.max(existing.task_id ?? -1, row.task_id ?? -1),
|
||||
};
|
||||
};
|
||||
|
||||
const mergeRow = (existing, row, mode) => {
|
||||
if(!existing){
|
||||
if(mode === "process" && row.deleted){return null;}
|
||||
return cloneRow(row, mode);
|
||||
}
|
||||
if(mode === "file"){
|
||||
return mergeFileRow(existing, row);
|
||||
}
|
||||
if(mode === "process"){
|
||||
return mergeProcessRow(existing, row);
|
||||
}
|
||||
return mergeCustomRow(existing, row);
|
||||
};
|
||||
|
||||
const cloneMatrixBucket = (matrix, group, host, parent, cloned) => {
|
||||
if(!cloned.root){
|
||||
matrix = {...matrix};
|
||||
cloned.root = true;
|
||||
}
|
||||
if(!cloned.groups.has(group)){
|
||||
matrix[group] = {...(matrix[group] || {})};
|
||||
cloned.groups.add(group);
|
||||
}
|
||||
const hostKey = `${group}\u0000${host}`;
|
||||
if(!cloned.hosts.has(hostKey)){
|
||||
matrix[group][host] = {...(matrix[group][host] || {})};
|
||||
cloned.hosts.add(hostKey);
|
||||
}
|
||||
const parentKey = `${hostKey}\u0000${parent}`;
|
||||
if(!cloned.parents.has(parentKey)){
|
||||
matrix[group][host][parent] = {...(matrix[group][host][parent] || {})};
|
||||
cloned.parents.add(parentKey);
|
||||
}
|
||||
return matrix;
|
||||
};
|
||||
|
||||
export const mergeBrowserTreeRows = (entry, rows, mode = entry.mode) => {
|
||||
let matrix = entry.matrix;
|
||||
let changed = false;
|
||||
const changedBuckets = new Set();
|
||||
const cloned = {root: false, groups: new Set(), hosts: new Set(), parents: new Set()};
|
||||
|
||||
rows.forEach((row) => {
|
||||
const fingerprint = rowFingerprint(row);
|
||||
if(entry.fingerprints.get(row.id) === fingerprint){return;}
|
||||
entry.fingerprints.set(row.id, fingerprint);
|
||||
getGroups(row).forEach((group) => {
|
||||
entry.data[group] = entry.data[group] || {};
|
||||
entry.data[group][row.host] = entry.data[group][row.host] || {};
|
||||
const existing = entry.data[group][row.host][row.full_path_text];
|
||||
let next = mergeRow(existing, row, mode);
|
||||
if(next === existing){return;}
|
||||
const previousParent = existing?.parent_path_text ?? row.parent_path_text ?? "";
|
||||
const nextParent = row.parent_path_text ?? previousParent;
|
||||
if(next && next.parent_path_text !== nextParent){next = {...next, parent_path_text: nextParent};}
|
||||
matrix = cloneMatrixBucket(matrix, group, row.host, previousParent, cloned);
|
||||
if(nextParent !== previousParent){
|
||||
matrix = cloneMatrixBucket(matrix, group, row.host, nextParent, cloned);
|
||||
delete matrix[group][row.host][previousParent][row.full_path_text];
|
||||
}
|
||||
if(next === null){
|
||||
delete entry.data[group][row.host][row.full_path_text];
|
||||
delete matrix[group][row.host][previousParent][row.full_path_text];
|
||||
}else{
|
||||
entry.data[group][row.host][row.full_path_text] = next;
|
||||
matrix[group][row.host][nextParent][row.full_path_text] = 1;
|
||||
}
|
||||
changedBuckets.add(`${group}\u0000${row.host}\u0000${previousParent}`);
|
||||
if(nextParent !== previousParent){changedBuckets.add(`${group}\u0000${row.host}\u0000${nextParent}`);}
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
if(changed){entry.matrix = matrix;}
|
||||
return {changed, changedBuckets};
|
||||
};
|
||||
|
||||
const createEntry = (mode) => ({
|
||||
mode,
|
||||
data: {},
|
||||
dataRef: {current: {}},
|
||||
matrix: {},
|
||||
version: 0,
|
||||
hydrated: false,
|
||||
fingerprints: new Map(),
|
||||
loadedScopes: new Set(),
|
||||
listeners: new Set(),
|
||||
changeListeners: new Set(),
|
||||
});
|
||||
|
||||
export const createCallbackBrowserTreeStore = () => {
|
||||
const entries = new Map();
|
||||
const getEntry = (treeType, mode = treeType === "process" ? "process" : treeType === "file" ? "file" : "custom") => {
|
||||
if(!entries.has(treeType)){
|
||||
const entry = createEntry(mode);
|
||||
entry.dataRef.current = entry.data;
|
||||
entries.set(treeType, entry);
|
||||
}
|
||||
return entries.get(treeType);
|
||||
};
|
||||
const publish = (entry) => {
|
||||
entry.version += 1;
|
||||
entry.listeners.forEach((listener) => listener());
|
||||
};
|
||||
return {
|
||||
getEntry,
|
||||
mergeRows(treeType, mode, rows, {notify = false, hydrated = false} = {}){
|
||||
const entry = getEntry(treeType, mode);
|
||||
const result = mergeBrowserTreeRows(entry, rows, mode);
|
||||
if(hydrated){entry.hydrated = true;}
|
||||
if(result.changed || hydrated){publish(entry);}
|
||||
if(notify && result.changed){
|
||||
entry.changeListeners.forEach((listener) => listener(result));
|
||||
}
|
||||
return {...result, matrix: entry.matrix, data: entry.data};
|
||||
},
|
||||
setMatrix(treeType, matrix){
|
||||
const entry = getEntry(treeType);
|
||||
entry.matrix = matrix;
|
||||
publish(entry);
|
||||
},
|
||||
subscribe(treeType, listener){
|
||||
const entry = getEntry(treeType);
|
||||
entry.listeners.add(listener);
|
||||
return () => entry.listeners.delete(listener);
|
||||
},
|
||||
subscribeToChanges(treeType, listener){
|
||||
const entry = getEntry(treeType);
|
||||
entry.changeListeners.add(listener);
|
||||
return () => entry.changeListeners.delete(listener);
|
||||
},
|
||||
getSnapshot(treeType){return getEntry(treeType).version;},
|
||||
isScopeLoaded(treeType, scope){return getEntry(treeType).loadedScopes.has(scope);},
|
||||
markScopeLoaded(treeType, scope){getEntry(treeType).loadedScopes.add(scope);},
|
||||
};
|
||||
};
|
||||
|
||||
const CallbackBrowserTreeContext = React.createContext(null);
|
||||
|
||||
const getTreeSources = (openTabs) => {
|
||||
const sources = new Map();
|
||||
openTabs.forEach((tab) => {
|
||||
if(tab.tabType === "fileBrowser"){
|
||||
sources.set("file", {treeType: "file", mode: "file"});
|
||||
}else if(tab.tabType === "processBrowser"){
|
||||
sources.set("process", {treeType: "process", mode: "process"});
|
||||
}else if(tab.tabType === "customFileBasedBrowser" && tab.customBrowser?.name){
|
||||
sources.set(tab.customBrowser.name, {treeType: tab.customBrowser.name, mode: "custom"});
|
||||
}
|
||||
});
|
||||
return [...sources.values()];
|
||||
};
|
||||
|
||||
const CallbackBrowserTreeSource = ({treeType, mode, store}) => {
|
||||
const fromNow = React.useRef(getSkewedNow());
|
||||
useQuery(rootBrowserTreeQuery, {
|
||||
variables: {tree_type: treeType},
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => store.mergeRows(treeType, mode, data.mythictree || [], {hydrated: true}),
|
||||
});
|
||||
useSubscription(mode === "process" ? processTreeSubscription : browserTreeSubscription, {
|
||||
variables: mode === "process" ? {now: fromNow.current} : {now: fromNow.current, tree_type: treeType},
|
||||
fetchPolicy: "no-cache",
|
||||
ignoreResults: true,
|
||||
onData: ({data}) => store.mergeRows(treeType, mode, data.data?.mythictree_stream || [], {notify: true}),
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
export const CallbackBrowserTreeProvider = ({openTabs, children}) => {
|
||||
const storeRef = React.useRef();
|
||||
if(!storeRef.current){storeRef.current = createCallbackBrowserTreeStore();}
|
||||
const sources = React.useMemo(() => getTreeSources(openTabs), [openTabs]);
|
||||
return (
|
||||
<CallbackBrowserTreeContext.Provider value={storeRef.current}>
|
||||
{sources.map((source) => (
|
||||
<CallbackBrowserTreeSource key={source.treeType} {...source} store={storeRef.current} />
|
||||
))}
|
||||
{children}
|
||||
</CallbackBrowserTreeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const emptySubscribe = () => () => {};
|
||||
|
||||
export const useCallbackBrowserTree = ({treeType, mode, active, onInactiveChange}) => {
|
||||
const store = React.useContext(CallbackBrowserTreeContext);
|
||||
if(!store){throw new Error("useCallbackBrowserTree must be used inside CallbackBrowserTreeProvider");}
|
||||
store.getEntry(treeType, mode);
|
||||
const subscribe = React.useCallback(
|
||||
(listener) => active ? store.subscribe(treeType, listener) : emptySubscribe(listener),
|
||||
[active, store, treeType]
|
||||
);
|
||||
React.useSyncExternalStore(
|
||||
subscribe,
|
||||
React.useCallback(() => store.getSnapshot(treeType), [store, treeType]),
|
||||
React.useCallback(() => store.getSnapshot(treeType), [store, treeType]),
|
||||
);
|
||||
React.useEffect(() => store.subscribeToChanges(treeType, () => {
|
||||
if(!active){onInactiveChange?.();}
|
||||
}), [active, onInactiveChange, store, treeType]);
|
||||
const entry = store.getEntry(treeType, mode);
|
||||
return {
|
||||
treeRootDataRef: entry.dataRef,
|
||||
treeAdjMtx: entry.matrix,
|
||||
version: entry.version,
|
||||
hydrated: entry.hydrated,
|
||||
mergeRows: React.useCallback(
|
||||
(rows, options) => store.mergeRows(treeType, mode, rows, options),
|
||||
[mode, store, treeType]
|
||||
),
|
||||
setTreeAdjMtx: React.useCallback(
|
||||
(matrix) => store.setMatrix(treeType, matrix),
|
||||
[store, treeType]
|
||||
),
|
||||
isScopeLoaded: React.useCallback(
|
||||
(scope) => store.isScopeLoaded(treeType, scope),
|
||||
[store, treeType]
|
||||
),
|
||||
markScopeLoaded: React.useCallback(
|
||||
(scope) => store.markScopeLoaded(treeType, scope),
|
||||
[store, treeType]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDefaultBrowserSelection = (treeData, matrix, callbackID, preferredHost) => {
|
||||
const groups = Object.keys(matrix).sort();
|
||||
if(groups.length === 0){return {group: "Default", host: preferredHost || ""};}
|
||||
let callbackGroup;
|
||||
for(const group of groups){
|
||||
for(const hostNodes of Object.values(treeData[group] || {})){
|
||||
for(const node of Object.values(hostNodes || {})){
|
||||
if(node?.callback?.id === callbackID){callbackGroup = group; break;}
|
||||
}
|
||||
if(callbackGroup){break;}
|
||||
}
|
||||
if(callbackGroup){break;}
|
||||
}
|
||||
const group = callbackGroup || (groups.includes("Default") ? "Default" : groups[0]);
|
||||
const hosts = Object.keys(matrix[group] || {}).sort();
|
||||
return {group, host: hosts.includes(preferredHost) ? preferredHost : (hosts[0] || preferredHost || "")};
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
jest.mock("../../utilities/Time", () => ({getSkewedNow: () => "2026-01-01T00:00:00Z"}));
|
||||
|
||||
import {createCallbackBrowserTreeStore} from "./CallbackBrowserTreeStore";
|
||||
|
||||
const callback = {id: 7, display_id: 7, mythictree_groups: ["Default"]};
|
||||
const row = (overrides = {}) => ({
|
||||
id: 1,
|
||||
task_id: 1,
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
deleted: false,
|
||||
success: true,
|
||||
comment: "first",
|
||||
tags: [{id: 1, tagtype: {id: 1, name: "a", color: "red"}}],
|
||||
filemeta: [],
|
||||
host: "HOST",
|
||||
can_have_children: true,
|
||||
has_children: true,
|
||||
full_path_text: "/",
|
||||
name_text: "/",
|
||||
parent_path_text: "",
|
||||
tree_type: "file",
|
||||
metadata: {size: 1, permissions: ["r"]},
|
||||
callback,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("callback browser tree store", () => {
|
||||
test("merges a streamed file update with the existing precedence", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
store.mergeRows("file", "file", [row()], {hydrated: true});
|
||||
store.mergeRows("file", "file", [row({
|
||||
id: 2,
|
||||
task_id: 2,
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
deleted: true,
|
||||
success: false,
|
||||
comment: "new",
|
||||
tags: [{id: 2, tagtype: {id: 2, name: "b", color: "blue"}}],
|
||||
metadata: {size: 2, access_time: 3, modify_time: 4, permissions: ["w"]},
|
||||
})], {notify: true});
|
||||
const node = store.getEntry("file").data.Default.HOST["/"];
|
||||
expect(node.deleted).toBe(true);
|
||||
expect(node.comment).toBe("new");
|
||||
expect(node.tags.map((tag) => tag.id)).toEqual([1, 2]);
|
||||
expect(node.metadata).toMatchObject({size: 2, access_time: 3, modify_time: 4, permissions: ["r", "w"]});
|
||||
});
|
||||
|
||||
test("ignores exact replay and emits no duplicate notification", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
const listener = jest.fn();
|
||||
store.subscribeToChanges("file", listener);
|
||||
const streamedRow = row();
|
||||
store.mergeRows("file", "file", [streamedRow], {notify: true});
|
||||
store.mergeRows("file", "file", [streamedRow], {notify: true});
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(store.getEntry("file").data.Default.HOST["/"].tags).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("deduplicates tags, callbacks, and file metadata while preserving newer values", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
store.mergeRows("process", "process", [row({tree_type: "process", comment: "a"})]);
|
||||
store.mergeRows("process", "process", [row({
|
||||
id: 2,
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
tree_type: "process",
|
||||
comment: "b",
|
||||
})]);
|
||||
const process = store.getEntry("process").data.Default.HOST["/"];
|
||||
expect(process.callbacks).toHaveLength(1);
|
||||
expect(process.tags).toHaveLength(1);
|
||||
expect(process.comment).toBe("ab");
|
||||
|
||||
store.mergeRows("file", "file", [row({filemeta: [{id: 3, filename_text: ""}]})]);
|
||||
store.mergeRows("file", "file", [row({
|
||||
id: 3,
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
filemeta: [{id: 3, filename_text: ""}],
|
||||
})]);
|
||||
expect(store.getEntry("file").data.Default.HOST["/"].filemeta).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("does not decode an already-normalized filename again on later updates", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
store.mergeRows("file", "file", [row({filemeta: [{id: 3, filename_text: "dGVzdA=="}]})]);
|
||||
store.mergeRows("file", "file", [row({
|
||||
id: 2,
|
||||
task_id: 2,
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
filemeta: [],
|
||||
})]);
|
||||
expect(store.getEntry("file").data.Default.HOST["/"].filemeta[0].filename_text).toBe("test");
|
||||
});
|
||||
|
||||
test("updates only the affected adjacency branches", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
store.mergeRows("file", "file", [
|
||||
row(),
|
||||
row({id: 2, host: "OTHER", full_path_text: "C:\\", timestamp: "2026-01-01T00:00:01Z"}),
|
||||
]);
|
||||
const before = store.getEntry("file").matrix;
|
||||
const untouchedHost = before.Default.OTHER;
|
||||
store.mergeRows("file", "file", [row({
|
||||
id: 3,
|
||||
full_path_text: "/tmp",
|
||||
parent_path_text: "/",
|
||||
timestamp: "2026-01-01T00:00:02Z",
|
||||
})]);
|
||||
const after = store.getEntry("file").matrix;
|
||||
expect(after).not.toBe(before);
|
||||
expect(after.Default.OTHER).toBe(untouchedHost);
|
||||
expect(after.Default.HOST["/"]["/tmp"]).toBe(1);
|
||||
});
|
||||
|
||||
test("moves an existing node between parent buckets without rebuilding unrelated hosts", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
store.mergeRows("custom", "custom", [row({
|
||||
tree_type: "custom",
|
||||
full_path_text: "/child",
|
||||
parent_path_text: "/old",
|
||||
})]);
|
||||
store.mergeRows("custom", "custom", [row({
|
||||
tree_type: "custom",
|
||||
task_id: 2,
|
||||
timestamp: "2026-01-01T00:00:01Z",
|
||||
full_path_text: "/child",
|
||||
parent_path_text: "/new",
|
||||
})]);
|
||||
const matrix = store.getEntry("custom").matrix.Default.HOST;
|
||||
expect(matrix["/old"]["/child"]).toBeUndefined();
|
||||
expect(matrix["/new"]["/child"]).toBe(1);
|
||||
});
|
||||
|
||||
test("historical hydration does not emit a background notification", () => {
|
||||
const store = createCallbackBrowserTreeStore();
|
||||
const listener = jest.fn();
|
||||
store.subscribeToChanges("custom", listener);
|
||||
store.mergeRows("custom", "custom", [row({tree_type: "custom"})], {hydrated: true});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(store.getEntry("custom").hydrated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,13 @@
|
||||
import {gql } from '@apollo/client';
|
||||
|
||||
export const hideCallbacksMutation = gql`
|
||||
mutation hideCallbacks ($callback_display_ids: [Int]!){
|
||||
updateCallback(input: {callback_display_ids: $callback_display_ids, active: false}) {
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const hideCallbackMutation = gql`
|
||||
mutation hideCallback ($callback_display_id: Int!){
|
||||
updateCallback(input: {callback_display_id: $callback_display_id, active: false}) {
|
||||
@@ -8,6 +16,14 @@ mutation hideCallback ($callback_display_id: Int!){
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const updateCallbackTriggerMutation = gql`
|
||||
mutation updateCallbackTriggerMutation ($callback_display_id: Int!, $trigger_on_checkin_after_time: Int!){
|
||||
updateCallback(input: {callback_display_id: $callback_display_id, trigger_on_checkin_after_time: $trigger_on_checkin_after_time}) {
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const toggleHideCallbackMutations = gql`
|
||||
mutation hideCallback ($callback_display_id: Int!, $active: Boolean!){
|
||||
updateCallback(input: {callback_display_id: $callback_display_id, active: $active}) {
|
||||
@@ -19,7 +35,7 @@ mutation hideCallback ($callback_display_id: Int!, $active: Boolean!){
|
||||
|
||||
export const removeEdgeMutation = gql`
|
||||
mutation removeEdgeMutation ($edge_id: Int!){
|
||||
callbackgraphedge_remove(edge_id: $edge_id) {
|
||||
callbackgraphedgeRemove(edge_id: $edge_id) {
|
||||
status
|
||||
error
|
||||
}
|
||||
@@ -28,7 +44,7 @@ mutation removeEdgeMutation ($edge_id: Int!){
|
||||
|
||||
export const addEdgeMutation = gql`
|
||||
mutation addEdgeMutation ($source_id: Int!, $destination_id: Int!, $c2profile: String!){
|
||||
callbackgraphedge_add(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
|
||||
callbackgraphedgeAdd(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
|
||||
status
|
||||
error
|
||||
}
|
||||
@@ -51,8 +67,8 @@ mutation lockCallack($callback_display_id: Int!){
|
||||
}
|
||||
`;
|
||||
export const updateDescriptionCallbackMutation = gql`
|
||||
mutation updateDescriptionCallack($callback_display_id: Int!, $description: String!){
|
||||
updateCallback(input: {callback_display_id: $callback_display_id, description: $description}) {
|
||||
mutation updateDescriptionCallback($callback_display_id: Int!, $description: String!, $color: String!){
|
||||
updateCallback(input: {callback_display_id: $callback_display_id, description: $description, color: $color}) {
|
||||
status
|
||||
error
|
||||
}
|
||||
@@ -74,3 +90,158 @@ mutation updateIPsCallback($callback_display_id: Int!, $ips: [String]!){
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const taskingDataFragment = gql`
|
||||
fragment taskData on task {
|
||||
comment
|
||||
parent_task_id
|
||||
agent_task_id
|
||||
tasking_location
|
||||
apitokens_id
|
||||
callback {
|
||||
display_id
|
||||
id
|
||||
user
|
||||
host
|
||||
integrity_level
|
||||
domain
|
||||
ip
|
||||
mythictree_groups
|
||||
pid
|
||||
}
|
||||
callback_id
|
||||
commentOperator{
|
||||
username
|
||||
}
|
||||
eventstepinstance {
|
||||
eventgroupinstance {
|
||||
eventgroup{
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
eventstep {
|
||||
name
|
||||
}
|
||||
}
|
||||
completed
|
||||
id
|
||||
display_id
|
||||
operator{
|
||||
username
|
||||
}
|
||||
original_params
|
||||
display_params
|
||||
status
|
||||
timestamp
|
||||
status_timestamp_submitted
|
||||
status_timestamp_processing
|
||||
status_timestamp_preprocessing
|
||||
command {
|
||||
cmd
|
||||
supported_ui_features
|
||||
id
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
}
|
||||
command_name
|
||||
opsec_pre_blocked
|
||||
opsec_pre_bypassed
|
||||
opsec_pre_bypass_user {
|
||||
id
|
||||
username
|
||||
}
|
||||
opsec_post_blocked
|
||||
opsec_post_bypassed
|
||||
opsec_post_bypass_user {
|
||||
id
|
||||
username
|
||||
}
|
||||
is_interactive_task
|
||||
interactive_task_type
|
||||
has_intercepted_response
|
||||
tasks(where: {is_interactive_task: {_eq: false}}, order_by: {id: asc}) {
|
||||
id
|
||||
comment
|
||||
agent_task_id
|
||||
apitokens_id
|
||||
commentOperator{
|
||||
username
|
||||
}
|
||||
callback {
|
||||
display_id
|
||||
id
|
||||
user
|
||||
host
|
||||
integrity_level
|
||||
domain
|
||||
ip
|
||||
mythictree_groups
|
||||
pid
|
||||
}
|
||||
completed
|
||||
subtask_group_name
|
||||
display_id
|
||||
operator{
|
||||
username
|
||||
}
|
||||
original_params
|
||||
display_params
|
||||
status
|
||||
timestamp
|
||||
command {
|
||||
cmd
|
||||
supported_ui_features
|
||||
id
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
}
|
||||
command_name
|
||||
response_count
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
tasks(order_by: {id: asc}) {
|
||||
id
|
||||
}
|
||||
has_intercepted_response
|
||||
}
|
||||
response_count
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
token {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const createTaskingMutation = gql`
|
||||
mutation createTasking($callback_display_id: Int, $callback_display_ids: [Int], $command: String!, $params: String!, $files: [String], $token_id: Int, $tasking_location: String, $original_params: String, $parameter_group_name: String, $parent_task_display_id: Int, $is_interactive_task: Boolean, $interactive_task_type: Int, $payload_type: String, $resolve_task_references: Boolean) {
|
||||
createTask(callback_display_id: $callback_display_id, callback_display_ids: $callback_display_ids, command: $command, params: $params, files: $files, token_id: $token_id, tasking_location: $tasking_location, original_params: $original_params, parameter_group_name: $parameter_group_name, parent_task_display_id: $parent_task_display_id, is_interactive_task: $is_interactive_task, interactive_task_type: $interactive_task_type, payload_type: $payload_type, resolve_task_references: $resolve_task_references) {
|
||||
status
|
||||
id
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const exportCallbackConfigQuery = gql`
|
||||
query exportCallbackConfigQuery($callback_display_id: Int!) {
|
||||
exportCallbackConfig(callback_display_id: $callback_display_id) {
|
||||
status
|
||||
error
|
||||
config
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,110 @@
|
||||
const EMPTY_CHILDREN = Object.freeze([]);
|
||||
|
||||
const comparableTaskFields = [
|
||||
"comment", "completed", "display_params", "original_params", "status", "timestamp",
|
||||
"response_count", "opsec_pre_blocked", "opsec_pre_bypassed", "opsec_post_blocked",
|
||||
"opsec_post_bypassed", "has_intercepted_response", "tasking_location",
|
||||
];
|
||||
|
||||
const relationIDsEqual = (left = [], right = []) => {
|
||||
if(left.length !== right.length){
|
||||
return false;
|
||||
}
|
||||
for(let index = 0; index < left.length; index++){
|
||||
if(left[index]?.id !== right[index]?.id){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const taskRowsEqual = (left, right) => {
|
||||
if(left === right){
|
||||
return true;
|
||||
}
|
||||
if(!left || !right || left.id !== right.id){
|
||||
return false;
|
||||
}
|
||||
for(const field of comparableTaskFields){
|
||||
if(left[field] !== right[field]){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return left.command?.id === right.command?.id &&
|
||||
left.operator?.username === right.operator?.username &&
|
||||
left.commentOperator?.username === right.commentOperator?.username &&
|
||||
relationIDsEqual(left.tags, right.tags) &&
|
||||
relationIDsEqual(left.tasks, right.tasks);
|
||||
};
|
||||
|
||||
export const mergeTasksByID = (existing = [], incoming = []) => {
|
||||
if(incoming.length === 0){
|
||||
return existing;
|
||||
}
|
||||
const tasksByID = new Map(existing.map((task) => [task.id, task]));
|
||||
incoming.forEach((task) => {
|
||||
const current = tasksByID.get(task.id);
|
||||
tasksByID.set(task.id, taskRowsEqual(current, task) ? current : task);
|
||||
});
|
||||
const merged = Array.from(tasksByID.values()).sort((left, right) => left.id - right.id);
|
||||
if(merged.length === existing.length && merged.every((task, index) => task === existing[index])){
|
||||
return existing;
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
export const nextLiveTaskLimit = (currentLimit, liveResultCount, increment = 20) => {
|
||||
return liveResultCount >= currentLimit ? currentLimit + increment : currentLimit;
|
||||
};
|
||||
|
||||
export const createTaskChildrenStore = () => {
|
||||
const childrenByParent = new Map();
|
||||
const listenersByParent = new Map();
|
||||
let highestTaskID = 0;
|
||||
|
||||
const notify = (parentID) => {
|
||||
listenersByParent.get(parentID)?.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const merge = (tasks = []) => {
|
||||
const incomingByParent = new Map();
|
||||
tasks.forEach((task) => {
|
||||
if(task?.parent_task_id === null || task?.parent_task_id === undefined){
|
||||
return;
|
||||
}
|
||||
highestTaskID = Math.max(highestTaskID, task.id || 0);
|
||||
const parentTasks = incomingByParent.get(task.parent_task_id) || [];
|
||||
parentTasks.push(task);
|
||||
incomingByParent.set(task.parent_task_id, parentTasks);
|
||||
});
|
||||
|
||||
const changedParents = [];
|
||||
incomingByParent.forEach((incoming, parentID) => {
|
||||
const current = childrenByParent.get(parentID) || EMPTY_CHILDREN;
|
||||
const merged = mergeTasksByID(current, incoming);
|
||||
if(merged !== current){
|
||||
childrenByParent.set(parentID, merged);
|
||||
changedParents.push(parentID);
|
||||
}
|
||||
});
|
||||
changedParents.forEach(notify);
|
||||
return changedParents;
|
||||
};
|
||||
|
||||
return {
|
||||
getHighestTaskID: () => highestTaskID,
|
||||
getSnapshot: (parentID) => childrenByParent.get(parentID) || EMPTY_CHILDREN,
|
||||
merge,
|
||||
subscribe: (parentID, listener) => {
|
||||
const listeners = listenersByParent.get(parentID) || new Set();
|
||||
listeners.add(listener);
|
||||
listenersByParent.set(parentID, listeners);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if(listeners.size === 0){
|
||||
listenersByParent.delete(parentID);
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import {createTaskChildrenStore, mergeTasksByID, nextLiveTaskLimit} from "./CallbackTaskingStreamUtils";
|
||||
|
||||
describe("callback task stream utilities", () => {
|
||||
test("merges tasks by id while preserving unchanged row identity", () => {
|
||||
const first = {id: 1, status: "complete", tasks: [], tags: [], command: {id: 1}, operator: {username: "a"}};
|
||||
const second = {id: 2, status: "running", tasks: [], tags: [], command: {id: 2}, operator: {username: "a"}};
|
||||
const merged = mergeTasksByID([first, second], [
|
||||
{...first},
|
||||
{...second, status: "complete"},
|
||||
{id: 3, status: "submitted", tasks: [], tags: [], command: {id: 3}, operator: {username: "a"}},
|
||||
]);
|
||||
|
||||
expect(merged.map((task) => task.id)).toEqual([1, 2, 3]);
|
||||
expect(merged[0]).toBe(first);
|
||||
expect(merged[1]).not.toBe(second);
|
||||
});
|
||||
|
||||
test("grows the live limit only when the live result window is saturated", () => {
|
||||
expect(nextLiveTaskLimit(20, 19)).toBe(20);
|
||||
expect(nextLiveTaskLimit(20, 20)).toBe(40);
|
||||
expect(nextLiveTaskLimit(40, 40)).toBe(60);
|
||||
});
|
||||
|
||||
test("groups children, deduplicates replay, and notifies only changed parents", () => {
|
||||
const store = createTaskChildrenStore();
|
||||
const parentOneListener = jest.fn();
|
||||
const parentTwoListener = jest.fn();
|
||||
store.subscribe(10, parentOneListener);
|
||||
store.subscribe(20, parentTwoListener);
|
||||
|
||||
const child = {id: 2, parent_task_id: 10, status: "running", tasks: [], tags: []};
|
||||
store.merge([child, {id: 3, parent_task_id: 20, status: "complete", tasks: [], tags: []}]);
|
||||
expect(store.getSnapshot(10)).toEqual([child]);
|
||||
expect(parentOneListener).toHaveBeenCalledTimes(1);
|
||||
expect(parentTwoListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.merge([{...child}]);
|
||||
expect(parentOneListener).toHaveBeenCalledTimes(1);
|
||||
|
||||
store.merge([{...child, status: "complete"}]);
|
||||
expect(parentOneListener).toHaveBeenCalledTimes(2);
|
||||
expect(parentTwoListener).toHaveBeenCalledTimes(1);
|
||||
expect(store.getHighestTaskID()).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,60 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React, { useEffect } from 'react';
|
||||
import { CallbacksTabs } from './CallbacksTabs';
|
||||
import SpeedDial from '@mui/material/SpeedDial';
|
||||
import SpeedDialIcon from '@mui/material/SpeedDialIcon';
|
||||
import SpeedDialAction from '@mui/material/SpeedDialAction';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import AspectRatioIcon from '@mui/icons-material/AspectRatio';
|
||||
import TocIcon from '@mui/icons-material/Toc';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { HeightsDialog } from './HeightsDialog';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import { CallbacksTop } from './CallbacksTop';
|
||||
import Split from 'react-split';
|
||||
import PhoneForwardedIcon from '@mui/icons-material/PhoneForwarded';
|
||||
import {MythicDialog} from "../../MythicComponents/MythicDialog";
|
||||
import {ImportCallbackConfigDialog} from "./ImportCallbackConfigDialog";
|
||||
import {reorder} from "../../MythicComponents/MythicDraggableList";
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
export const getCallbackIdFromClickedTab = (tabId) => {
|
||||
if(tabId === null || tabId === undefined){return 0}
|
||||
if(tabId === ""){return 0}
|
||||
if(tabId.includes("fileBrowser")) {
|
||||
return Number(tabId.split("fileBrowser")[0]);
|
||||
}else if(tabId.includes("interact")){
|
||||
return Number(tabId.split("interact")[0]);
|
||||
}else if(tabId.includes("processBrowser")) {
|
||||
return Number(tabId.split("processBrowser")[0]);
|
||||
}else if(tabId.includes("customFileBasedBrowser")) {
|
||||
return Number(tabId.split("customFileBasedBrowser")[0]);
|
||||
}else if(tabId.includes("customProcessBasedBrowser")) {
|
||||
return Number(tabId.split("customProcessBasedBrowser")[0]);
|
||||
} else {
|
||||
console.log("unknown tab type", tabId);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
transform: 'translateZ(0px)',
|
||||
flexGrow: 1,
|
||||
},
|
||||
speedDial: {
|
||||
position: 'absolute',
|
||||
'&.MuiSpeedDial-directionUp, &.MuiSpeedDial-directionLeft': {
|
||||
bottom: theme.spacing(2),
|
||||
right: theme.spacing(2),
|
||||
},
|
||||
'&.MuiSpeedDial-directionDown, &.MuiSpeedDial-directionRight': {
|
||||
top: theme.spacing(2),
|
||||
right: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: theme.palette.background.contrast,
|
||||
color: theme.palette.text.contrast,
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 13,
|
||||
},
|
||||
arrow: {
|
||||
color: theme.palette.background.contrast,
|
||||
},
|
||||
}));
|
||||
export function Callbacks({me}) {
|
||||
const theme = useTheme();
|
||||
const [openCallbackImport, setOpenCallbackImport] = React.useState(false);
|
||||
const [topDisplay, setTopDisplay] = React.useState('table');
|
||||
const [newDataForTab, setNewDataForTab] = React.useState({});
|
||||
const [openTabs, setOpenTabs] = React.useState([]);
|
||||
const [clickedTabId, setClickedTabId] = React.useState('');
|
||||
const [heights, setHeights] = React.useState({ top: '30%', bottom: '68%' });
|
||||
const openTabRef = React.useRef();
|
||||
const [clickedTabId, setClickedTabIdValue] = React.useState('');
|
||||
const openTabRef = React.useRef([]);
|
||||
const callbackTableGridRef = React.useRef();
|
||||
const [callbackTableSplitSizes, setCallbackTableSplitSizes] = React.useState([30, 70]);
|
||||
const setClickedTabId = (tabID) => {
|
||||
if(callbackTableGridRef.current){
|
||||
let tabIDNumber = getCallbackIdFromClickedTab(tabID);
|
||||
let rowIndex = callbackTableGridRef.current?.props?.itemData?.items?.findIndex((e) => {
|
||||
return e[0]?.props?.rowData?.id === tabIDNumber
|
||||
});
|
||||
if(rowIndex >= 0){
|
||||
callbackTableGridRef.current?.scrollToItem({rowIndex: rowIndex, align: "end", columnIndex: 0})
|
||||
}
|
||||
}
|
||||
setClickedTabIdValue(tabID);
|
||||
setNewDataForTab((prev) => {return {...prev, [tabID]: false}});
|
||||
}
|
||||
useEffect(() => {
|
||||
const oldTabs = localStorage.getItem('openTabs');
|
||||
if (oldTabs !== undefined && oldTabs !== null) {
|
||||
@@ -57,13 +69,12 @@ export function Callbacks({me}) {
|
||||
console.log('failed to parse oldTabs', error);
|
||||
}
|
||||
}
|
||||
const oldHeights = localStorage.getItem('heights');
|
||||
if (oldHeights !== undefined && oldHeights !== null) {
|
||||
try {
|
||||
const newHeights = JSON.parse(oldHeights);
|
||||
setHeights(newHeights);
|
||||
} catch (error) {
|
||||
console.log('failed to parse oldHeights', error);
|
||||
const oldSizes = localStorage.getItem("callbackTableSplitSizes");
|
||||
if (oldSizes) {
|
||||
try{
|
||||
setCallbackTableSplitSizes(JSON.parse(oldSizes));
|
||||
}catch(error){
|
||||
console.log("failed to parse callback table split sizes");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -72,6 +83,12 @@ export function Callbacks({me}) {
|
||||
}, [openTabs])
|
||||
const onOpenTab = React.useRef( (tabData) => {
|
||||
let found = false;
|
||||
openTabRef.current = openTabRef.current.map( (tab) => {
|
||||
if(tab.tabID === tabData.tabID){
|
||||
return {...tabData};
|
||||
}
|
||||
return {...tab};
|
||||
})
|
||||
openTabRef.current.forEach((tab) => {
|
||||
if (tab.tabID === tabData.tabID) found = true;
|
||||
});
|
||||
@@ -80,12 +97,30 @@ export function Callbacks({me}) {
|
||||
const tabs = [...openTabRef.current, { ...tabData }];
|
||||
localStorage.setItem('openTabs', JSON.stringify(tabs));
|
||||
setOpenTabs(tabs);
|
||||
} else {
|
||||
setOpenTabs([...openTabRef.current]);
|
||||
}
|
||||
localStorage.setItem('clickedTab', tabData.tabID);
|
||||
setClickedTabId(tabData.tabID);
|
||||
|
||||
});
|
||||
|
||||
const onOpenTabs = React.useRef( (tabData) => {
|
||||
let currentTabs = [...openTabRef.current];
|
||||
for(let i = 0; i < tabData.length; i++){
|
||||
let found = false;
|
||||
currentTabs.forEach((tab) => {
|
||||
if (tab.tabID === tabData[i].tabID) found = true;
|
||||
});
|
||||
if (!found) {
|
||||
currentTabs = [...currentTabs, { ...tabData[i] }];
|
||||
}
|
||||
}
|
||||
localStorage.setItem('openTabs', JSON.stringify(currentTabs));
|
||||
setOpenTabs(currentTabs);
|
||||
localStorage.setItem('clickedTab', tabData[0].tabID);
|
||||
setClickedTabId(tabData[0].tabID);
|
||||
|
||||
});
|
||||
const onEditTabDescription = React.useCallback( (tabInfo, description) => {
|
||||
const tabs = openTabs.map((t) => {
|
||||
if (t.tabID === tabInfo.tabID) {
|
||||
@@ -103,12 +138,11 @@ export function Callbacks({me}) {
|
||||
});
|
||||
localStorage.setItem('openTabs', JSON.stringify(tabSet));
|
||||
setOpenTabs(tabSet);
|
||||
if(tabSet.length === 0){
|
||||
setClickedTabId("0");
|
||||
localStorage.removeItem("clickedTab");
|
||||
}
|
||||
}, [openTabs]);
|
||||
|
||||
const onSubmitHeights = React.useCallback((newHeights) => {
|
||||
setHeights(newHeights);
|
||||
localStorage.setItem('heights', JSON.stringify(newHeights));
|
||||
}, []);
|
||||
const onDragTab = ({selected, toLeftOf}) => {
|
||||
//console.log("onDragTab in CallbacksTabs", selected, toLeftOf);
|
||||
let selectedPieces = selected.split("-");
|
||||
@@ -136,7 +170,7 @@ export function Callbacks({me}) {
|
||||
newOpenTabList.push(openTabs[targetTabIndex]);
|
||||
newOpenTabList.push(openTabs[i]);
|
||||
}
|
||||
|
||||
|
||||
setClickedTabId(openTabs[targetTabIndex].tabID)
|
||||
} else {
|
||||
newOpenTabList.push(openTabs[i]);
|
||||
@@ -169,116 +203,99 @@ export function Callbacks({me}) {
|
||||
}
|
||||
},
|
||||
];
|
||||
const onDragEnd = ({ destination, source }) => {
|
||||
// dropped outside the list
|
||||
if (!destination) return;
|
||||
const newItems = reorder(openTabs, source.index, destination.index);
|
||||
setOpenTabs(newItems);
|
||||
localStorage.setItem('openTabs', JSON.stringify(newItems));
|
||||
};
|
||||
return (
|
||||
<div style={{ maxWidth: '100%', height: '100%', flexDirection: 'column'}}>
|
||||
<React.Fragment>
|
||||
<SpeedDialWrapper setTopDisplay={setTopDisplay} heights={heights} onSubmitHeights={onSubmitHeights} />
|
||||
<div style={{flexGrow: 1, flexBasis: heights.top, height: heights.top }}>
|
||||
<CallbacksTop topDisplay={topDisplay} onOpenTab={onOpenTab.current} heights={heights} me={me}/>
|
||||
<>
|
||||
<Split direction="vertical"
|
||||
sizes={callbackTableSplitSizes}
|
||||
minSize={[0,0]}
|
||||
onDragEnd={(sizes) => localStorage.setItem('callbackTableSplitSizes', JSON.stringify(sizes))}
|
||||
style={{ height: "100%" }}>
|
||||
<div style={{display: "flex", flexDirection: "row-reverse", height: "100%", minWidth: 0, overflow: "hidden"}}>
|
||||
<Paper elevation={0} style={{
|
||||
width: "2.25rem",
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: `1px solid ${theme.borderColor}`,
|
||||
borderRight: 0,
|
||||
borderBottom: `1px solid ${theme.borderColor}`,
|
||||
borderLeft: `1px solid ${theme.borderColor}`,
|
||||
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
|
||||
paddingTop: "3px",
|
||||
}}>
|
||||
{topDisplay !== 'table' &&
|
||||
<MythicActionButton
|
||||
iconOnly
|
||||
appearance={"raised"}
|
||||
shape={"square"}
|
||||
variant={"outlined"}
|
||||
icon={<TocIcon />}
|
||||
tooltip={"Table View"}
|
||||
onClick={() =>setTopDisplay("table")}>
|
||||
</MythicActionButton>
|
||||
}
|
||||
{topDisplay !== 'graph' &&
|
||||
<MythicActionButton
|
||||
iconOnly
|
||||
appearance={"raised"}
|
||||
shape={"square"}
|
||||
variant={"outlined"}
|
||||
icon={<AssessmentIcon />}
|
||||
tooltip={"Graph View"}
|
||||
onClick={() =>setTopDisplay("graph")}>
|
||||
</MythicActionButton>
|
||||
}
|
||||
<MythicActionButton
|
||||
appearance={"raised"}
|
||||
iconOnly
|
||||
shape={"square"}
|
||||
colorMode={"hover"}
|
||||
tone={"neutral"}
|
||||
icon={<PhoneForwardedIcon />}
|
||||
tooltip={"Import previously exported Callbacks"}
|
||||
onClick={() =>setOpenCallbackImport(true)}>
|
||||
</MythicActionButton>
|
||||
{openCallbackImport &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openCallbackImport}
|
||||
onClose={()=>{setOpenCallbackImport(false);}}
|
||||
innerDialog={<ImportCallbackConfigDialog onClose={()=>{setOpenCallbackImport(false);}} />}
|
||||
/>
|
||||
}
|
||||
</Paper>
|
||||
<CallbacksTop
|
||||
callbackTableGridRef={callbackTableGridRef}
|
||||
topDisplay={topDisplay}
|
||||
onOpenTab={onOpenTab.current}
|
||||
onOpenTabs={onOpenTabs.current}
|
||||
me={me} clickedTabId={clickedTabId}/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, flexBasis: heights.bottom, height: heights.bottom }}>
|
||||
<div >
|
||||
<CallbacksTabs
|
||||
onCloseTab={onCloseTab}
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
tabHeight={heights.bottom}
|
||||
maxHeight={heights.bottom}
|
||||
key={'callbackstabs'}
|
||||
clickedTabId={clickedTabId}
|
||||
setClickedTabId={setClickedTabId}
|
||||
openTabs={openTabs}
|
||||
onDragTab={onDragTab}
|
||||
me={me}
|
||||
onDragEnd={onDragEnd}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
newDataForTab={newDataForTab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
</Split>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeedDialWrapperPreMemo({ setTopDisplay, onSubmitHeights, heights }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const classes = useStyles();
|
||||
const [openHeightsDialog, setOpenHeightsDialog] = React.useState(false);
|
||||
const submitHeights = (newHeights) => {
|
||||
setOpen(false);
|
||||
onSubmitHeights(newHeights);
|
||||
};
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <TocIcon />,
|
||||
name: 'Table layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('table');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <AssessmentIcon />,
|
||||
name: 'Graph layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('graph');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <AspectRatioIcon />,
|
||||
name: 'Adjust Top/Bottom Size',
|
||||
onClick: () => {
|
||||
setOpenHeightsDialog(true);
|
||||
setOpen(false);
|
||||
},
|
||||
},
|
||||
],
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{openHeightsDialog && (
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
maxWidth='sm'
|
||||
open={openHeightsDialog}
|
||||
onClose={() => {
|
||||
setOpenHeightsDialog(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
innerDialog={
|
||||
<HeightsDialog
|
||||
onClose={() => {
|
||||
setOpenHeightsDialog(false);
|
||||
setOpen(false);
|
||||
}}
|
||||
heights={heights}
|
||||
onSubmit={submitHeights}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SpeedDial
|
||||
ariaLabel='SpeedDial example'
|
||||
className={classes.speedDial}
|
||||
icon={<SpeedDialIcon />}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onOpen={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
FabProps={{ color: 'info' }}
|
||||
open={open}
|
||||
style={{ marginTop: '35px' }}
|
||||
direction='down'>
|
||||
{actions.map((action) => (
|
||||
<SpeedDialAction
|
||||
arrow
|
||||
key={action.name}
|
||||
TooltipClasses={{ tooltip: classes.tooltip, arrow: classes.arrow }}
|
||||
icon={action.icon}
|
||||
tooltipTitle={action.name}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</SpeedDial>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const SpeedDialWrapper = React.memo(SpeedDialWrapperPreMemo);
|
||||
@@ -1,17 +1,9 @@
|
||||
import React, {useRef, useEffect, useState, useMemo} from 'react';
|
||||
import {drawC2PathElements, getNodeEdges} from './C2PathDialog';
|
||||
import React, {useEffect, useState, useMemo, useContext} from 'react';
|
||||
import {DrawC2PathElementsFlowWithProvider} from './C2PathDialog';
|
||||
import {Button} from '@mui/material';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import {useMutation } from '@apollo/client';
|
||||
import {saveSvgAsPng} from 'save-svg-as-png';
|
||||
import {hideCallbackMutation, removeEdgeMutation, addEdgeMutation} from './CallbackMutations';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {MythicSelectFromListDialog} from '../../MythicComponents/MythicSelectFromListDialog';
|
||||
@@ -19,12 +11,16 @@ import {ManuallyAddEdgeDialog} from './ManuallyAddEdgeDialog';
|
||||
import {gql, useLazyQuery } from '@apollo/client';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import {TaskParametersDialog} from './TaskParametersDialog';
|
||||
import {createTaskingMutation} from './CallbacksTabsTasking';
|
||||
import {createTaskingMutation} from './CallbackMutations';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import {CallbackGraphEdgesContext, CallbacksContext} from './CallbacksTop';
|
||||
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {operatorSettingDefaults} from "../../../cache";
|
||||
import {useTaskReferenceSubmitter} from './taskingReferences';
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 1;
|
||||
@@ -60,17 +56,142 @@ query loadedLinkCommandsQuery ($callback_id: Int!){
|
||||
}
|
||||
`;
|
||||
|
||||
export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
const GraphViewOptions = ({viewConfig, setViewConfig}) => {
|
||||
const theme = useTheme();
|
||||
const dagreRef = useRef(null);
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
const [contextMenuData, setContextMenuData] = React.useState({
|
||||
"g": null, "elem": null, "pageX": 0, "pageY": 0
|
||||
});
|
||||
const [reZoom, setReZoom] = useState(true);
|
||||
const [showConfiguration, setShowConfiguration] = React.useState(false);
|
||||
const labelComponentOptions = ["display_id", "user", "host", "ip", "domain", "os", "process_name"];
|
||||
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(viewConfig?.label_components || ["display_id", "user"]);
|
||||
const [selectedGroupBy, setSelectedGroupBy] = React.useState(viewConfig?.group_by || "None");
|
||||
const groupByOptions = ["host", "user", "ip", "domain", "os", "process_name", "None"];
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setSelectedComponentOptions(
|
||||
// On autofill we get a stringified value.
|
||||
typeof value === 'string' ? value.split(',') : value,
|
||||
);
|
||||
};
|
||||
const handleGroupByChange = (event) => {
|
||||
setSelectedGroupBy(event.target.value);
|
||||
}
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, label_components: selectedComponentOptions})
|
||||
}, [selectedComponentOptions])
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, group_by: selectedGroupBy});
|
||||
}, [selectedGroupBy])
|
||||
const toggleViewConfig = (field) => {
|
||||
setViewConfig({...viewConfig, [field]: !viewConfig[field]});
|
||||
}
|
||||
const toggleRankDirection = () => {
|
||||
setViewConfig({...viewConfig, rankDir: viewConfig["rankDir"] === "LR" ? "TB" : "LR"});
|
||||
}
|
||||
return (
|
||||
<div className="mythic-callback-graph-options flex flex-column gap-4">
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-options-toggle rounded"
|
||||
onClick={() => setShowConfiguration(!showConfiguration)}
|
||||
>
|
||||
{showConfiguration ? "Hide Graph Options" : "Graph Options"}
|
||||
</Button>
|
||||
{showConfiguration &&
|
||||
<div className="mythic-callback-graph-options-panel flex flex-column gap-5 min-w-0 rounded bg-surface border-subtle">
|
||||
<div className="mythic-callback-graph-options-actions flex flex-wrap gap-3 min-w-0">
|
||||
<MythicActionButton
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button"
|
||||
colorMode={!viewConfig["include_disconnected"] ? "always" : "hover"}
|
||||
tone={!viewConfig["include_disconnected"] ? "success" : "neutral"}
|
||||
onClick={() => toggleViewConfig("include_disconnected")}
|
||||
>
|
||||
{viewConfig["include_disconnected"] ? "All Edges" : "Active Edges"}
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button"
|
||||
colorMode={viewConfig["show_all_nodes"] ? "always" : "hover"}
|
||||
tone={viewConfig["show_all_nodes"] ? "warning" : "neutral"}
|
||||
onClick={() => toggleViewConfig("show_all_nodes")}
|
||||
>
|
||||
{viewConfig["show_all_nodes"] ? "All Callbacks" : "Connected Callbacks"}
|
||||
</MythicActionButton>
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button rounded"
|
||||
onClick={toggleRankDirection}
|
||||
>
|
||||
{viewConfig["rankDir"] === "LR" ? "Left to Right" : "Top to Bottom"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button rounded"
|
||||
onClick={() => toggleViewConfig("packet_flow_view")}
|
||||
>
|
||||
{viewConfig["packet_flow_view"] ? "Egress Routes" : "Connection Directions"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mythic-callback-graph-options-fields gap-5 min-w-0 grid">
|
||||
<FormControl size="small" className="mythic-callback-graph-options-field">
|
||||
<InputLabel id="callback-graph-group-label">Group By</InputLabel>
|
||||
<Select
|
||||
labelId="callback-graph-group-label"
|
||||
id="callback-graph-group"
|
||||
value={selectedGroupBy}
|
||||
onChange={handleGroupByChange}
|
||||
input={<OutlinedInput id="select-callback-graph-group" label="Group By" />}
|
||||
>
|
||||
{groupByOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={name}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" className="mythic-callback-graph-options-field mythic-callback-graph-options-field-wide">
|
||||
<InputLabel id="callback-graph-label-components-label">Callback Labels</InputLabel>
|
||||
<Select
|
||||
labelId="callback-graph-label-components-label"
|
||||
id="callback-graph-label-components"
|
||||
multiple
|
||||
value={selectedComponentOptions}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-callback-graph-label-components" label="Callback Labels" />}
|
||||
MenuProps={MenuProps}
|
||||
renderValue={(selected) => selected.join(", ")}
|
||||
>
|
||||
{labelComponentOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={name}
|
||||
style={getStyles(name, selectedComponentOptions, theme)}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
{viewConfig["show_all_nodes"] &&
|
||||
<div className="mythic-callback-graph-options-warning text-xs rounded mythic-tone-warning bg-tone-1 border border-tone-2 text-tone">
|
||||
Showing all callbacks can be slow in large operations.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export function CallbacksGraph({onOpenTab}){
|
||||
const callbacks = useContext(CallbacksContext);
|
||||
const callbackgraphedges = useContext(CallbackGraphEdgesContext);
|
||||
//used for creating a task to do a link command
|
||||
const [loadingSettings, setLoadingSettings] = React.useState(true);
|
||||
const [filterOptions, setFilterOptions] = React.useState({});
|
||||
const [linkCommands, setLinkCommands] = React.useState([]);
|
||||
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
|
||||
const [openSelectLinkCommandDialog, setOpenSelectLinkCommandDialog] = React.useState(false);
|
||||
@@ -80,10 +201,6 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
const [manuallyAddEdgeDialogOpen, setManuallyAddEdgeDialogOpen] = useState(false);
|
||||
const [edgeOptions, setEdgeOptions] = useState([]); // used for manuallyRemoveEdgeDialog
|
||||
const [addEdgeSource, setAddEdgeSource] = useState(null); // used for manuallyAddEdgeDialog
|
||||
const labelComponentOptions = ["id", "user", "host", "ip", "domain", "os", "process_name"];
|
||||
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(["id", "user"]);
|
||||
const [selectedGroupBy, setSelectedGroupBy] = React.useState("host");
|
||||
const groupByOptions = ["host", "user", "ip", "domain", "os", "process_name", "extra_info"];
|
||||
const [getLinkCommands] = useLazyQuery(loadedLinkCommandsQuery, {fetchPolicy: "network-only",
|
||||
onCompleted: data => {
|
||||
const updatedCommands = data.loadedcommands.map( c => {return {command: {...c.command, parsedParameters: {}}}})
|
||||
@@ -116,36 +233,19 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
|
||||
}
|
||||
});
|
||||
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
|
||||
const submitParametersDialog = (cmd, parameters, files) => {
|
||||
setOpenParametersDialog(false);
|
||||
createTask({variables: {callback_id: selectedCallback.id, command: cmd, params: parameters, files}});
|
||||
submitTask({variables: {callback_display_id: selectedCallback.display_id, command: cmd, params: parameters, files}});
|
||||
}
|
||||
const [viewConfig, setViewConfig] = React.useState({
|
||||
rankDir: "BT",
|
||||
label_components: selectedComponentOptions,
|
||||
rankDir: "TB",
|
||||
label_components: ["display_id", "user"],
|
||||
packet_flow_view: true,
|
||||
include_disconnected: true,
|
||||
include_disconnected: false,
|
||||
show_all_nodes: false,
|
||||
group_by: selectedGroupBy
|
||||
group_by: "host"
|
||||
});
|
||||
const handleChange = (event) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = event;
|
||||
setSelectedComponentOptions(
|
||||
// On autofill we get a stringified value.
|
||||
typeof value === 'string' ? value.split(',') : value,
|
||||
);
|
||||
};
|
||||
const handleGroupByChange = (event) => {
|
||||
setSelectedGroupBy(event.target.value);
|
||||
}
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, label_components: selectedComponentOptions})
|
||||
}, [selectedComponentOptions])
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, group_by: selectedGroupBy});
|
||||
}, [selectedGroupBy])
|
||||
const [hideCallback] = useMutation(hideCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
//console.log(data);
|
||||
@@ -153,27 +253,34 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
onError: (error) => {
|
||||
console.log(error)
|
||||
snackActions.error(error.message);
|
||||
setContextMenuOpen(false);
|
||||
//setContextMenuOpen(false);
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
console.log(data)
|
||||
if(data.updateCallback.status === "success"){
|
||||
snackActions.success("Successfully hid callback")
|
||||
}else{
|
||||
snackActions.error(data.updateCallback.error)
|
||||
}
|
||||
|
||||
setContextMenuOpen(false);
|
||||
//setContextMenuOpen(false);
|
||||
}
|
||||
});
|
||||
const [manuallyRemoveEdge] = useMutation(removeEdgeMutation, {
|
||||
update: (cache, {data}) => {
|
||||
//console.log(data);
|
||||
snackActions.success("Successfully removed edge, updating graph...");
|
||||
},
|
||||
onError: (err) => {
|
||||
snackActions.error(err.message);
|
||||
}
|
||||
});
|
||||
const [manuallyAddEdge] = useMutation(addEdgeMutation, {
|
||||
update: (cache, {data}) => {
|
||||
//console.log(data);
|
||||
snackActions.success("Successfully added edge, updating graph...");
|
||||
},
|
||||
onError: (err) => {
|
||||
snackActions.error(err.message);
|
||||
}
|
||||
});
|
||||
const onSubmitManuallyRemoveEdge = (edge) => {
|
||||
@@ -181,223 +288,104 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
snackActions.warning("No edge selected");
|
||||
return;
|
||||
}
|
||||
manuallyRemoveEdge({variables: {edge_id: edge.edge_id}});
|
||||
manuallyRemoveEdge({variables: {edge_id: edge.id}});
|
||||
}
|
||||
const onSubmitManuallyAddEdge = (source_id, profile, destination) => {
|
||||
if(profile === "" || destination === ""){
|
||||
snackActions.warning("Profile or Destination Callback not provided");
|
||||
return;
|
||||
}
|
||||
manuallyAddEdge({variables: {source_id: source_id, c2profile: profile.name, destination_id: destination.id}});
|
||||
manuallyAddEdge({variables: {source_id: source_id, c2profile: profile.name, destination_id: destination.display_id}});
|
||||
}
|
||||
const node_events = useMemo(() => {return {
|
||||
"mouseover": (parent, node, d) => {return},
|
||||
"mouseout": (parent, node, d) => {return},
|
||||
"click": (parent, node, d) => {return},
|
||||
"contextmenu": [
|
||||
const contextMenu = useMemo(() => {return [
|
||||
{
|
||||
title: 'Hide Callback',
|
||||
action: function(g, elm) {
|
||||
hideCallback({variables: {callback_display_id: elm.node.display_id}});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Interact',
|
||||
action: function(g, elm){
|
||||
onOpenTab({tabType: "interact", tabID: elm.node.id + "interact", callbackID: elm.node.id});
|
||||
setContextMenuOpen(false);
|
||||
title: 'Open Tasking',
|
||||
onClick: function(node){
|
||||
onOpenTab({tabType: "interact", tabID: node.callback_id + "interact", callbackID: node.callback_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Manually Remove Edge",
|
||||
action: function(g, elm){
|
||||
const edges = getNodeEdges(g, elm.node.id);
|
||||
const opts = edges.reduce( (prev, e) => {
|
||||
const fulledg = g.edge(e.v, e.w, e.name);
|
||||
if(fulledg.end_timestamp === null){
|
||||
if(fulledg.direction === 1){
|
||||
if(viewConfig["packet_flow_view"]){
|
||||
return [...prev, {...fulledg, "display": e.w + " --> " + e.name + " --> " + e.v}];
|
||||
}else{
|
||||
return [...prev, {...fulledg, "display": e.v + " --> " + e.name + " --> " + e.w}];
|
||||
}
|
||||
}else{
|
||||
return [...prev, {...fulledg, "display": e.w + " --> " + e.name + " --> " + e.v}];
|
||||
}
|
||||
|
||||
}else{
|
||||
return [...prev];
|
||||
}
|
||||
title: "Add P2P Edge",
|
||||
onClick: function(node){
|
||||
setAddEdgeSource(node);
|
||||
setManuallyAddEdgeDialogOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Remove Active Edge",
|
||||
onClick: function(node){
|
||||
const opts = callbackgraphedges.reduce( (prev, e) => {
|
||||
if(e.source.id === node.id || e.destination.id === node.id){
|
||||
if(e.end_timestamp === null){
|
||||
if(e.source.id === e.destination.id){
|
||||
return [...prev, {...e, "display": e.source.display_id + " --> " + e.c2profile.name + " --> Mythic"}];
|
||||
} else {
|
||||
return [...prev, {...e, "display": e.source.display_id + " --> " + e.c2profile.name + " --> " + e.destination.display_id}];
|
||||
}
|
||||
|
||||
}else{
|
||||
return [...prev];
|
||||
}
|
||||
} else {
|
||||
return [...prev];
|
||||
}
|
||||
}, []);
|
||||
setEdgeOptions(opts);
|
||||
setManuallyRemoveEdgeDialogOpen(true);
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Manually Add Edge",
|
||||
action: function(g, elm){
|
||||
setAddEdgeSource(elm.node);
|
||||
setManuallyAddEdgeDialogOpen(true);
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Task Callback for Edge",
|
||||
action: function(g, elm){
|
||||
title: "Task Link Command",
|
||||
onClick: function(node){
|
||||
setLinkCommands([]);
|
||||
setSelectedLinkCommand(null);
|
||||
setSelectedCallback(null);
|
||||
getLinkCommands({variables: {callback_id: elm.node.id} });
|
||||
setSelectedCallback(elm.node);
|
||||
setContextMenuOpen(false);
|
||||
getLinkCommands({variables: {callback_id: node.id} });
|
||||
setSelectedCallback(node);
|
||||
}
|
||||
},
|
||||
]
|
||||
}}, [getLinkCommands, hideCallback, viewConfig, onOpenTab]);
|
||||
const handleDropdownToggle = (evt) => {
|
||||
evt.stopPropagation();
|
||||
setDropdownOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
options[index].click();
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
const options = [{name: viewConfig["include_disconnected"] ? 'Show Only Active Edges' : "Show All Edges", click: () => {
|
||||
const view = {...viewConfig, include_disconnected: !viewConfig["include_disconnected"]};
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, view, node_events, theme);
|
||||
setViewConfig(view);
|
||||
}},
|
||||
{name: viewConfig["show_all_nodes"] ? 'Hide inactive callbacks' : 'Show All Callbacks', click: () => {
|
||||
const view = {...viewConfig, show_all_nodes: !viewConfig["show_all_nodes"]};
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, view, node_events, theme);
|
||||
setViewConfig(view);
|
||||
}},
|
||||
{name: 'Autosize', click: () => {
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, viewConfig, node_events, theme);
|
||||
}},
|
||||
{name: viewConfig["rankDir"] === "LR" ? 'Change Layout to Top-Bottom' : "Change Layout to Left-Right", click: () => {
|
||||
if(viewConfig["rankDir"] === "LR"){
|
||||
const view = {...viewConfig, rankDir: "BT"};
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, view, node_events, theme);
|
||||
setViewConfig(view);
|
||||
}else{
|
||||
const view = {...viewConfig, rankDir: "LR"};
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, view, node_events, theme);
|
||||
setViewConfig(view);
|
||||
}
|
||||
}},
|
||||
{name: viewConfig["packet_flow_view"] ? "View Connection Directions" : "View Egress Routes" , click: () => {
|
||||
const view = {...viewConfig, packet_flow_view: !viewConfig["packet_flow_view"]};
|
||||
drawC2PathElements([...callbackgraphedges], dagreRef, true, view, node_events, theme);
|
||||
setViewConfig(view);
|
||||
}},
|
||||
{name: "Download Graph", click: () => {
|
||||
saveSvgAsPng(document.getElementById("callbacksgraph"), "diagram.png");
|
||||
}}];
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
{
|
||||
title: 'Hide Callback',
|
||||
onClick: function(node) {
|
||||
hideCallback({variables: {callback_display_id: node.display_id}});
|
||||
}
|
||||
},
|
||||
]}, [getLinkCommands, hideCallback, callbackgraphedges, onOpenTab]);
|
||||
React.useEffect( () => {
|
||||
try {
|
||||
const storageItemOptions = GetMythicSetting({setting_name: "callbacks_table_filter_options", default_value: operatorSettingDefaults.callbacks_table_filters});
|
||||
if(storageItemOptions !== null){
|
||||
setFilterOptions(storageItemOptions);
|
||||
}
|
||||
}catch(error){
|
||||
console.log("Failed to load callbacks_table_filter_options", error);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
const handleContextMenuClose = (event) => {
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
const setContextMenu = (event, graph, node) => {
|
||||
setContextMenuData({"g": graph, "elem": node, "pageX": event.pageX, "pageY": event.pageY});
|
||||
setContextMenuOpen(true);
|
||||
}
|
||||
useEffect( () => {
|
||||
const allEdges = [...callbackgraphedges];
|
||||
drawC2PathElements(allEdges, dagreRef, true, viewConfig, node_events, theme, setContextMenu);
|
||||
setReZoom(false);
|
||||
}, [callbackgraphedges, reZoom, viewConfig, theme]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
return (
|
||||
<div style={{maxWidth: "100%", "overflow": "auto", height: "100%"}}>
|
||||
<div style={{display: "flex"}} >
|
||||
<ButtonGroup variant="contained" ref={dropdownAnchorRef} aria-label="split button" style={{marginTop: "10px"}} color="primary">
|
||||
<Button size="small" color="primary" aria-controls={dropdownOpen ? 'split-button-menu' : undefined}
|
||||
aria-expanded={dropdownOpen ? 'true' : undefined}
|
||||
aria-haspopup="menu"
|
||||
onClick={handleDropdownToggle}>
|
||||
Actions <ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<FormControl sx={{ width: 200, marginTop: "10px" }}>
|
||||
<InputLabel id="demo-chip-label">Group Callbacks By</InputLabel>
|
||||
<Select
|
||||
labelId="demo-chip-label"
|
||||
id="demo-chip"
|
||||
|
||||
value={selectedGroupBy}
|
||||
onChange={handleGroupByChange}
|
||||
input={<OutlinedInput id="select-chip" label="Group Callbacks By" />}
|
||||
>
|
||||
{groupByOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={name}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{minWidth: 300, marginTop: "10px"}}>
|
||||
<InputLabel id="demo-multiple-chip-label">Display Properties per Callback</InputLabel>
|
||||
<Select
|
||||
labelId="demo-multiple-chip-label"
|
||||
id="demo-multiple-chip"
|
||||
multiple
|
||||
value={selectedComponentOptions}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-multiple-chip" label="Display Properties per Callback" />}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{labelComponentOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={name}
|
||||
style={getStyles(name, selectedComponentOptions, theme)}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
setLoadingSettings(false);
|
||||
}, []);
|
||||
if(loadingSettings){
|
||||
return (<div style={{width: '100%', height: '100%', position: "relative",}}>
|
||||
<div style={{overflowY: "hidden", flexGrow: 1}}>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "35%",
|
||||
top: "40%"
|
||||
}}>
|
||||
{"Loading Callbacks and Connections..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popper open={dropdownOpen} anchorEl={dropdownAnchorRef.current} transition role={undefined} style={{zIndex: 200}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu">
|
||||
{options.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</div>)
|
||||
}
|
||||
return (
|
||||
<div style={{maxWidth: "100%", "overflow": "hidden", height: "100%"}}>
|
||||
{taskReferenceSubmitDialog}
|
||||
|
||||
{manuallyRemoveEdgeDialogOpen &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={manuallyRemoveEdgeDialogOpen}
|
||||
onClose={()=>{setManuallyRemoveEdgeDialogOpen(false);}}
|
||||
innerDialog={<MythicSelectFromListDialog onClose={()=>{setManuallyRemoveEdgeDialogOpen(false);}} identifier="edge_id" display="display"
|
||||
onClose={() => {
|
||||
setManuallyRemoveEdgeDialogOpen(false);
|
||||
}}
|
||||
innerDialog={<MythicSelectFromListDialog onClose={() => {
|
||||
setManuallyRemoveEdgeDialogOpen(false);}} identifier="id" display="display"
|
||||
onSubmit={onSubmitManuallyRemoveEdge} options={edgeOptions} title={"Manually Remove Edge"} action={"remove"} />}
|
||||
/>
|
||||
}
|
||||
@@ -411,7 +399,7 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
{openParametersDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
|
||||
onClose={()=>{setOpenParametersDialog(false);}}
|
||||
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
|
||||
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} callback_display_id={selectedCallback?.display_id} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
|
||||
/>
|
||||
}
|
||||
{openSelectLinkCommandDialog &&
|
||||
@@ -422,26 +410,9 @@ export function CallbacksGraph({onOpenTab, callbackgraphedges}){
|
||||
action={"select"} display={"display"} identifier={"display"}/>}
|
||||
/>
|
||||
}
|
||||
<Popover open={contextMenuOpen} anchorReference="anchorPosition"
|
||||
anchorPosition={{top: contextMenuData.pageY, left: contextMenuData.pageX}}
|
||||
role={undefined} style={{zIndex: 200}}>
|
||||
<Paper style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}}>
|
||||
<ClickAwayListener onClickAway={handleContextMenuClose}>
|
||||
<MenuList id="split-button-menu">
|
||||
{node_events.contextmenu.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.title}
|
||||
onClick={(event) => option.action(contextMenuData.g, contextMenuData.elem)}
|
||||
>
|
||||
{option.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Popover>
|
||||
<svg id="callbacksgraph" ref={dagreRef} width="100%" height="100%"></svg>
|
||||
</div>
|
||||
<DrawC2PathElementsFlowWithProvider providedNodes={callbacks} edges={callbackgraphedges} view_config={viewConfig}
|
||||
filterOptions={filterOptions}
|
||||
panel={<GraphViewOptions setViewConfig={setViewConfig} viewConfig={viewConfig} />} contextMenu={contextMenu}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React from 'react';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {MythicDraggablePortal, reorder} from "../../MythicComponents/MythicDraggableList";
|
||||
import {
|
||||
Draggable,
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
} from "@hello-pangea/dnd";
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection,
|
||||
} from "../../MythicComponents/MythicDialogLayout";
|
||||
|
||||
export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onClose, onReset, visible, hidden}) {
|
||||
const [items, setItems] = React.useState(initialItems);
|
||||
const onDragEnd = ({ destination, source }) => {
|
||||
// dropped outside the list
|
||||
if (!destination) return;
|
||||
const newItems = reorder(items, source.index, destination.index);
|
||||
setItems(newItems);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
setItems((currentItems) => currentItems.map( c => {
|
||||
if(visible.includes(c.name)){
|
||||
return {...c, visible: true};
|
||||
}
|
||||
return {...c, visible: false};
|
||||
}));
|
||||
}, [visible, hidden]);
|
||||
const onToggleVisibility = (i) => {
|
||||
const newItems = items.map( (c, index) => {
|
||||
if(index === i){
|
||||
return {...c, visible: !c.visible};
|
||||
}
|
||||
return {...c};
|
||||
});
|
||||
setItems(newItems);
|
||||
}
|
||||
const onFinish = () => {
|
||||
onSubmit(items);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Column Layout</DialogTitle>
|
||||
<DialogContent dividers={true} sx={{p: 0, overflow: "hidden"}}>
|
||||
<MythicDialogBody sx={{height: "min(70vh, 42rem)", p: 1}}>
|
||||
<MythicDialogSection
|
||||
title="Columns"
|
||||
sx={{display: "flex", flexDirection: "column", flex: "1 1 auto", minHeight: 0}}
|
||||
>
|
||||
<DraggableList items={items} onToggleVisibility={onToggleVisibility} onDragEnd={onDragEnd} />
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={onClose}>
|
||||
Close
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={onReset} intent="warning">
|
||||
Reset
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={onFinish} intent="primary">
|
||||
Save
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export const DraggableList = ({ items, onDragEnd, onToggleVisibility }) => {
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="callback-table-column-list">
|
||||
{(provided) => (
|
||||
<div className="mythic-reorder-list flex flex-fill flex-column gap-4 min-h-0 overflow-auto" ref={provided.innerRef} {...provided.droppableProps}>
|
||||
{items.map((item, index) => (
|
||||
<DraggableListItem onToggleVisibility={onToggleVisibility} item={item} index={index} key={item.key} />
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
export const DraggableListItem = ({ item, index, onToggleVisibility }) => {
|
||||
return (
|
||||
<Draggable draggableId={item.key} index={index}>
|
||||
{(provided, snapshot) => {
|
||||
const row = (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
className={`mythic-reorder-row items-center flex flex-none gap-4 rounded bg-surface-raised border-subtle text-primary shadow-none${snapshot.isDragging ? " mythic-reorder-row-dragging mythic-tone-primary bg-tone-1 border-tone-3 shadow-3" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"} min-w-0 w-full`}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<span className="mythic-reorder-drag-handle items-center inline-flex justify-center rounded bg-neutral-2 border-subtle text-muted" {...provided.dragHandleProps}>
|
||||
<DragHandleIcon fontSize="small" />
|
||||
</span>
|
||||
<div className="mythic-reorder-row-main items-center flex flex-fill gap-4 min-w-0">
|
||||
<span className="mythic-reorder-row-title text-sm font-750 leading-125 min-w-0 truncate whitespace-nowrap">{item.name}</span>
|
||||
</div>
|
||||
<div className="mythic-reorder-row-actions items-center flex flex-none gap-3">
|
||||
<MythicActionButton iconOnly
|
||||
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
|
||||
appearance="raised" colorMode="hover" tone={item.visible ? "error" : "info"}
|
||||
size="small"
|
||||
onClick={() => onToggleVisibility(index)}
|
||||
>
|
||||
{item.visible ? (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</MythicActionButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<MythicDraggablePortal isDragging={snapshot.isDragging}>
|
||||
{row}
|
||||
</MythicDraggablePortal>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Box from '@mui/material/Box';
|
||||
import AceEditor from 'react-ace';
|
||||
import "ace-builds/src-noconflict/mode-json";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
import "ace-builds/src-noconflict/theme-monokai";
|
||||
import "ace-builds/src-noconflict/ext-searchbox";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {getReadableTextColor, isValidHexColor, MythicColorSwatchInput} from '../../MythicComponents/MythicColorInput';
|
||||
|
||||
export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
const [comment, setComment] = React.useState("");
|
||||
const [color, setColor] = React.useState("");
|
||||
const theme = useTheme();
|
||||
const onCommitSubmit = () => {
|
||||
const normalizedColor = color?.toLowerCase() || "";
|
||||
if(normalizedColor === "" || normalizedColor === "ffffff" || normalizedColor === "#ffffff" ||
|
||||
normalizedColor === "000000" || normalizedColor === "#000000"){
|
||||
props.onSubmit(comment, "");
|
||||
} else {
|
||||
props.onSubmit(comment, color);
|
||||
}
|
||||
if(props.dontCloseOnSubmit){
|
||||
return;
|
||||
}
|
||||
props.onClose();
|
||||
}
|
||||
const onChange = (value) => {
|
||||
setComment(value);
|
||||
}
|
||||
useEffect( () => {
|
||||
try{
|
||||
setComment(JSON.stringify(JSON.parse(props.description), null, 2));
|
||||
}catch(error){
|
||||
setComment(props.description);
|
||||
}
|
||||
setColor(props.color);
|
||||
}, [props.description, props.color]);
|
||||
const previewColor = isValidHexColor(color) ? color : "";
|
||||
const darkPreviewBackground = previewColor || "#1f2937";
|
||||
const lightPreviewBackground = previewColor || "#f8fafc";
|
||||
const darkPreviewTextColor = previewColor ? getReadableTextColor(previewColor) : "#ffffff";
|
||||
const lightPreviewTextColor = previewColor ? getReadableTextColor(previewColor) : "#111827";
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", margin: 0, padding: 0}}>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
height="100%"
|
||||
fontSize={14}
|
||||
wrapEnabled={props.wrap ? props.wrap : false}
|
||||
minLines={props.maxRows ? props.maxRows : 3}
|
||||
maxLines={props.maxRows ? props.maxRows : 3}
|
||||
value={comment}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
showInvisibles: false,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{p: 1.5, borderTop: "1px solid", borderColor: "divider", backgroundColor: "background.paper"}}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<Box sx={{display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 1}}>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography variant="body2" sx={{fontWeight: 700}}>Callback Color</Typography>
|
||||
<Typography variant="caption" sx={{color: "text.secondary"}}>Callback row background and tasking accent</Typography>
|
||||
</Box>
|
||||
<Box sx={{display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", justifyContent: "flex-end"}}>
|
||||
<MythicColorSwatchInput
|
||||
color={isValidHexColor(color) ? color : "#000000"}
|
||||
label="Callback color"
|
||||
onChange={setColor}
|
||||
/>
|
||||
<MythicActionButton colorMode="always" onClick={() => setColor("")} tone="success" variant="outlined" size="small">
|
||||
Clear
|
||||
</MythicActionButton>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", sm: "1fr 1fr"}, gap: 1}}>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
px: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
backgroundColor: darkPreviewBackground,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{color: darkPreviewTextColor, fontWeight: 700}}>Dark callback row</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
px: 1.5,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
backgroundColor: lightPreviewBackground,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{color: lightPreviewTextColor, fontWeight: 700}}>Light callback row</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</MythicActionButton>
|
||||
{props.onSubmit &&
|
||||
<MythicActionButton colorMode="always" onClick={onCommitSubmit} tone="success" variant="contained">
|
||||
Submit
|
||||
</MythicActionButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
|
||||
import MythicTextField from "../../MythicComponents/MythicTextField";
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection,
|
||||
MythicForm,
|
||||
MythicFormField
|
||||
} from "../../MythicComponents/MythicDialogLayout";
|
||||
|
||||
export function CallbacksTableEditTriggerOnCheckinDialog(props) {
|
||||
const [comment, setComment] = React.useState(0);
|
||||
const onChange = (name, value, error) => {
|
||||
if(isNaN(parseInt(value))){
|
||||
setComment(0);
|
||||
} else {
|
||||
setComment(parseInt(value));
|
||||
}
|
||||
}
|
||||
useEffect( () => {
|
||||
setComment(props.trigger_on_checkin_after_time);
|
||||
}, [props.trigger_on_checkin_after_time]);
|
||||
const onSubmit = () => {
|
||||
props.onSubmit(comment);
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Callback check-in alert</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<MythicDialogBody>
|
||||
<div className="mythic-callback-trigger-summary p-6 items-start flex gap-6 min-w-0 rounded bg-surface-muted border-subtle">
|
||||
<span className={`mythic-callback-trigger-summary-icon items-center inline-flex flex-none justify-center rounded bg-neutral-1 border-subtle text-muted${comment > 0 ? " mythic-callback-trigger-summary-icon-active" : ""}`}>
|
||||
<NotificationsActiveTwoToneIcon fontSize="small" />
|
||||
</span>
|
||||
<div className="mythic-callback-trigger-summary-copy min-w-0">
|
||||
<Typography className="mythic-callback-trigger-summary-title">
|
||||
{comment > 0 ? `Alert after ${comment} minute${comment === 1 ? "" : "s"} without a check-in` : "Alerting disabled"}
|
||||
</Typography>
|
||||
<Typography className="mythic-callback-trigger-summary-description">
|
||||
This setting only triggers when the callback checks in after crossing the configured threshold.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
<MythicDialogSection
|
||||
title="Threshold"
|
||||
description="Set how many minutes this callback can remain silent before its next check-in can trigger eventing."
|
||||
>
|
||||
<MythicForm>
|
||||
<MythicFormField
|
||||
label="Minutes without a check-in"
|
||||
description="Use 0 to disable this alert for the callback."
|
||||
>
|
||||
<div className="mythic-form-field-control min-w-0 w-full">
|
||||
<MythicTextField
|
||||
autoFocus={true}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
value={comment}
|
||||
onEnter={onSubmit}
|
||||
name="Trigger threshold"
|
||||
showLabel={false}
|
||||
marginTop="0"
|
||||
marginBottom="0"
|
||||
InputProps={{inputProps: {min: 0}}}
|
||||
/>
|
||||
</div>
|
||||
</MythicFormField>
|
||||
</MythicForm>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="Eventing behavior">
|
||||
<div className="mythic-callback-trigger-rule-list gap-4 grid">
|
||||
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
|
||||
Trigger name: <strong>callback_checkin</strong>
|
||||
</div>
|
||||
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
|
||||
Matching workflow filters still apply, including payload type and supported OS restrictions.
|
||||
</div>
|
||||
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
|
||||
If no matching workflow exists, no workflow will run.
|
||||
</div>
|
||||
</div>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</MythicDialogButton>
|
||||
{props.onSubmit &&
|
||||
<MythicDialogButton onClick={onSubmit} intent="primary">
|
||||
Save Threshold
|
||||
</MythicDialogButton>
|
||||
}
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,41 @@
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {Button} from '@mui/material';
|
||||
import React, {useCallback, useEffect, useContext} from 'react';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import { MythicDisplayTextDialog} from '../../MythicComponents/MythicDisplayTextDialog';
|
||||
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import LockIcon from '@mui/icons-material/Lock';
|
||||
import {getTimeDifference, useInterval, toLocalTime } from '../../utilities/Time';
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import InsertLinkTwoToneIcon from '@mui/icons-material/InsertLinkTwoTone';
|
||||
import {C2PathDialog} from './C2PathDialog';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import {hideCallbackMutation} from './CallbackMutations';
|
||||
import {
|
||||
updateIPsCallbackMutation,
|
||||
} from './CallbackMutations';
|
||||
import {useMutation } from '@apollo/client';
|
||||
import SnoozeIcon from '@mui/icons-material/Snooze';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {faQuestion, faSkullCrossbones, faFolderOpen, faList} from '@fortawesome/free-solid-svg-icons';
|
||||
import {faQuestion, faRobot} from '@fortawesome/free-solid-svg-icons';
|
||||
import {faLinux, faApple, faWindows, faChrome, faAndroid} from '@fortawesome/free-brands-svg-icons';
|
||||
import {useSubscription, gql } from '@apollo/client';
|
||||
import {DetailedCallbackTable} from './DetailedCallbackTable';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import {TaskFromUIButton} from './TaskFromUIButton';
|
||||
import {CallbacksTabsTaskMultipleDialog} from './CallbacksTabsTaskMultipleDialog';
|
||||
import {CallbacksTabsHideMultipleDialog} from './CallbacksTabsHideMultipleDialog';
|
||||
import { MythicSelectFromRawListDialog } from '../../MythicComponents/MythicSelectFromListDialog';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import { areEqual } from 'react-window';
|
||||
import {CallbackGraphEdgesContext, OnOpenTabContext} from './CallbacksTop';
|
||||
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import {faSocks} from '@fortawesome/free-solid-svg-icons';
|
||||
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import {MythicStatusChip} from "../../MythicComponents/MythicStatusChip";
|
||||
import {MythicTimestamp} from "../../MythicComponents/MythicTimestamp";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
// callback_stream(batch_size: 1, cursor: {initial_value: {last_checkin: "1970-01-01"}}, where: {id: {_eq: $callback_id}}){
|
||||
// still have some issues with the stream unfortunately
|
||||
const SUB_Callbacks = gql`
|
||||
subscription CallbacksSubscription ($callback_id: Int!){
|
||||
callback_stream(batch_size: 1, cursor: {initial_value: {last_checkin: "1970-01-01"}}, where: {id: {_eq: $callback_id}}){
|
||||
id
|
||||
display_id
|
||||
current_time
|
||||
last_checkin
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CallbacksTableIDCell = ({rowData, onOpenTab, toggleLock, updateDescription, setOpenHideMultipleDialog}) =>{
|
||||
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown, onOpenTriggerDialog, onOpenUnlockDialog}) =>{
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const [openMetaDialog, setOpenMetaDialog] = React.useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
const [openEditDescriptionDialog, setOpenEditDescriptionDialog] = React.useState(false);
|
||||
const [openTaskingButton, setOpenTaskingButton] = React.useState(false);
|
||||
const taskingData = React.useRef({"parameters": "", "ui_feature": "callback_table:exit"});
|
||||
const [openTaskMultipleDialog, setOpenTaskMultipleDialog] = React.useState(false);
|
||||
|
||||
const onOpenTab = useContext(OnOpenTabContext);
|
||||
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact"})
|
||||
const [rowDataStatic, setRowDataStatic] = React.useState(rowData);
|
||||
React.useEffect( () => {
|
||||
let update = false;
|
||||
@@ -79,285 +54,206 @@ export const CallbacksTableIDCell = ({rowData, onOpenTab, toggleLock, updateDesc
|
||||
if(rowData.description !== rowDataStatic.description){
|
||||
update = true;
|
||||
}
|
||||
if(rowData.id !== rowDataStatic.id){
|
||||
update = true;
|
||||
}
|
||||
if(rowData.color !== rowDataStatic.color){
|
||||
update = true;
|
||||
}
|
||||
if(rowData.trigger_on_checkin_after_time !== rowDataStatic.trigger_on_checkin_after_time){
|
||||
update = true;
|
||||
}
|
||||
if(rowData.callbackports.length !== rowDataStatic.callbackports.length){
|
||||
update = true;
|
||||
}
|
||||
if(update){
|
||||
setRowDataStatic(rowData);
|
||||
}
|
||||
}, [rowData]);
|
||||
const editDescriptionSubmit = (description) => {
|
||||
if(description === ""){
|
||||
updateDescription({description: rowDataStatic.payload.description, callback_display_id: rowDataStatic.display_id});
|
||||
} else {
|
||||
updateDescription({description, callback_display_id: rowDataStatic.display_id});
|
||||
const proxyMessage = useCallback(() => {
|
||||
let message = [];
|
||||
for(let i = 0; i < rowDataStatic?.callbackports?.length || 0; i++){
|
||||
switch(rowDataStatic.callbackports[i].port_type){
|
||||
case "socks":
|
||||
if(rowDataStatic.callbackports[i].username === ""){
|
||||
message.push(`socks: ${rowDataStatic.callbackports[i].local_port}`);
|
||||
} else {
|
||||
message.push(`socks: ${rowDataStatic.callbackports[i].local_port}, ${rowDataStatic.callbackports[i].username}:${rowDataStatic.callbackports[i].password}`);
|
||||
}
|
||||
break;
|
||||
case "rpfwd":
|
||||
if(rowDataStatic.callbackports[i].username === ""){
|
||||
message.push(`rpfwd: ${rowDataStatic.callbackports[i].local_port}->${rowDataStatic.callbackports[i].remote_ip}:${rowDataStatic.callbackports[i].remote_port}`);
|
||||
} else {
|
||||
message.push(`pfwd: ${rowDataStatic.callbackports[i].local_port}->${rowDataStatic.callbackports[i].remote_ip}:${rowDataStatic.callbackports[i].remote_port}, ${rowDataStatic.callbackports[i].username}:${rowDataStatic.callbackports[i].password}`);
|
||||
}
|
||||
break;
|
||||
case "interactive":
|
||||
message.push(`interactive: ${rowDataStatic.callbackports[i].local_port}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
const handleDropdownToggle = (evt) => {
|
||||
evt.stopPropagation();
|
||||
setDropdownOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
const localOnOpenTab = (tabType) => {
|
||||
if(tabType === "interact"){
|
||||
onOpenTab({tabType, tabID: rowDataStatic.id + tabType, callbackID: rowDataStatic.id, displayID: rowDataStatic.display_id});
|
||||
}else if(tabType === "processBrowser"){
|
||||
onOpenTab({tabType, tabID: rowDataStatic.host, callbackID: rowDataStatic.id, displayID: rowDataStatic.display_id});
|
||||
}else{
|
||||
onOpenTab({tabType, tabID: rowDataStatic.id + tabType, callbackID: rowDataStatic.id, displayID: rowDataStatic.display_id});
|
||||
}
|
||||
}
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
options[index].click(event);
|
||||
setDropdownOpen(false);
|
||||
return message.join("\n");
|
||||
}, [rowDataStatic]);
|
||||
const handleDropdownToggle = (event) => {
|
||||
event.stopPropagation();
|
||||
callbackDropdown({rowDataStatic, event});
|
||||
};
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
const [hideCallback] = useMutation(hideCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.updateCallback.status === "success"){
|
||||
snackActions.success("Hiding callback");
|
||||
}else{
|
||||
snackActions.warning(data.updateCallback.error);
|
||||
}
|
||||
|
||||
},
|
||||
onError: data => {
|
||||
console.log(data);
|
||||
}
|
||||
});
|
||||
const localToggleLock = () => {
|
||||
toggleLock({callback_display_id: rowDataStatic.display_id, locked: rowDataStatic.locked})
|
||||
const handleOpenTriggerDialog = (event) => {
|
||||
event.stopPropagation();
|
||||
onOpenTriggerDialog?.(rowDataStatic);
|
||||
};
|
||||
const handleOpenUnlockDialog = (event) => {
|
||||
event.stopPropagation();
|
||||
onOpenUnlockDialog?.(rowDataStatic);
|
||||
};
|
||||
const localOnOpenTab = () => {
|
||||
onOpenTab({tabType: interactType, tabID: rowDataStatic.id + interactType, callbackID: rowDataStatic.id, displayID: rowDataStatic.display_id});
|
||||
}
|
||||
let defaultInteractIcon = <KeyboardIcon fontSize="small" />;
|
||||
if(interactType === "interactSplit"){
|
||||
defaultInteractIcon = <VerticalSplitIcon fontSize="small" />;
|
||||
} else if(interactType === "interactConsole"){
|
||||
defaultInteractIcon = <TerminalIcon fontSize="small" />;
|
||||
}
|
||||
const highIntegrity = rowDataStatic.integrity_level >= 3;
|
||||
const lockOwner = rowDataStatic.locked_operator?.username || "unknown operator";
|
||||
const onOpenTabClick = (e) => {
|
||||
e.stopPropagation();
|
||||
localOnOpenTab()
|
||||
}
|
||||
const options = [
|
||||
{name: 'Hide Callback', icon: <VisibilityOffIcon style={{paddingRight: "5px"}}/>, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
hideCallback({variables: {callback_display_id: rowDataStatic.display_id}});
|
||||
}},
|
||||
{
|
||||
name: "Hide Multiple", icon: <VisibilityOffIcon style={{paddingRight: "5px"}}/>, click: (evt) => {
|
||||
setOpenHideMultipleDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Exit Callback", icon: <FontAwesomeIcon icon={faSkullCrossbones} style={{cursor: "pointer", marginRight: "10px"}} />, click: (evt) => {
|
||||
taskingData.current = {"parameters": "", "ui_feature": "callback_table:exit", "getConfirmation": true, acceptText: "exit"};
|
||||
setOpenTaskingButton(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Task Multiple", icon: <FontAwesomeIcon icon={faList} style={{cursor: "pointer", marginRight: "10px"}} />, click: (evt) => {
|
||||
setOpenTaskMultipleDialog(true);
|
||||
}
|
||||
},
|
||||
{name: 'File Browser', icon: <FontAwesomeIcon icon={faFolderOpen} style={{cursor: "pointer", marginRight: "10px"}} />, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
localOnOpenTab("fileBrowser");
|
||||
}},
|
||||
{name: 'Process Browser', icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
localOnOpenTab("processBrowser");
|
||||
}},
|
||||
{name: rowDataStatic.locked ? 'Unlock (Locked by ' + rowDataStatic.locked_operator.username + ')' : 'Lock Callback', icon: rowDataStatic.locked ? (<LockOpenIcon style={{paddingRight: "5px"}}/>) : (<LockIcon style={{paddingRight: "5px"}} />), click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
localToggleLock();
|
||||
}},
|
||||
{name: "Edit Description", icon: <EditIcon style={{paddingRight: "5px"}} />, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
setOpenEditDescriptionDialog(true);
|
||||
}},
|
||||
{name: "Expand Callback", icon: <OpenInNewIcon style={{paddingRight: "5px"}} />, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
window.open("/new/callbacks/" + rowDataStatic.display_id, "_blank").focus();
|
||||
}},
|
||||
{name: "View Metadata", icon: <InfoIcon style={{paddingRight: "5px"}} />, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
setOpenMetaDialog(true);
|
||||
}}
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<ButtonGroup
|
||||
color={rowDataStatic.integrity_level > 2 ? "error" : "primary"}
|
||||
ref={dropdownAnchorRef}
|
||||
aria-label="split button"
|
||||
>
|
||||
<Button style={{padding: "0 10px 0 10px"}} color={rowDataStatic.integrity_level > 2 ? "error" : "primary"} variant="contained"
|
||||
onClick={(evt) => {evt.stopPropagation();localOnOpenTab("interact")}}>
|
||||
{ rowDataStatic.locked ? (<LockIcon fontSize="large" style={{marginRight: "10px"}} />):(<KeyboardIcon fontSize="large" style={{marginRight: "10px"}}/>) }
|
||||
{rowDataStatic.display_id}
|
||||
</Button>
|
||||
<Button
|
||||
style={{margin: 0, padding: 0}}
|
||||
variant="contained"
|
||||
aria-controls={dropdownOpen ? 'split-button-menu' : undefined}
|
||||
aria-expanded={dropdownOpen ? 'true' : undefined}
|
||||
color={rowDataStatic.integrity_level > 2 ? "error" : "primary"}
|
||||
<div id={`callbacksTableID${rowDataStatic.id}`} className="mythic-callback-interactCell items-center inline-flex gap-1 overflow-hidden w-full">
|
||||
<ButtonGroup className="mythic-split-action-group inline-flex " >
|
||||
<MythicActionButton
|
||||
appearance={"raised"}
|
||||
compact
|
||||
colorMode={highIntegrity ? "always" : "hover"}
|
||||
icon={defaultInteractIcon}
|
||||
onClick={onOpenTabClick}
|
||||
tone={highIntegrity ? "error" : "primary"}
|
||||
tooltip={`Open ${interactType === "interactSplit" ? "split" : interactType === "interactConsole" ? "console" : "tasking"} view${highIntegrity ? " - high integrity callback" : ""}`}
|
||||
>
|
||||
<span className="font-700 font-mono text-output">
|
||||
{rowDataStatic.display_id}
|
||||
</span>
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
appearance={"raised"}
|
||||
compact
|
||||
aria-haspopup="menu"
|
||||
icon={<ArrowDropDownIcon />}
|
||||
onClick={handleDropdownToggle}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
</Button>
|
||||
ref={dropdownAnchorRef}
|
||||
colorMode={highIntegrity ? "always" : "hover"}
|
||||
tone={highIntegrity ? "error" : "primary"}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
<Popper open={dropdownOpen} anchorEl={dropdownAnchorRef.current} role={undefined} transition style={{zIndex: 200}}>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin: placement === 'bottom' ? 'top left' : 'top center',
|
||||
}}
|
||||
>
|
||||
<Paper style={{backgroundColor: theme.palette.mode === 'dark' ? theme.palette.primary.dark : theme.palette.primary.light, color: "white"}} elevation={5}>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu">
|
||||
{options.map((option, index) => (
|
||||
<MenuItem
|
||||
key={option.name}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
>
|
||||
{option.icon}{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
{openTaskingButton &&
|
||||
<TaskFromUIButton ui_feature={taskingData.current?.ui_feature || " "}
|
||||
callback_id={rowDataStatic.id}
|
||||
display_id={rowDataStatic.display_id}
|
||||
parameters={taskingData.current?.parameters || ""}
|
||||
openDialog={taskingData.current?.openDialog || false}
|
||||
getConfirmation={taskingData.current?.getConfirmation || false}
|
||||
acceptText={taskingData.current?.acceptText || "YES"}
|
||||
onTasked={() => setOpenTaskingButton(false)}/>
|
||||
}
|
||||
{openMetaDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="lg" open={openMetaDialog}
|
||||
onClose={()=>{setOpenMetaDialog(false);}}
|
||||
innerDialog={<DetailedCallbackTable onClose={()=>{setOpenMetaDialog(false);}} callback_id={rowDataStatic.id} />}
|
||||
/>
|
||||
|
||||
{rowDataStatic.locked &&
|
||||
<MythicStyledTooltip title={`Locked by ${lockOwner}`}>
|
||||
<MythicStatusChip
|
||||
aria-label={`Unlock callback ${rowDataStatic.display_id}`}
|
||||
compact
|
||||
iconOnly
|
||||
onClick={handleOpenUnlockDialog}
|
||||
status="locked"
|
||||
/>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{openEditDescriptionDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
open={openEditDescriptionDialog}
|
||||
onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
innerDialog={
|
||||
<MythicModifyStringDialog title={"Edit Callback's Description"}
|
||||
onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
value={rowDataStatic.description}
|
||||
onSubmit={editDescriptionSubmit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{rowDataStatic.trigger_on_checkin_after_time > 0 &&
|
||||
<MythicStyledTooltip title={`Alert on callback after no checkin for ${rowDataStatic.trigger_on_checkin_after_time} minutes`}>
|
||||
<MythicStatusChip
|
||||
aria-label={`Adjust alert trigger for callback ${rowDataStatic.display_id}`}
|
||||
compact
|
||||
iconOnly
|
||||
onClick={handleOpenTriggerDialog}
|
||||
status="alerting"
|
||||
/>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{openTaskMultipleDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
maxWidth="lg"
|
||||
open={openTaskMultipleDialog}
|
||||
onClose={() => {setOpenTaskMultipleDialog(false);}}
|
||||
innerDialog={
|
||||
<CallbacksTabsTaskMultipleDialog callback={rowDataStatic} onClose={() => {setOpenTaskMultipleDialog(false);}} />
|
||||
}
|
||||
/>
|
||||
{rowDataStatic.callbackports.length > 0 &&
|
||||
<MythicStyledTooltip title={proxyMessage()}>
|
||||
<MythicStatusChip compact icon={<FontAwesomeIcon icon={faSocks} />} iconOnly label="Proxy active" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const CallbacksTableStringCell = ({rowData, cellData}) => {
|
||||
},
|
||||
areEqual)
|
||||
export const CallbacksTableStringCell = React.memo(({rowData, cellData}) => {
|
||||
return (
|
||||
<div>{cellData}</div>
|
||||
)
|
||||
}
|
||||
export const CallbacksTableLastCheckinCell = React.memo( (props) => {
|
||||
const [displayTime, setDisplayTime] = React.useState("");
|
||||
const lastCheckinDifference = React.useRef(-1);
|
||||
const lastCheckinTimestampFromMythic = React.useRef("");
|
||||
const mountedRef = React.useRef(true);
|
||||
const lastCheckinTimestamp = React.useRef("");
|
||||
}, areEqual)
|
||||
export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me}) => {
|
||||
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
|
||||
return ""
|
||||
}
|
||||
if(rowData.last_checkin === "1970-01-01T00:00:00"){
|
||||
return (
|
||||
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
|
||||
{rowData.dead &&
|
||||
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
|
||||
<MythicStatusChip compact iconOnly status="dead" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">Streaming Now</span>
|
||||
</div>
|
||||
|
||||
useSubscription(SUB_Callbacks, {
|
||||
variables: {callback_id: props.rowData.id}, fetchPolicy: "network-only", shouldResubscribe: true,
|
||||
onSubscriptionData: ({subscriptionData}) => {
|
||||
if(!mountedRef.current || !props.parentMountedRef.current){
|
||||
return null;
|
||||
}
|
||||
//console.log(subscriptionData.data.callback_stream[0].display_id, subscriptionData.data.callback_stream)
|
||||
let last = new Date(subscriptionData.data.callback_stream[0].last_checkin);
|
||||
lastCheckinTimestampFromMythic.current = subscriptionData.data.callback_stream[0].last_checkin;
|
||||
let currentMythic = new Date(subscriptionData.data.callback_stream[0].current_time);
|
||||
let timeskew = (new Date()) - currentMythic;
|
||||
lastCheckinDifference.current = last - timeskew;
|
||||
updateDisplayTime();
|
||||
}
|
||||
});
|
||||
const updateDisplayTime = () => {
|
||||
let newTimeDifference = getTimeDifference(lastCheckinDifference.current);
|
||||
if(newTimeDifference.includes("m")){
|
||||
if(newTimeDifference.includes("m0s")){
|
||||
lastCheckinTimestamp.current = toLocalTime(lastCheckinTimestampFromMythic.current, false);
|
||||
setDisplayTime(newTimeDifference.slice(0, newTimeDifference.length-2));
|
||||
}else if(displayTime === ""){
|
||||
lastCheckinTimestamp.current = toLocalTime(lastCheckinTimestampFromMythic.current, false);
|
||||
setDisplayTime(newTimeDifference.slice(0, newTimeDifference.indexOf("m")+1));
|
||||
} else {
|
||||
lastCheckinTimestamp.current = toLocalTime(lastCheckinTimestampFromMythic.current, false);
|
||||
setDisplayTime(newTimeDifference);
|
||||
}
|
||||
} else {
|
||||
lastCheckinTimestamp.current = toLocalTime(lastCheckinTimestampFromMythic.current, false);
|
||||
setDisplayTime(newTimeDifference);
|
||||
}
|
||||
)
|
||||
}
|
||||
useInterval( () => {
|
||||
if(!mountedRef.current || !props.parentMountedRef.current){
|
||||
return null;
|
||||
}
|
||||
if(lastCheckinDifference.current === -1){
|
||||
setDisplayTime("Loading...");
|
||||
return;
|
||||
}
|
||||
updateDisplayTime();
|
||||
|
||||
}, 1000, mountedRef, props.parentMountedRef);
|
||||
React.useEffect( () => {
|
||||
return() => {
|
||||
mountedRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
return (
|
||||
<div>
|
||||
<MythicStyledTooltip title={lastCheckinTimestamp.current}>
|
||||
{displayTime}
|
||||
</MythicStyledTooltip>
|
||||
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
|
||||
{rowData.dead &&
|
||||
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
|
||||
<MythicStatusChip compact iconOnly status="dead" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">
|
||||
<MythicTimestamp
|
||||
interval={1000}
|
||||
withTitle
|
||||
titleFormat={"YYYY-MM-DD HH:mm:ss"}
|
||||
serverSkew={me?.user?.server_skew}
|
||||
fromNow
|
||||
ago
|
||||
>
|
||||
{rowData.last_checkin + "Z"}
|
||||
</MythicTimestamp>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
)
|
||||
});
|
||||
export const CallbacksTablePayloadTypeCell = ({rowData}) => {
|
||||
const payloadTypeName = React.useRef(rowData.payload.payloadtype.name)
|
||||
}, areEqual);
|
||||
export const CallbacksTablePayloadTypeCell = React.memo( ({rowData}) => {
|
||||
return (
|
||||
<MythicStyledTooltip title={payloadTypeName.current}>
|
||||
<img
|
||||
style={{width: "35px", height: "35px"}}
|
||||
src={"/static/" + payloadTypeName.current + ".svg"}
|
||||
/>
|
||||
</MythicStyledTooltip>
|
||||
rowData?.payload?.payloadtype?.name
|
||||
)
|
||||
}
|
||||
export const CallbacksTableIPCell = ({cellData, rowData, callback_id, updateIPs}) => {
|
||||
}, areEqual)
|
||||
export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
const [displayIP, setDisplayIP] = React.useState("");
|
||||
const [openPickIP, setOpenPickIP] = React.useState(false);
|
||||
const [options, setOptions] = React.useState([]);
|
||||
const onClick = () => {
|
||||
const [updateIPs] = useMutation(updateIPsCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.updateCallback.status === "success"){
|
||||
snackActions.success("Updated Callback");
|
||||
} else {
|
||||
snackActions.warning(data.updateCallback.error);
|
||||
}
|
||||
|
||||
},
|
||||
onError: data => {
|
||||
console.log(data)
|
||||
snackActions.warning(data);
|
||||
}
|
||||
});
|
||||
const updateIPsInfo = React.useCallback( ({callback_display_id, ips}) => {
|
||||
updateIPs({variables: {callback_display_id: callback_display_id, ips}})
|
||||
}, []);
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
setOpenPickIP(true);
|
||||
}
|
||||
const editIPSubmit = (selected_ip) => {
|
||||
@@ -365,7 +261,7 @@ export const CallbacksTableIPCell = ({cellData, rowData, callback_id, updateIPs}
|
||||
const ipArray = JSON.parse(cellData).filter( c => c !== selected_ip);
|
||||
const newIPArray = [selected_ip, ...ipArray];
|
||||
//const newIPString = JSON.stringify( newIPArray);
|
||||
updateIPs({callback_display_id: rowData.display_id, ips: newIPArray});
|
||||
updateIPsInfo({callback_display_id: rowData.display_id, ips: newIPArray});
|
||||
//console.log(newIPString)
|
||||
}
|
||||
React.useEffect( () => {
|
||||
@@ -377,9 +273,14 @@ export const CallbacksTableIPCell = ({cellData, rowData, callback_id, updateIPs}
|
||||
}, [cellData]);
|
||||
return (
|
||||
<>
|
||||
<div onContextMenu={onClick}>{displayIP}</div>
|
||||
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
|
||||
{options.length > 1 &&
|
||||
<MythicActionButton appearance="raised" compact icon={<UnfoldMoreIcon />} iconOnly onClick={onClick} tooltip="Adjust displayed IP" />
|
||||
}
|
||||
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">{displayIP}</span>
|
||||
</div>
|
||||
{openPickIP &&
|
||||
<MythicDialog fullWidth={true} open={openPickIP} onClose={() => {setOpenPickIP(false);}}
|
||||
<MythicDialog fullWidth={true} open={openPickIP} onClose={() => {setOpenPickIP(false);}}
|
||||
innerDialog={
|
||||
<MythicSelectFromRawListDialog
|
||||
onClose={() => {setOpenPickIP(false);}}
|
||||
@@ -392,12 +293,11 @@ export const CallbacksTableIPCell = ({cellData, rowData, callback_id, updateIPs}
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
const theme = useTheme();
|
||||
const [activeEgress, setActiveEgress] = React.useState(theme.palette.success.main);
|
||||
const [activeEgressBool, setActiveEgressBool] = React.useState(true);
|
||||
const [hasEgressRoute, setHasEgressRoute] = React.useState(true);
|
||||
}, areEqual)
|
||||
export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
|
||||
const [localRowData, setLocalRowData] = React.useState(rowData);
|
||||
const initialCallbackGraphEdges = useContext(CallbackGraphEdgesContext);
|
||||
const onOpenTab = useContext(OnOpenTabContext);
|
||||
const [openC2Dialog, setOpenC2Dialog] = React.useState(false);
|
||||
const [callbackgraphedges, setCallbackgraphedges] = React.useState([]);
|
||||
const [callbackgraphedgesAll, setCallbackgraphedgesAll] = React.useState([]);
|
||||
@@ -405,24 +305,11 @@ export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
event.stopPropagation();
|
||||
setOpenC2Dialog(true);
|
||||
}
|
||||
useEffect( () => {
|
||||
const routes = callbackgraphedgesAll.filter( (edge) => {
|
||||
if(!edge.c2profile.is_p2p && edge.source.id === rowData.id && edge.destination.id === rowData.id){
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}).length;
|
||||
if(routes > 0 && !hasEgressRoute){
|
||||
setHasEgressRoute(true);
|
||||
}else if(routes === 0 && hasEgressRoute){
|
||||
setHasEgressRoute(false);
|
||||
}
|
||||
}, [callbackgraphedgesAll]);
|
||||
useEffect( () => {
|
||||
const getEdges = (activeOnly) => {
|
||||
//update our aggregate of callbackgraphedges for both src and dst that involve us
|
||||
let myEdges = initialCallbackGraphEdges?.filter( (edge) =>{
|
||||
if(edge.source.id === rowData.id || edge.destination.id === rowData.id){
|
||||
if(edge.source.id === localRowData.id || edge.destination.id === localRowData.id){
|
||||
if(activeOnly){
|
||||
if(edge.end_timestamp === null){
|
||||
return true;
|
||||
@@ -438,10 +325,13 @@ export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
//look at all of the edges in myEdges and see if there are any edges that share a source/destination in props.callbackgraphedges that are _not_ in myEdges so far
|
||||
const newEdges = initialCallbackGraphEdges?.reduce( (prev, edge) => {
|
||||
//looking to see if we should add 'edge' to our list of relevant edges
|
||||
if(prev.includes(edge)){return [...prev]}
|
||||
const alreadyIncludes = prev.filter( (e) => e.id === edge.id);
|
||||
if(alreadyIncludes.length > 0){
|
||||
return [...prev]
|
||||
}
|
||||
//look through all of the previous edges we know about and see if there's a matching source/destination id with the new edge
|
||||
const matching = prev.filter( (e) => {
|
||||
if(e.source.id === edge.source.id || e.source.id === edge.destination.id || e.destination.id === edge.source.id ){
|
||||
if(e.source.id === edge.source.id || e.source.id === edge.destination.id || e.destination.id === edge.source.id || e.destination.id === edge.destination.id ){
|
||||
if(activeOnly){
|
||||
if(edge.end_timestamp === null) { return true}
|
||||
else{return false}
|
||||
@@ -451,47 +341,55 @@ export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
return false;
|
||||
});
|
||||
if(matching.length > 0){
|
||||
return [...prev, edge];
|
||||
return [...prev, {...edge}];
|
||||
}else{
|
||||
return [...prev];
|
||||
}
|
||||
}, [...myEdges]) || [];
|
||||
foundMore = newEdges.length > myEdges;
|
||||
foundMore = newEdges.length > myEdges.length;
|
||||
myEdges = [...newEdges];
|
||||
}
|
||||
return myEdges;
|
||||
}
|
||||
const myActiveEdges = getEdges(true);
|
||||
const myEdges = getEdges(false);
|
||||
if(callbackgraphedges.length !== myActiveEdges.length){
|
||||
setCallbackgraphedges(myActiveEdges);
|
||||
}
|
||||
if(callbackgraphedgesAll.length !== myEdges.length){
|
||||
setCallbackgraphedgesAll(myEdges);
|
||||
}
|
||||
}, [initialCallbackGraphEdges, rowData]);
|
||||
setCallbackgraphedges(myActiveEdges);
|
||||
setCallbackgraphedgesAll(myEdges);
|
||||
|
||||
}, [initialCallbackGraphEdges, localRowData]);
|
||||
useEffect( () => {
|
||||
//determine if there are any active routes left at all
|
||||
const activeRoutes = callbackgraphedges.filter( (edge) => {
|
||||
if(!edge.c2profile.is_p2p && edge.end_timestamp === null){
|
||||
return true;
|
||||
}
|
||||
return false
|
||||
});
|
||||
if(activeRoutes.length === 0){
|
||||
setActiveEgress(theme.palette.error.main);
|
||||
setActiveEgressBool(false);
|
||||
}else{
|
||||
setActiveEgress(theme.palette.success.main);
|
||||
setActiveEgressBool(true);
|
||||
if(rowData.id !== localRowData.id){
|
||||
setLocalRowData(rowData);
|
||||
}
|
||||
}, [callbackgraphedges, theme.palette.success.main, theme.palette.error.main]);
|
||||
}, [rowData]);
|
||||
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
|
||||
return null
|
||||
}
|
||||
const directEgressRoutes = callbackgraphedgesAll.filter((edge) =>
|
||||
!edge.c2profile?.is_p2p &&
|
||||
edge.source.id === localRowData.id &&
|
||||
edge.destination.id === localRowData.id
|
||||
);
|
||||
const hasDirectEgressRoute = directEgressRoutes.length > 0;
|
||||
const directEgressActive = directEgressRoutes.some((edge) => edge.end_timestamp === null);
|
||||
const p2pRouteActive = callbackgraphedges.some((edge) =>
|
||||
!edge.c2profile?.is_p2p && edge.end_timestamp === null
|
||||
);
|
||||
const c2RouteActive = hasDirectEgressRoute ? directEgressActive : p2pRouteActive;
|
||||
const c2Tooltip = hasDirectEgressRoute ?
|
||||
(directEgressActive ? "Direct C2 route active. View C2 path information" : "Direct C2 route inactive. View C2 path information") :
|
||||
(p2pRouteActive ? "Active P2P route to Mythic. View C2 path information" : "No active route to Mythic. View C2 path information");
|
||||
return (
|
||||
<div>
|
||||
{hasEgressRoute ?
|
||||
<WifiIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}}/> :
|
||||
<InsertLinkTwoToneIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}} />
|
||||
}
|
||||
<div className={`mythic-callback-cellInline items-center inline-flex gap-2${centered ? " mythic-callback-cellInlineCenter justify-center" : ""} h-full max-w-full min-w-0 overflow-hidden w-full`}>
|
||||
<MythicActionButton appearance="raised"
|
||||
active={!c2RouteActive}
|
||||
compact
|
||||
icon={hasDirectEgressRoute ? <WifiIcon /> : <InsertLinkTwoToneIcon />}
|
||||
iconOnly
|
||||
onClick={onOpenC2Dialog}
|
||||
tone={c2RouteActive ? "success" : "error"}
|
||||
tooltip={c2Tooltip}
|
||||
/>
|
||||
{openC2Dialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
@@ -500,9 +398,11 @@ export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
innerDialog={
|
||||
<C2PathDialog
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
callback={rowData}
|
||||
callbackgraphedges={activeEgressBool ? callbackgraphedges : callbackgraphedgesAll}
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
|
||||
callback={localRowData}
|
||||
callbackgraphedges={callbackgraphedgesAll}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -511,36 +411,40 @@ export const CallbacksTableC2Cell = ({initialCallbackGraphEdges, rowData}) => {
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
}, areEqual)
|
||||
export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
|
||||
const [openOSDialog, setOpenOSDialog] = React.useState(false);
|
||||
const getOSIcon = useCallback( () => {
|
||||
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
|
||||
return <FontAwesomeIcon icon={faRobot} />
|
||||
}
|
||||
switch(rowData.payload.os.toLowerCase()){
|
||||
case "windows":
|
||||
return <FontAwesomeIcon icon={faWindows} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faWindows} />
|
||||
case "linux":
|
||||
case "centos":
|
||||
case "redhat":
|
||||
case "debian":
|
||||
case "fedora":
|
||||
case "freebsd":
|
||||
return <FontAwesomeIcon icon={faLinux} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faLinux} />
|
||||
case "macos":
|
||||
return <FontAwesomeIcon icon={faApple} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo}/>
|
||||
return <FontAwesomeIcon icon={faApple} />
|
||||
case "chrome":
|
||||
return <FontAwesomeIcon icon={faChrome} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faChrome} />
|
||||
case "android":
|
||||
return <FontAwesomeIcon icon={faAndroid} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faAndroid} />
|
||||
default:
|
||||
return <FontAwesomeIcon icon={faQuestion} size="2x" style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faQuestion} />
|
||||
}
|
||||
}, []);
|
||||
const displayOSInfo = React.useCallback( () => {
|
||||
}, [rowData?.payload?.os]);
|
||||
const displayOSInfo = React.useCallback( (event) => {
|
||||
event.stopPropagation();
|
||||
setOpenOSDialog(true);
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
{getOSIcon()}
|
||||
<div className="mythic-callback-cellInline items-center inline-flex gap-2 mythic-callback-cellInlineCenter justify-center h-full max-w-full min-w-0 overflow-hidden w-full">
|
||||
<MythicActionButton appearance="raised" compact icon={getOSIcon()} iconOnly onClick={displayOSInfo} tone="primary" tooltip="View operating system information" />
|
||||
{ openOSDialog &&
|
||||
<MythicDisplayTextDialog
|
||||
onClose={()=>{setOpenOSDialog(false);}}
|
||||
@@ -555,9 +459,8 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
|
||||
|
||||
|
||||
)
|
||||
});
|
||||
export const CallbacksTableSleepCell = ({rowData, cellData, updateSleepInfo}) => {
|
||||
const theme = useTheme();
|
||||
}, areEqual);
|
||||
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo, centered = true}) => {
|
||||
const [openSleepDialog, setOpenSleepDialog] = React.useState(false);
|
||||
const editSleepSubmit = (sleep) => {
|
||||
updateSleepInfo({sleep_info: sleep, callback_display_id: rowData.display_id});
|
||||
@@ -566,16 +469,28 @@ export const CallbacksTableSleepCell = ({rowData, cellData, updateSleepInfo}) =>
|
||||
event.stopPropagation();
|
||||
setOpenSleepDialog(true);
|
||||
}
|
||||
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<SnoozeIcon onClick={onOpenSleepDialog}
|
||||
style={{color: cellData === "" ? theme.palette.warning.main : theme.palette.info.main, cursor: "pointer"}}
|
||||
<div className={`mythic-callback-cellInline items-center inline-flex gap-2${centered ? " mythic-callback-cellInlineCenter justify-center" : ""} h-full max-w-full min-w-0 overflow-hidden w-full`}>
|
||||
<MythicActionButton appearance="raised"
|
||||
active={cellData === ""}
|
||||
compact
|
||||
icon={<SnoozeIcon />}
|
||||
iconOnly
|
||||
onClick={onOpenSleepDialog}
|
||||
tone={cellData === "" ? "warning" : "info"}
|
||||
tooltip={cellData === "" ? "No sleep information set" : "View or edit sleep information"}
|
||||
/>
|
||||
{ openSleepDialog &&
|
||||
<MythicDialog fullWidth={true} open={openSleepDialog} onClose={() => {setOpenSleepDialog(false);}}
|
||||
<MythicDialog fullWidth={true} open={openSleepDialog} maxWidth={"md"}
|
||||
style={{height: "100%"}}
|
||||
onClose={() => {setOpenSleepDialog(false);}}
|
||||
innerDialog={
|
||||
<MythicModifyStringDialog title={"Sleep Information"}
|
||||
onClose={() => {setOpenSleepDialog(false);}}
|
||||
<MythicModifyStringDialog title={"View / Edit Sleep Information - This doesn't issue tasks to the agent, but helps alive/dead tracking"}
|
||||
maxRows={20}
|
||||
onClose={() => {setOpenSleepDialog(false);}}
|
||||
value={cellData}
|
||||
onSubmit={editSleepSubmit} />
|
||||
}
|
||||
@@ -585,4 +500,17 @@ export const CallbacksTableSleepCell = ({rowData, cellData, updateSleepInfo}) =>
|
||||
|
||||
|
||||
)
|
||||
}
|
||||
}, areEqual)
|
||||
export const CallbacksTableTagsCell = React.memo(({rowData, cellData}) => {
|
||||
return (
|
||||
<div className="mythic-tag-cell items-center flex gap-3 mythic-tag-cell-fill min-w-0 overflow-hidden h-full w-full">
|
||||
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} target_object_display_id={rowData.display_id} />
|
||||
<div className="mythic-tag-list items-center flex flex-fill flex-nowrap gap-2 mythic-callback-tag-list min-w-0 overflow-hidden">
|
||||
{cellData?.length > 0 ?
|
||||
<TagsDisplay tags={cellData} expand={false} /> :
|
||||
<span className="mythic-callback-tagsEmpty text-xs font-650 truncate text-disabled whitespace-nowrap">No tags</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, areEqual)
|
||||
@@ -1,24 +1,31 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import AppBar from '@mui/material/AppBar';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { CallbacksTabsTaskingLabel, CallbacksTabsTaskingPanel } from './CallbacksTabsTasking';
|
||||
import { CallbacksTabsFileBrowserLabel, CallbacksTabsFileBrowserPanel } from './CallbacksTabsFileBrowser';
|
||||
import { CallbacksTabsProcessBrowserLabel, CallbacksTabsProcessBrowserPanel } from './CallbacksTabsProcessBrowser';
|
||||
import { CallbacksTabsTaskingSplitLabel, CallbacksTabsTaskingSplitPanel} from "./CallbacksTabsTaskingSplit";
|
||||
import {CallbacksTabsTaskingConsoleLabel, CallbacksTabsTaskingConsolePanel} from "./CallbacksTabsTaskingConsole";
|
||||
import {
|
||||
CallbacksTabsCustomFileBasedBrowserLabel,
|
||||
CallbacksTabsCustomFileBasedBrowserPanel
|
||||
} from "./CallbacksTabsCustomFileBasedBrowser";
|
||||
import {CallbackBrowserTreeProvider} from "./CallbackBrowserTreeStore";
|
||||
|
||||
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clickedTabId, setClickedTabId, onEditTabDescription, contextMenuOptions, me, newDataForTab, setNewDataForTab}) {
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
width: '100%',
|
||||
},
|
||||
}));
|
||||
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, onEditTabDescription, contextMenuOptions, me}) {
|
||||
const classes = useStyles();
|
||||
const mountedRef = React.useRef(true);
|
||||
const [value, setValue] = React.useState(0);
|
||||
const handleChange = (event, newValue) => {
|
||||
setValue(newValue);
|
||||
//console.log(newValue);
|
||||
//console.log("clickedTab", newValue, openTabs[newValue]);
|
||||
if(openTabs[newValue] === undefined){return}
|
||||
localStorage.setItem('clickedTab', openTabs[newValue].tabID);
|
||||
setClickedTabId(openTabs[newValue].tabID);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
return() => {
|
||||
@@ -28,81 +35,168 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
}, [])
|
||||
const onCloseTabLocal = ({ tabID, index }) => {
|
||||
if (index > 0) {
|
||||
setClickedTabId(openTabs[index-1].tabID);
|
||||
setValue(index - 1);
|
||||
} else {
|
||||
//console.log("closing index", index)
|
||||
} else if(openTabs.length > 0) {
|
||||
//console.log("closing index length > 0", index)
|
||||
setClickedTabId(openTabs[0].tabID);
|
||||
setValue(0);
|
||||
}
|
||||
onCloseTab({ tabID, index });
|
||||
};
|
||||
useEffect(() => {
|
||||
//console.log(clickedTabId);
|
||||
//console.log(clickedTabId, openTabs);
|
||||
for (let i = 0; i < openTabs.length; i++) {
|
||||
//console.log("openTabs[i]", i, openTabs[i]);
|
||||
if (openTabs[i].tabID === clickedTabId) {
|
||||
//console.log("seting value", i);
|
||||
//console.log("setting value", i);
|
||||
setValue(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [clickedTabId, openTabs]);
|
||||
|
||||
const [collapseTaskRequest, setCollapseTaskRequest] = React.useState({})
|
||||
const modifiedInteractContextMenuOptions = [...contextMenuOptions,
|
||||
{
|
||||
name: 'Collapse All Tasks',
|
||||
click: ({event, index}) => {
|
||||
setCollapseTaskRequest((prevState) => {
|
||||
if(prevState[index] !== undefined){
|
||||
prevState[index] += 1;
|
||||
return {...prevState};
|
||||
} else {
|
||||
return { [index]: 0};
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
},]
|
||||
|
||||
return (
|
||||
<div className={classes.root} style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, height: "100%" }}>
|
||||
<AppBar color='default' position='static'>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
indicatorColor='primary'
|
||||
textColor='primary'
|
||||
variant='scrollable'
|
||||
scrollButtons='auto'
|
||||
style={{ maxWidth: '100%', width: '100%' }}
|
||||
aria-label='scrollable auto tabs example'>
|
||||
{openTabs.map((tab, index) => {
|
||||
switch (tab.tabType) {
|
||||
case 'interact':
|
||||
return (
|
||||
<CallbacksTabsTaskingLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
case 'fileBrowser':
|
||||
return (
|
||||
<CallbacksTabsFileBrowserLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
case 'processBrowser':
|
||||
return (
|
||||
<CallbacksTabsProcessBrowserLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Tabs>
|
||||
<CallbackBrowserTreeProvider openTabs={openTabs}>
|
||||
<div style={{width: "100%", maxWidth: "100%", display: 'flex', flexDirection: 'column',
|
||||
flexGrow: 1, height: "100%", }}>
|
||||
<AppBar color='default' position={"static"} className="no-box-shadow mythic-table-toolbar flex flex-none flex-wrap gap-4 w-full rounded bg-surface-muted border-subtle"
|
||||
style={{marginBottom: "0.2rem"}}>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="callback-tabs-list" direction={"horizontal"}>
|
||||
{(provided) => (
|
||||
<>
|
||||
<Tabs
|
||||
ref={provided.innerRef} {...provided.droppableProps}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
indicatorColor='primary'
|
||||
textColor='primary'
|
||||
variant='fullWidth'
|
||||
scrollButtons={true}
|
||||
TabIndicatorProps={{style: {display: "none"}}}
|
||||
aria-label='scrollable tabs'>
|
||||
{openTabs.map((tab, index) => {
|
||||
switch (tab.tabType) {
|
||||
case 'interact':
|
||||
return (
|
||||
<CallbacksTabsTaskingLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={modifiedInteractContextMenuOptions}
|
||||
/>
|
||||
);
|
||||
case 'interactSplit':
|
||||
return (
|
||||
<CallbacksTabsTaskingSplitLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
)
|
||||
case 'interactConsole':
|
||||
return (
|
||||
<CallbacksTabsTaskingConsoleLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
)
|
||||
case 'customFileBasedBrowser':
|
||||
return (
|
||||
<CallbacksTabsCustomFileBasedBrowserLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType + tab?.customBrowser?.name}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
case 'processBrowser':
|
||||
return (
|
||||
<CallbacksTabsProcessBrowserLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
case 'fileBrowser':
|
||||
return (
|
||||
<CallbacksTabsFileBrowserLabel
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tablabel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
index={index}
|
||||
me={me}
|
||||
newDataForTab={newDataForTab}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{provided.placeholder}
|
||||
</Tabs>
|
||||
</>
|
||||
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</AppBar>
|
||||
|
||||
{openTabs.map((tab, index) => {
|
||||
@@ -119,6 +213,65 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
collapseTaskRequest={collapseTaskRequest}
|
||||
parentMountedRef={mountedRef}
|
||||
/>
|
||||
);
|
||||
case 'interactSplit':
|
||||
return (
|
||||
<CallbacksTabsTaskingSplitPanel
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
parentMountedRef={mountedRef}
|
||||
/>
|
||||
);
|
||||
case 'interactConsole':
|
||||
return (
|
||||
<CallbacksTabsTaskingConsolePanel
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
parentMountedRef={mountedRef}
|
||||
/>
|
||||
)
|
||||
case 'customFileBasedBrowser':
|
||||
return (
|
||||
<CallbacksTabsCustomFileBasedBrowserPanel
|
||||
style={{
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType + tab?.customBrowser?.name}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
@@ -137,10 +290,12 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
parentMountedRef={mountedRef}
|
||||
baseUIFeature={"file_browser"}
|
||||
/>
|
||||
);
|
||||
case 'processBrowser':
|
||||
@@ -155,6 +310,7 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
@@ -166,5 +322,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</CallbackBrowserTreeProvider>
|
||||
);
|
||||
}
|
||||