mirror of
https://github.com/its-a-feature/Mythic.git
synced 2026-11-09 02:30:01 -10:00
Compare commits
1274
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 | ||
|
|
0c034fbfd2 | ||
|
|
7bed629304 | ||
|
|
2807e322fb | ||
|
|
0d14a41351 | ||
|
|
9add701f1a | ||
|
|
d35bfba84f | ||
|
|
69854ee85e | ||
|
|
e42020c1be | ||
|
|
b8e1631450 |
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
|
||||
+13
@@ -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
|
||||
+1675
-1
File diff suppressed because it is too large.
Load diff
@@ -108,7 +108,7 @@ 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/v1.5.2/LICENSE
|
||||
## https://github.com/rubenv/sql-migrate/blob/master/LICENSE
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -132,11 +132,10 @@ 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
|
||||
===========
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2014-2021 by Ruben Vermeersch <[email protected]>
|
||||
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
|
||||
@@ -154,4 +153,26 @@ 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.
|
||||
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/
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -6,8 +6,12 @@ COPY . .
|
||||
|
||||
# ==== BUILD =====
|
||||
# Install dependencies (npm ci makes sure the exact versions in the lockfile gets installed)
|
||||
RUN npm install --legacy-peer-deps
|
||||
#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;
|
||||
}
|
||||
Generated
+8664
-6539
File diff suppressed because it is too large.
Load diff
+61
-42
@@ -6,60 +6,74 @@
|
||||
"author": "@its_a_feature_",
|
||||
"description": "React UI for Mythic C2 Framework",
|
||||
"devDependencies": {
|
||||
"react-scripts": "^5.0.1"
|
||||
"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.7.17",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@mui/icons-material": "^5.14.3",
|
||||
"@mui/lab": "^5.0.0-alpha.138",
|
||||
"@mui/material": "^5.14.3",
|
||||
"@mui/styles": "^5.14.3",
|
||||
"ace-builds": "^1.23.4",
|
||||
"anser": "^2.1.1",
|
||||
"ansi-to-react": "^6.1.6",
|
||||
"d3": "^7.8.5",
|
||||
"dagre-d3": "^0.6.4",
|
||||
"date-fns": "^2.30.0",
|
||||
"elkjs": "^0.8.2",
|
||||
"graphql": "^16.7.1",
|
||||
"graphql-ws": "^5.14.0",
|
||||
"@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",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"ip6": "^0.2.10",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"randexp": "^0.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draggable": "^4.4.5",
|
||||
"react-moment": "^1.1.3",
|
||||
"react-router-dom": "^6.14.2",
|
||||
"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": "^9.1.3",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-window": "^1.8.9",
|
||||
"reactflow": "^11.8.0",
|
||||
"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": "^6.0.5",
|
||||
"subscriptions-transport-ws": "^0.9.18"
|
||||
"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",
|
||||
"build": "npm run react-build",
|
||||
"start": "cross-env BROWSER=none npm run react-start"
|
||||
"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": [
|
||||
@@ -69,7 +83,12 @@
|
||||
},
|
||||
"babel": {
|
||||
"presets": [
|
||||
"react-app"
|
||||
[
|
||||
"react-app",
|
||||
{
|
||||
"runtime": "automatic"
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 305 KiB |
File diff suppressed because it is too large.
Load diff
|
Before Width: | Height: | Size: 784 KiB |
+374
-10
@@ -1,39 +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);
|
||||
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 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];
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
+1316
-133
File diff suppressed because it is too large.
Load diff
@@ -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,56 +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 + ":\n" + data.operationeventlog_stream[0].message;
|
||||
if (data.operationeventlog_stream[0].level === "warning") {
|
||||
snackActions.warning(message, { autoClose: 2000});
|
||||
} else {
|
||||
snackActions.info(message, { autoClose: 2000});
|
||||
}
|
||||
|
||||
}else {
|
||||
if (data.operationeventlog_stream[0].level === "warning") {
|
||||
snackActions.warning(data.operationeventlog_stream[0].message, {autoClose: 3000});
|
||||
} else {
|
||||
snackActions.info(
|
||||
<div style={{width: "100%", whiteSpace: "pre"}}>{data.operationeventlog_stream[0].message}</div>,
|
||||
{autoClose: 3000});
|
||||
}
|
||||
|
||||
}
|
||||
}else if(error){
|
||||
console.error(error);
|
||||
snackActions.error("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,10 +1,9 @@
|
||||
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 {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
|
||||
|
||||
export function MythicConfirmDialog(props) {
|
||||
const handleSubmit = () => {
|
||||
@@ -14,26 +13,30 @@ export function MythicConfirmDialog(props) {
|
||||
}
|
||||
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) {
|
||||
@@ -26,198 +104,389 @@ export function MythicDialog(props) {
|
||||
}
|
||||
}, [props.open]);
|
||||
const dialogOnClick = (e) => {
|
||||
e.stopPropagation();
|
||||
if(e.target.classList.length > 0 && e.target.classList.contains("MuiDialog-container")){
|
||||
props.onClose();
|
||||
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}
|
||||
style={props.style}
|
||||
aria-labelledby="scroll-dialog-title"
|
||||
aria-describedby="scroll-dialog-description"
|
||||
onClick={dialogOnClick}
|
||||
>
|
||||
{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' + header + 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], props.me)}</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], props.me)}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
:
|
||||
(<TableCell style={{wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{convertValueToContextValue(element.name, element.value, props.me) }</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], props.me)}</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>
|
||||
);
|
||||
}
|
||||
@@ -241,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>
|
||||
@@ -252,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>
|
||||
@@ -261,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, me) => {
|
||||
if( key.includes("time") ){
|
||||
const keyText = String(key || "").toLowerCase();
|
||||
if(value === undefined || value === null){
|
||||
return "";
|
||||
}
|
||||
if( keyText.includes("time") ){
|
||||
try{
|
||||
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);
|
||||
@@ -352,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,388 +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);
|
||||
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 className={"hoverme"} 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: item?.metadata?.has_children ? theme.folderColor : theme.palette.text.secondary,
|
||||
}}
|
||||
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: item?.metadata?.has_children ? theme.folderColor : theme.palette.text.secondary, }}
|
||||
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:
|
||||
item?.metadata?.has_children ||
|
||||
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"
|
||||
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,109 +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}>
|
||||
<div className={classes.cellInner} style={{height: style.height}}>
|
||||
{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>
|
||||
<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 [isDragging, setIsDragging] = useState(false);
|
||||
const classes = useStyles();
|
||||
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;
|
||||
+181
@@ -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",
|
||||
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,66 +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 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} />}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
+480
-136
@@ -5,20 +5,218 @@ 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 HeaderCellContext = createContext({});
|
||||
|
||||
const MIN_COLUMN_WIDTH = 100;
|
||||
|
||||
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;
|
||||
};
|
||||
const innerElementType = React.forwardRef(({ children, ...rest }, ref) => {
|
||||
const classes = useStyles();
|
||||
|
||||
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} {...rest}>
|
||||
<div ref={ref} style={{...style, position: "relative"}}>
|
||||
{/* always render header cells */}
|
||||
<div
|
||||
className={classes.headerCellRow}
|
||||
@@ -32,13 +230,12 @@ const innerElementType = React.forwardRef(({ children, ...rest }, ref) => {
|
||||
key={i}
|
||||
headerNameKey={HeaderCellData.headerNameKey}
|
||||
onClick={HeaderCellData.onClickHeader}
|
||||
onDoubleClick={(e, columnIndex) => {
|
||||
if (column.disableAutosize) return;
|
||||
HeaderCellData.autosizeColumn({columnIndex});
|
||||
}}
|
||||
onDoubleClick={onHeaderDoubleClick}
|
||||
contextMenuOptions={HeaderCellData.contextMenuOptions}
|
||||
sortIndicatorIndex={HeaderCellData.sortIndicatorIndex}
|
||||
sortDirection={HeaderCellData.sortDirection}
|
||||
isResizing={HeaderCellData.resizingColumnIndex === i}
|
||||
onResizePointerDown={HeaderCellData.startColumnResize}
|
||||
VariableSizeGridProps={{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
@@ -57,14 +254,24 @@ const innerElementType = React.forwardRef(({ children, ...rest }, ref) => {
|
||||
</div>
|
||||
{/* render other cells as usual */}
|
||||
{children}
|
||||
{resizingColumnIndex >= 0 &&
|
||||
<div
|
||||
className={classes.resizeGuide}
|
||||
style={{
|
||||
left: resizeGuideLeft,
|
||||
height: style.height,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const getShortRandomString = () => {
|
||||
export const GetShortRandomString = () => {
|
||||
return (Math.random() + 1).toString(36).substring(2);
|
||||
}
|
||||
const ResizableGridWrapper = ({
|
||||
name,
|
||||
columns,
|
||||
sortIndicatorIndex,
|
||||
sortDirection,
|
||||
@@ -74,17 +281,43 @@ 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 gridUUID = React.useMemo( () => getShortRandomString(), []);
|
||||
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;
|
||||
@@ -93,106 +326,210 @@ const ResizableGridWrapper = ({
|
||||
);
|
||||
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, AutoSizerProps.width]); // 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){
|
||||
if(columns[columnIndex].plaintext){
|
||||
columns[columnIndex].key = columns[columnIndex].plaintext;
|
||||
} else {
|
||||
return 30;
|
||||
}
|
||||
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
|
||||
if(didUpdateWidths){
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
if(columns[columnIndex].key){
|
||||
if(columns[columnIndex].key.includes("time")){
|
||||
return 30;
|
||||
}
|
||||
try{
|
||||
items = JSON.parse(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key]);
|
||||
if(Array.isArray(items) && items.length > 0){
|
||||
return String(items[0]).length;
|
||||
}
|
||||
}catch(error){
|
||||
//console.log(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key])
|
||||
}
|
||||
let data = itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key];
|
||||
if(!data){
|
||||
return 3;
|
||||
}
|
||||
if(data.plaintext){
|
||||
return String(data.plaintext).length || -1;
|
||||
} else {
|
||||
return String(data).length || -1;
|
||||
}
|
||||
//return String(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key]).length || -1;
|
||||
} else if(typeof(itemRow[columnIndex]?.props?.cellData) === "string"){
|
||||
try{
|
||||
items = JSON.parse(itemRow[columnIndex]?.props?.cellData);
|
||||
if(Array.isArray(items) && items.length > 0){
|
||||
return String(items[0]).length;
|
||||
}
|
||||
}catch(error){
|
||||
return itemRow[columnIndex]?.props?.cellData.length;
|
||||
}
|
||||
|
||||
}else {
|
||||
return itemRow[columnIndex]?.props?.cellData?.length || -1;
|
||||
}
|
||||
}, [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);
|
||||
}
|
||||
|
||||
}));
|
||||
const updatedWidths = columnWidths.map((columnWidth, index) => {
|
||||
if (columnIndex === index) {
|
||||
return Math.floor(Math.max(longestElementInColumn * 10 + 40, MIN_COLUMN_WIDTH));
|
||||
}
|
||||
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 headerCellData = {
|
||||
"getRowHeight": getRowHeight,
|
||||
@@ -201,16 +538,19 @@ const ResizableGridWrapper = ({
|
||||
"headerNameKey": headerNameKey,
|
||||
"onClickHeader": onClickHeader,
|
||||
"autosizeColumn": autosizeColumn,
|
||||
"contextMenuOptions": contextMenuOptions,
|
||||
"contextMenuOptions": headerContextMenuOptions,
|
||||
"sortIndicatorIndex": sortIndicatorIndex,
|
||||
"sortDirection": sortDirection,
|
||||
"getColumnWidth": getColumnWidth,
|
||||
"resizingColumnIndex": resizingColumnIndex,
|
||||
"startColumnResize": startColumnResize,
|
||||
"itemsWithHeader": itemsWithHeader,
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<HeaderCellContext.Provider value={headerCellData}>
|
||||
<VariableSizeGrid
|
||||
className={classes.grid}
|
||||
height={Math.max(1, AutoSizerProps.height)}
|
||||
width={AutoSizerProps.width}
|
||||
columnCount={columns.length}
|
||||
@@ -218,26 +558,16 @@ const ResizableGridWrapper = ({
|
||||
headerNameKey={headerNameKey}
|
||||
rowCount={itemsWithHeader.length}
|
||||
rowHeight={getRowHeight}
|
||||
itemData={{ items: itemsWithHeader, onDoubleClickRow, gridUUID, rowContextMenuOptions}}
|
||||
itemData={{ items: itemsWithHeader,
|
||||
onDoubleClickRow, gridUUID,
|
||||
rowContextMenuOptions, onRowClick,
|
||||
onRowContextMenuClick}}
|
||||
innerElementType={innerElementType}
|
||||
overscanRowCount={0}
|
||||
onScroll={({ scrollLeft }) => {
|
||||
if (dragHandlesRef.current) {
|
||||
dragHandlesRef.current.scrollTo({ left: scrollLeft });
|
||||
}
|
||||
}}
|
||||
overscanRowCount={20}
|
||||
useIsScrolling={false}
|
||||
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>
|
||||
|
||||
</>
|
||||
@@ -254,28 +584,40 @@ const MythicResizableGrid = ({
|
||||
onDoubleClickRow,
|
||||
contextMenuOptions,
|
||||
rowContextMenuOptions,
|
||||
onRowContextMenuClick,
|
||||
widthMeasureKey,
|
||||
rowHeight = 32,
|
||||
rowHeight = 20,
|
||||
headerRowHeight = 20,
|
||||
callbackTableGridRef,
|
||||
onRowClick,
|
||||
name,
|
||||
}) => {
|
||||
return (
|
||||
<AutoSizer style={{height: "100%"}}>
|
||||
{(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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -285,6 +627,7 @@ MythicResizableGrid.propTypes = {
|
||||
name: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
disableAutosize: PropTypes.bool,
|
||||
disableResize: PropTypes.bool,
|
||||
disableSort: PropTypes.bool
|
||||
})
|
||||
).isRequired,
|
||||
@@ -296,6 +639,7 @@ MythicResizableGrid.propTypes = {
|
||||
contextMenuOptions: PropTypes.array,
|
||||
rowContextMenuOptions: PropTypes.array,
|
||||
rowHeight: PropTypes.number,
|
||||
headerRowHeight: PropTypes.number,
|
||||
headerNameKey: PropTypes.string,
|
||||
widthMeasureKey: PropTypes.string
|
||||
};
|
||||
|
||||
@@ -1,68 +1,24 @@
|
||||
import { makeStyles } from '@mui/styles';
|
||||
|
||||
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',
|
||||
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}} style={{zIndex: 1000}} {...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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+16
-15
@@ -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,91 +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,
|
||||
variant: PropTypes.string,
|
||||
inline: PropTypes.bool,
|
||||
marginBottom: PropTypes.string,
|
||||
}
|
||||
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, evt);
|
||||
}
|
||||
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%", display: this.props.inline ? "inline-block": "",}}>
|
||||
<ValidationTextField
|
||||
fullWidth={true}
|
||||
placeholder={this.props.placeholder}
|
||||
value={this.props.value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyPress}
|
||||
label={this.props.showLabel === undefined ? this.props.name : this.props.showLabel ? this.props.name : undefined}
|
||||
autoFocus={this.props.autoFocus}
|
||||
variant={this.props.variant === undefined ? "outlined" : this.props.variant}
|
||||
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}
|
||||
onWheel={ event => event.target.blur() }
|
||||
InputProps={this.props.InputProps}
|
||||
helperText={this.checkError() ? this.props.errorText : this.props.helperText}
|
||||
style={{
|
||||
padding:0,
|
||||
marginBottom: this.props.marginBottom ? this.props.marginBottom : "10px",
|
||||
display: this.props.inline ? "inline-block": "",
|
||||
}}/>
|
||||
</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,217 +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 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 { CardContent } from '@mui/material';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: 'auto',
|
||||
},
|
||||
paper: {
|
||||
width: 200,
|
||||
},
|
||||
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) => (
|
||||
<>
|
||||
<CardHeader
|
||||
className={classes.cardHeader}
|
||||
title={title}
|
||||
/>
|
||||
<Divider classes={{root: classes.divider}}/>
|
||||
<CardContent style={{flexGrow: 1, height: "100%", width: "100%", overflowY: "auto", padding: 0}}>
|
||||
<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>
|
||||
</CardContent>
|
||||
</>
|
||||
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}>
|
||||
<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="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
disabled={leftChecked.length === 0}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
disabled={rightChecked.length === 0}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
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", overflowY: "auto", flexDirection: "column" }}>
|
||||
{customList(rightTitle, right)}
|
||||
</div>
|
||||
</div>
|
||||
<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,516 +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 { useReactiveVar } from '@apollo/client';
|
||||
import { menuOpen, FailedRefresh } from '../cache';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import { TopAppBarNotifications } from './TopAppBarNotifications';
|
||||
import { EventFeedNotifications } from './EventFeedNotifications';
|
||||
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(null);
|
||||
const [documentationAnchorEl, setDocumentationAnchorEl] = React.useState(null);
|
||||
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(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = (evt) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleDocumentationMenu = (event) => {
|
||||
setDocumentationAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleDocumentationClose = (evt) => {
|
||||
setDocumentationAnchorEl(null);
|
||||
};
|
||||
|
||||
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"
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={Boolean(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"
|
||||
anchorEl={documentationAnchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={Boolean(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><HeadsetIcon 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="info"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,7 +3,8 @@ import { gql, useMutation, useSubscription } from '@apollo/client';
|
||||
import {BrowserScriptsTable} from './BrowserScriptsTable';
|
||||
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`
|
||||
@@ -78,7 +79,7 @@ export function BrowserScripts({me}){
|
||||
}
|
||||
} )
|
||||
setBrowserScripts(scripts);
|
||||
if(backdropOpen){setBackdropOpen(false);}
|
||||
setBackdropOpen(false);
|
||||
}
|
||||
});
|
||||
const [toggleActive] = useMutation(updateBrowserScriptActive, {
|
||||
@@ -117,6 +118,7 @@ export function BrowserScripts({me}){
|
||||
}
|
||||
});
|
||||
const onToggleActive = ({browserscript_id, active}) => {
|
||||
setBackdropOpen(true);
|
||||
toggleActive({variables: {browserscript_id, active}});
|
||||
}
|
||||
const onSubmitEdit = ({browserscript_id, script, command_id, payload_type_id}) => {
|
||||
@@ -134,9 +136,9 @@ 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}
|
||||
@@ -144,6 +146,6 @@ export function BrowserScripts({me}){
|
||||
onSubmitEdit={onSubmitEdit} onRevert={onRevert} onSubmitNew={onSubmitCreateNewBrowserScript}
|
||||
/>
|
||||
|
||||
</React.Fragment>
|
||||
</MythicPageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +1,130 @@
|
||||
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 me={props.me} 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: "3rem"}}>Edit</TableCell>
|
||||
<TableCell style={{width: "5rem"}}>Active</TableCell>
|
||||
<TableCell style={{width: "15rem"}}>Payload</TableCell>
|
||||
<TableCell style={{width: "20rem"}}>Command</TableCell>
|
||||
<TableCell style={{width: "12rem"}}> Author</TableCell>
|
||||
<TableCell style={{textAlign: "left"}}>User Modified?</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) => (
|
||||
{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}
|
||||
@@ -58,9 +135,8 @@ export function BrowserScriptsTable(props){
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
</TableContainer>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React from 'react';
|
||||
import { Switch} from '@mui/material';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
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 SettingsIcon from '@mui/icons-material/Settings';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
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 [openEdit, setOpenEdit] = React.useState(false);
|
||||
@@ -20,23 +24,44 @@ export function BrowserScriptsTableRow(props){
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableRow key={"payload" + props.id} hover>
|
||||
<TableCell >
|
||||
<IconButton size="small" onClick={()=>{setOpenEdit(true);}} color="info" variant="contained"><SettingsIcon color="info" /></IconButton>
|
||||
</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>
|
||||
<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}
|
||||
@@ -50,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';
|
||||
@@ -14,11 +15,22 @@ 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 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 {taskingDataFragment} from '../Callbacks/CallbacksTabsTasking'
|
||||
|
||||
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`
|
||||
@@ -42,6 +54,38 @@ query getAvailableTasks($command_id: Int!){
|
||||
}
|
||||
`;
|
||||
|
||||
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();
|
||||
const [script, setScript] = React.useState("");
|
||||
@@ -49,12 +93,19 @@ export function EditScriptDialog(props) {
|
||||
const [selectedCommand, setSelectedCommand] = React.useState('');
|
||||
const [payloadTypeCmdOptions, setPayloadTypeCmdOptions] = React.useState([]);
|
||||
const [commandOptions, setCommandOptions] = React.useState([]);
|
||||
const inputPTRef = useRef(null);
|
||||
const inputCMDRef = useRef(null);
|
||||
const inputTaskRef = useRef(null);
|
||||
const [availableTasks, setAvailableTasks] = React.useState([]);
|
||||
const [selectedTask, setSelectedTask] = React.useState('');
|
||||
useQuery(getCommandsAndPayloadTypesQuery, {
|
||||
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){
|
||||
@@ -66,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){
|
||||
@@ -84,11 +132,13 @@ export function EditScriptDialog(props) {
|
||||
|
||||
}
|
||||
});
|
||||
const [getAvailableTasks] = useLazyQuery(getExistingTasksForCommand, {
|
||||
const [getAvailableTasks, {loading: tasksLoading}] = useLazyQuery(getExistingTasksForCommand, {
|
||||
onCompleted: data => {
|
||||
setAvailableTasks(data.task);
|
||||
if(data.task.length > 0){
|
||||
setSelectedTask(data.task[0]);
|
||||
}else{
|
||||
setSelectedTask('');
|
||||
}
|
||||
},
|
||||
onError: data => {
|
||||
@@ -100,15 +150,20 @@ export function EditScriptDialog(props) {
|
||||
const [logOutput, setLogOutput] = React.useState("console.log messages:\n");
|
||||
const logStreamRef = React.useRef("console.log messages:\n");
|
||||
useEffect( () => {
|
||||
getAvailableTasks({variables: {command_id: props.command_id}})
|
||||
}, []);
|
||||
if(selectedCommand !== ""){
|
||||
getAvailableTasks({variables: {command_id: selectedCommand}})
|
||||
}else{
|
||||
setAvailableTasks([]);
|
||||
setSelectedTask('');
|
||||
}
|
||||
|
||||
}, [selectedCommand, getAvailableTasks]);
|
||||
useEffect( () => {
|
||||
if(props.script !== undefined){
|
||||
try{
|
||||
//setScript(atob(props.script));
|
||||
setScript(decodeURIComponent(window.atob(props.script)));
|
||||
}catch(error){
|
||||
console.log(error)
|
||||
setScript(props.script);
|
||||
}
|
||||
}
|
||||
@@ -135,9 +190,12 @@ export function EditScriptDialog(props) {
|
||||
}
|
||||
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);
|
||||
@@ -146,31 +204,48 @@ export function EditScriptDialog(props) {
|
||||
setSelectedCommand(event.target.value);
|
||||
}
|
||||
const onLoad = (editor) => {
|
||||
// Your editor options comes here
|
||||
editor.on('change', (arg, activeEditor) => {
|
||||
editorRef.current = activeEditor;
|
||||
editor.removeEventListener('change');
|
||||
editorRef.current.resize();
|
||||
});
|
||||
editorRef.current = editor;
|
||||
requestAnimationFrame(() => editor.resize());
|
||||
}
|
||||
const onOutputLoad = (editor) => {
|
||||
// Your editor options comes here
|
||||
editor.on('change', (arg, activeEditor) => {
|
||||
outputRef.current = activeEditor;
|
||||
editor.removeEventListener('change');
|
||||
outputRef.current.resize();
|
||||
});
|
||||
outputRef.current = editor;
|
||||
requestAnimationFrame(() => editor.resize());
|
||||
}
|
||||
React.useEffect( () => {
|
||||
var logBackup = console.log;
|
||||
console.log = function(msg) {
|
||||
logStreamRef.current += "\n" + msg;
|
||||
logBackup.apply(msg);
|
||||
setLogOutput(logStreamRef.current)
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onDragging = () => {
|
||||
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();
|
||||
}
|
||||
@@ -178,128 +253,227 @@ export function EditScriptDialog(props) {
|
||||
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 " + props.author + "'s BrowserScript Code"}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: `calc(100vh)`, display: "flex", flexDirection: "column", width: "100%"}}>
|
||||
<div style={{display: "flex"}}>
|
||||
<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%"}}/>}
|
||||
<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
|
||||
>
|
||||
{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%"}}/>}
|
||||
<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}
|
||||
>
|
||||
{commandOptions.map( (opt) => (
|
||||
<MenuItem value={opt.id} key={"command" + opt.id}>{opt.cmd}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<p>
|
||||
<b>To test locally</b>: Make your changes in the top left code box. Any <b>console.log</b> entries will appear in the box to the right when executed.
|
||||
Click <b>Save for Testing</b> to finalize your changes. Make sure to select an already executed task that matches this command from the bottom.
|
||||
You will need to collapse and re-expand the task to pull in your updated changes.
|
||||
</p>
|
||||
<div style={{display: "flex", width: "100%", flexGrow: 1}}>
|
||||
<Split direction="vertical" style={{height: "100%", width: "100%"}} sizes={[30, 70]} onDrag={onDragging} >
|
||||
<Split style={{display: "flex"}} sizes={[70, 30]} onDrag={onDragging} >
|
||||
<div className="bg-gray-light">
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
onLoad={onLoad}
|
||||
height="100%"
|
||||
value={script}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
useWorker: false
|
||||
}}
|
||||
/>
|
||||
<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="bg-gray-light" >
|
||||
<AceEditor
|
||||
mode="javascript"
|
||||
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
|
||||
width="100%"
|
||||
onLoad={onOutputLoad}
|
||||
height="100%"
|
||||
value={logOutput}
|
||||
focus={false}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
useWorker: false
|
||||
}}
|
||||
/>
|
||||
<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="bg-gray-light" >
|
||||
<FormControl style={{width: "100%"}}>
|
||||
<InputLabel ref={inputTaskRef} style={{paddingTop: "10px"}}>Test Script With Task</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selectedTask}
|
||||
onChange={onChangeTask}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
>
|
||||
{availableTasks.map( (opt) => (
|
||||
<MenuItem value={opt} key={"task" + opt.id}>{opt.command_name + " " + opt.display_params}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedTask !== "" &&
|
||||
<TaskDisplay me={props.me} task={selectedTask} command_id={selectedTask.command == null ? 0 : selectedTask.command.id} />
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
</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={onTest} variant="contained" color="info">
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton intent="info" disabled={selectedCommand === "" || selectedPayloadType === ""} onClick={onTest}>
|
||||
Save For Testing
|
||||
</Button>
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
|
||||
Save and Exit
|
||||
</Button>
|
||||
</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="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
disabled={leftChecked.length === 0}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
disabled={rightChecked.length === 0}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
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"},
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -16,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}) {
|
||||
@@ -27,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
|
||||
}
|
||||
@@ -36,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
|
||||
}
|
||||
@@ -59,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
|
||||
}
|
||||
@@ -82,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 TocIcon from '@mui/icons-material/Toc';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { CallbacksTop } from './CallbacksTop';
|
||||
import Split from 'react-split'
|
||||
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),
|
||||
},
|
||||
},
|
||||
speedDialAction: {
|
||||
backgroundColor: theme.palette.speedDialAction,
|
||||
},
|
||||
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 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,12 +69,26 @@ export function Callbacks({me}) {
|
||||
console.log('failed to parse oldTabs', error);
|
||||
}
|
||||
}
|
||||
const oldSizes = localStorage.getItem("callbackTableSplitSizes");
|
||||
if (oldSizes) {
|
||||
try{
|
||||
setCallbackTableSplitSizes(JSON.parse(oldSizes));
|
||||
}catch(error){
|
||||
console.log("failed to parse callback table split sizes");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
useEffect( () => {
|
||||
openTabRef.current = openTabs;
|
||||
}, [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;
|
||||
});
|
||||
@@ -71,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) {
|
||||
@@ -94,6 +138,10 @@ export function Callbacks({me}) {
|
||||
});
|
||||
localStorage.setItem('openTabs', JSON.stringify(tabSet));
|
||||
setOpenTabs(tabSet);
|
||||
if(tabSet.length === 0){
|
||||
setClickedTabId("0");
|
||||
localStorage.removeItem("clickedTab");
|
||||
}
|
||||
}, [openTabs]);
|
||||
const onDragTab = ({selected, toLeftOf}) => {
|
||||
//console.log("onDragTab in CallbacksTabs", selected, toLeftOf);
|
||||
@@ -122,7 +170,7 @@ export function Callbacks({me}) {
|
||||
newOpenTabList.push(openTabs[targetTabIndex]);
|
||||
newOpenTabList.push(openTabs[i]);
|
||||
}
|
||||
|
||||
|
||||
setClickedTabId(openTabs[targetTabIndex].tabID)
|
||||
} else {
|
||||
newOpenTabList.push(openTabs[i]);
|
||||
@@ -155,108 +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 (
|
||||
|
||||
<React.Fragment>
|
||||
<SpeedDialWrapper setTopDisplay={setTopDisplay} />
|
||||
<Split direction="vertical" sizes={[30, 70]} minSize={[0,0]} style={{ height: "100%" }}>
|
||||
<div className="bg-gray-base">
|
||||
<CallbacksTop topDisplay={topDisplay} onOpenTab={onOpenTab.current} 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 className="bg-gray-mid">
|
||||
<div >
|
||||
<CallbacksTabs
|
||||
onCloseTab={onCloseTab}
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
key={'callbackstabs'}
|
||||
clickedTabId={clickedTabId}
|
||||
setClickedTabId={setClickedTabId}
|
||||
openTabs={openTabs}
|
||||
onDragTab={onDragTab}
|
||||
me={me}
|
||||
onDragEnd={onDragEnd}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
newDataForTab={newDataForTab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
/>
|
||||
</div>
|
||||
</Split>
|
||||
</React.Fragment>
|
||||
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
/*
|
||||
<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}/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, flexBasis: heights.bottom, height: heights.bottom }}>
|
||||
<CallbacksTabs
|
||||
onCloseTab={onCloseTab}
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
tabHeight={heights.bottom}
|
||||
maxHeight={heights.bottom}
|
||||
key={'callbackstabs'}
|
||||
clickedTabId={clickedTabId}
|
||||
openTabs={openTabs}
|
||||
onDragTab={onDragTab}
|
||||
me={me}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
*/
|
||||
function SpeedDialWrapperPreMemo({ setTopDisplay }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const classes = useStyles();
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <TocIcon />,
|
||||
name: 'Table layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('table');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <AssessmentIcon />,
|
||||
name: 'Graph layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('graph');
|
||||
},
|
||||
},
|
||||
],
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<SpeedDial
|
||||
ariaLabel='SpeedDial example'
|
||||
className={classes.speedDial}
|
||||
icon={<SpeedDialIcon />}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onOpen={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
FabProps={{ color: 'info', size: "small", variant: "extended" }}
|
||||
open={open}
|
||||
style={{ marginTop: '35px' }}
|
||||
direction='down'>
|
||||
{actions.map((action) => (
|
||||
<SpeedDialAction
|
||||
arrow
|
||||
className={classes.speedDialAction}
|
||||
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,16 +1,9 @@
|
||||
import React, {useRef, useEffect, useState, useMemo, useContext} from 'react';
|
||||
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 {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';
|
||||
@@ -18,13 +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} from './CallbacksTop';
|
||||
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;
|
||||
@@ -62,12 +58,10 @@ query loadedLinkCommandsQuery ($callback_id: Int!){
|
||||
|
||||
const GraphViewOptions = ({viewConfig, setViewConfig}) => {
|
||||
const theme = useTheme();
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
const [showConfiguration, setShowConfiguration] = React.useState(false);
|
||||
const labelComponentOptions = ["id", "user", "host", "ip", "domain", "os", "process_name"];
|
||||
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(["id", "user"]);
|
||||
const [selectedGroupBy, setSelectedGroupBy] = React.useState("None");
|
||||
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 {
|
||||
@@ -87,134 +81,117 @@ const GraphViewOptions = ({viewConfig, setViewConfig}) => {
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, group_by: selectedGroupBy});
|
||||
}, [selectedGroupBy])
|
||||
const handleDropdownToggle = (evt) => {
|
||||
evt.stopPropagation();
|
||||
setDropdownOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
const handleMenuItemClick = (event, index) => {
|
||||
options[index].click();
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
const options = [
|
||||
{name: showConfiguration ? "Hide Grouping Options": "Show Grouping Options", click: () => {
|
||||
setShowConfiguration(!showConfiguration);
|
||||
}},
|
||||
{name: viewConfig["include_disconnected"] ? 'Show Only Active Edges' : "Show All Edges", click: () => {
|
||||
const view = {...viewConfig, include_disconnected: !viewConfig["include_disconnected"]};
|
||||
setViewConfig(view);
|
||||
}},
|
||||
{name: viewConfig["show_all_nodes"] ? 'Hide inactive callbacks' : 'Show All Callbacks', click: () => {
|
||||
const view = {...viewConfig, show_all_nodes: !viewConfig["show_all_nodes"]};
|
||||
setViewConfig(view);
|
||||
}},
|
||||
{name: viewConfig["rankDir"] === "LR" ? 'Change Layout to Top-Bottom' : "Change Layout to Left-Right", click: () => {
|
||||
if(viewConfig["rankDir"] === "LR"){
|
||||
const view = {...viewConfig, rankDir: "TB"};
|
||||
setViewConfig(view);
|
||||
}else{
|
||||
const view = {...viewConfig, rankDir: "LR"};
|
||||
setViewConfig(view);
|
||||
}
|
||||
}},
|
||||
{name: viewConfig["packet_flow_view"] ? "View Connection Directions" : "View Egress Routes" , click: () => {
|
||||
const view = {...viewConfig, packet_flow_view: !viewConfig["packet_flow_view"]};
|
||||
setViewConfig(view);
|
||||
}},
|
||||
];
|
||||
const handleClose = (event) => {
|
||||
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
const toggleViewConfig = (field) => {
|
||||
setViewConfig({...viewConfig, [field]: !viewConfig[field]});
|
||||
}
|
||||
const toggleRankDirection = () => {
|
||||
setViewConfig({...viewConfig, rankDir: viewConfig["rankDir"] === "LR" ? "TB" : "LR"});
|
||||
}
|
||||
return (
|
||||
<div >
|
||||
<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}>
|
||||
Configure Graph View <ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<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 &&
|
||||
<FormControl sx={{ width: 200, marginTop: "10px", backgroundColor: theme.palette.background.default}} >
|
||||
<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}
|
||||
<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" />}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
}
|
||||
{showConfiguration &&
|
||||
<FormControl sx={{minWidth: 300, marginTop: "10px",backgroundColor: theme.palette.background.default}}>
|
||||
<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>
|
||||
}
|
||||
|
||||
<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) => (
|
||||
{groupByOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={option.name}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
key={name}
|
||||
value={name}
|
||||
>
|
||||
{option.name}
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</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 theme = useTheme();
|
||||
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);
|
||||
@@ -256,17 +233,18 @@ export function CallbacksGraph({onOpenTab}){
|
||||
|
||||
}
|
||||
});
|
||||
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: "TB",
|
||||
label_components: ["id", "user"],
|
||||
label_components: ["display_id", "user"],
|
||||
packet_flow_view: true,
|
||||
include_disconnected: true,
|
||||
include_disconnected: false,
|
||||
show_all_nodes: false,
|
||||
group_by: "None"
|
||||
group_by: "host"
|
||||
});
|
||||
const [hideCallback] = useMutation(hideCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
@@ -317,24 +295,24 @@ export function CallbacksGraph({onOpenTab}){
|
||||
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 contextMenu = useMemo(() => {return [
|
||||
{
|
||||
title: 'Hide Callback',
|
||||
onClick: function(node) {
|
||||
hideCallback({variables: {callback_display_id: node.display_id}});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Interact',
|
||||
title: 'Open Tasking',
|
||||
onClick: function(node){
|
||||
console.log(node);
|
||||
onOpenTab({tabType: "interact", tabID: node.callback_id + "interact", callbackID: node.callback_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Manually Remove Edge",
|
||||
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){
|
||||
@@ -356,15 +334,8 @@ export function CallbacksGraph({onOpenTab}){
|
||||
setManuallyRemoveEdgeDialogOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Manually Add Edge",
|
||||
onClick: function(node){
|
||||
setAddEdgeSource(node);
|
||||
setManuallyAddEdgeDialogOpen(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: "Task Callback for Edge",
|
||||
title: "Task Link Command",
|
||||
onClick: function(node){
|
||||
setLinkCommands([]);
|
||||
setSelectedLinkCommand(null);
|
||||
@@ -373,15 +344,48 @@ export function CallbacksGraph({onOpenTab}){
|
||||
setSelectedCallback(node);
|
||||
}
|
||||
},
|
||||
]}, [getLinkCommands, hideCallback, viewConfig, onOpenTab]);
|
||||
|
||||
{
|
||||
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);
|
||||
}
|
||||
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>
|
||||
</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"} />}
|
||||
/>
|
||||
}
|
||||
@@ -395,7 +399,7 @@ export function CallbacksGraph({onOpenTab}){
|
||||
{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 &&
|
||||
@@ -406,10 +410,9 @@ export function CallbacksGraph({onOpenTab}){
|
||||
action={"select"} display={"display"} identifier={"display"}/>}
|
||||
/>
|
||||
}
|
||||
<DrawC2PathElementsFlowWithProvider edges={callbackgraphedges} view_config={viewConfig} theme={theme}
|
||||
panel={<GraphViewOptions setViewConfig={setViewConfig} viewConfig={viewConfig} />}
|
||||
contextMenu={contextMenu}/>
|
||||
<DrawC2PathElementsFlowWithProvider providedNodes={callbacks} edges={callbackgraphedges} view_config={viewConfig}
|
||||
filterOptions={filterOptions}
|
||||
panel={<GraphViewOptions setViewConfig={setViewConfig} viewConfig={viewConfig} />} contextMenu={contextMenu}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
+141
@@ -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>
|
||||
);
|
||||
}
|
||||
+102
@@ -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,58 +1,41 @@
|
||||
import React, {useCallback, useEffect, useContext} from 'react';
|
||||
import {Button} from '@mui/material';
|
||||
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 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,
|
||||
lockCallbackMutation,
|
||||
unlockCallbackMutation,
|
||||
updateIPsCallbackMutation
|
||||
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 {DetailedCallbackTable} from './DetailedCallbackTable';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import {TaskFromUIButton} from './TaskFromUIButton';
|
||||
import { MythicSelectFromRawListDialog } from '../../MythicComponents/MythicSelectFromListDialog';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import { areEqual } from 'react-window';
|
||||
import {CallbackGraphEdgesContext} from './CallbacksTop';
|
||||
import Moment from 'react-moment';
|
||||
import moment from 'moment';
|
||||
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";
|
||||
|
||||
export const CallbacksTableIDCell = React.memo(({rowData, onOpenTab, toggleLock, updateDescription, setOpenHideMultipleDialog, setOpenTaskMultipleDialog}) =>{
|
||||
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 onOpenTab = useContext(OnOpenTabContext);
|
||||
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact"})
|
||||
const [rowDataStatic, setRowDataStatic] = React.useState(rowData);
|
||||
React.useEffect( () => {
|
||||
let update = false;
|
||||
@@ -71,222 +54,130 @@ export const CallbacksTableIDCell = React.memo(({rowData, onOpenTab, toggleLock,
|
||||
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 [lockCallback] = useMutation(lockCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.updateCallback.status === "success"){
|
||||
snackActions.success("Locked callback");
|
||||
}else{
|
||||
snackActions.warning(data.updateCallback.error);
|
||||
}
|
||||
|
||||
},
|
||||
onError: data => {
|
||||
console.log(data);
|
||||
snackActions.warning(data);
|
||||
}
|
||||
});
|
||||
const [unlockCallback] = useMutation(unlockCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.updateCallback.status === "success"){
|
||||
snackActions.success("Unlocked callback");
|
||||
}else{
|
||||
snackActions.warning(data.updateCallback.error);
|
||||
}
|
||||
|
||||
},
|
||||
onError: data => {
|
||||
console.log(data);
|
||||
snackActions.warning(data);
|
||||
}
|
||||
});
|
||||
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 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);
|
||||
};
|
||||
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 = () => {
|
||||
if(rowDataStatic.locked){
|
||||
unlockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
|
||||
}else{
|
||||
lockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
|
||||
}
|
||||
}
|
||||
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({open: true, data: rowDataStatic});
|
||||
}
|
||||
},
|
||||
{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: 'Split Tasking', icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: (evt) => {
|
||||
evt.stopPropagation();
|
||||
localOnOpenTab("interactSplit");
|
||||
}},
|
||||
{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"}
|
||||
aria-haspopup="menu"
|
||||
onClick={handleDropdownToggle}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
</Button>
|
||||
</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} />}
|
||||
/>
|
||||
}
|
||||
{openEditDescriptionDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
open={openEditDescriptionDialog}
|
||||
onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
innerDialog={
|
||||
<MythicModifyStringDialog title={"Edit Callback's Description"}
|
||||
onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
value={rowDataStatic.description}
|
||||
onSubmit={editDescriptionSubmit}
|
||||
/>
|
||||
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;
|
||||
}
|
||||
}
|
||||
return message.join("\n");
|
||||
}, [rowDataStatic]);
|
||||
const handleDropdownToggle = (event) => {
|
||||
event.stopPropagation();
|
||||
callbackDropdown({rowDataStatic, event});
|
||||
};
|
||||
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()
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
ref={dropdownAnchorRef}
|
||||
colorMode={highIntegrity ? "always" : "hover"}
|
||||
tone={highIntegrity ? "error" : "primary"}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
|
||||
</div>
|
||||
{rowDataStatic.locked &&
|
||||
<MythicStyledTooltip title={`Locked by ${lockOwner}`}>
|
||||
<MythicStatusChip
|
||||
aria-label={`Unlock callback ${rowDataStatic.display_id}`}
|
||||
compact
|
||||
iconOnly
|
||||
onClick={handleOpenUnlockDialog}
|
||||
status="locked"
|
||||
/>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{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>
|
||||
}
|
||||
{rowDataStatic.callbackports.length > 0 &&
|
||||
<MythicStyledTooltip title={proxyMessage()}>
|
||||
<MythicStatusChip compact icon={<FontAwesomeIcon icon={faSocks} />} iconOnly label="Proxy active" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
areEqual)
|
||||
@@ -295,39 +186,49 @@ export const CallbacksTableStringCell = React.memo(({rowData, cellData}) => {
|
||||
<div>{cellData}</div>
|
||||
)
|
||||
}, areEqual)
|
||||
export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData}) => {
|
||||
const adjustOutput = (newTime) => {
|
||||
if(newTime.includes("54 years")){
|
||||
return "Streaming Now"
|
||||
}else if(newTime === "a few seconds"){
|
||||
moment.relativeTimeThreshold('s', 60);
|
||||
moment.relativeTimeThreshold('ss', 0);
|
||||
return moment(rowData.last_checkin + "Z", "YYYY-MM-DDTHH:mm:ss.SSSSSSZ").fromNow(true)
|
||||
}
|
||||
return newTime;
|
||||
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>
|
||||
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Moment filter={adjustOutput} interval={500} parse={"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"}
|
||||
<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"}
|
||||
fromNow ago
|
||||
serverSkew={me?.user?.server_skew}
|
||||
fromNow
|
||||
ago
|
||||
>
|
||||
{rowData.last_checkin + "Z"}
|
||||
</Moment>
|
||||
</MythicTimestamp>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
)
|
||||
});
|
||||
}, areEqual);
|
||||
export const CallbacksTablePayloadTypeCell = React.memo( ({rowData}) => {
|
||||
const payloadTypeName = React.useRef(rowData.payload.payloadtype.name)
|
||||
return (
|
||||
<MythicStyledTooltip title={payloadTypeName.current}>
|
||||
<img
|
||||
style={{width: "35px", height: "35px"}}
|
||||
src={"/static/" + payloadTypeName.current + ".svg"}
|
||||
/>
|
||||
</MythicStyledTooltip>
|
||||
rowData?.payload?.payloadtype?.name
|
||||
)
|
||||
}, areEqual)
|
||||
export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
@@ -351,7 +252,8 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
const updateIPsInfo = React.useCallback( ({callback_display_id, ips}) => {
|
||||
updateIPs({variables: {callback_display_id: callback_display_id, ips}})
|
||||
}, []);
|
||||
const onClick = () => {
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
setOpenPickIP(true);
|
||||
}
|
||||
const editIPSubmit = (selected_ip) => {
|
||||
@@ -371,16 +273,14 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
}, [cellData]);
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
<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 &&
|
||||
<MythicStyledTooltip title={"Adjust Displayed"}>
|
||||
<UnfoldMoreIcon onClick={onClick} style={{paddingTop: "5px", cursor: "pointer"}} />
|
||||
</MythicStyledTooltip>
|
||||
<MythicActionButton appearance="raised" compact icon={<UnfoldMoreIcon />} iconOnly onClick={onClick} tooltip="Adjust displayed IP" />
|
||||
}
|
||||
{displayIP}
|
||||
<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);}}
|
||||
@@ -394,12 +294,10 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
|
||||
)
|
||||
}, areEqual)
|
||||
export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
const theme = useTheme();
|
||||
export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
|
||||
const [localRowData, setLocalRowData] = React.useState(rowData);
|
||||
const initialCallbackGraphEdges = useContext(CallbackGraphEdgesContext);
|
||||
const [activeEgress, setActiveEgress] = React.useState(theme.palette.success.main);
|
||||
const [hasEgressRoute, setHasEgressRoute] = React.useState(true);
|
||||
const onOpenTab = useContext(OnOpenTabContext);
|
||||
const [openC2Dialog, setOpenC2Dialog] = React.useState(false);
|
||||
const [callbackgraphedges, setCallbackgraphedges] = React.useState([]);
|
||||
const [callbackgraphedgesAll, setCallbackgraphedgesAll] = React.useState([]);
|
||||
@@ -407,19 +305,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
event.stopPropagation();
|
||||
setOpenC2Dialog(true);
|
||||
}
|
||||
useEffect( () => {
|
||||
const routes = callbackgraphedgesAll.filter( (edge) => {
|
||||
if(!edge.c2profile.is_p2p && edge.source.id === localRowData.id && edge.destination.id === localRowData.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
|
||||
@@ -440,10 +325,13 @@ export const CallbacksTableC2Cell = React.memo(({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}
|
||||
@@ -453,12 +341,12 @@ export const CallbacksTableC2Cell = React.memo(({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;
|
||||
@@ -468,27 +356,40 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
setCallbackgraphedges(myActiveEdges);
|
||||
setCallbackgraphedgesAll(myEdges);
|
||||
|
||||
}, [initialCallbackGraphEdges]);
|
||||
}, [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);
|
||||
}else{
|
||||
setActiveEgress(theme.palette.success.main);
|
||||
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}
|
||||
@@ -497,9 +398,11 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
innerDialog={
|
||||
<C2PathDialog
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
onClose={()=>{setOpenC2Dialog(false);}}
|
||||
|
||||
callback={localRowData}
|
||||
callbackgraphedges={callbackgraphedgesAll}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -512,32 +415,36 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
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);}}
|
||||
@@ -552,9 +459,8 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
|
||||
|
||||
|
||||
)
|
||||
});
|
||||
export const CallbacksTableSleepCell = React.memo( ({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});
|
||||
@@ -563,16 +469,28 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
|
||||
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} />
|
||||
}
|
||||
@@ -582,4 +500,17 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
|
||||
|
||||
|
||||
)
|
||||
}, areEqual)
|
||||
}, 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,25 +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() => {
|
||||
@@ -29,94 +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 'interactSplit':
|
||||
return (
|
||||
<CallbacksTabsTaskingSplitLabel
|
||||
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) => {
|
||||
@@ -133,9 +213,11 @@ 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}
|
||||
/>
|
||||
);
|
||||
@@ -151,12 +233,51 @@ 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}
|
||||
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}
|
||||
parentMountedRef={mountedRef}
|
||||
/>
|
||||
);
|
||||
case 'fileBrowser':
|
||||
return (
|
||||
<CallbacksTabsFileBrowserPanel
|
||||
@@ -169,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':
|
||||
@@ -187,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}
|
||||
@@ -198,5 +322,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, clickedTabId, o
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</CallbackBrowserTreeProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1001
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,51 +1,248 @@
|
||||
import React from 'react';
|
||||
import FileBrowserVirtualTree from '../../MythicComponents/MythicFileBrowserVirtualTree';
|
||||
import {FileBrowserVirtualTree} from '../../MythicComponents/MythicFileBrowserVirtualTree';
|
||||
import {MythicDialog} from "../../MythicComponents/MythicDialog";
|
||||
import {ViewCallbackMythicTreeGroupsDialog} from "./ViewCallbackMythicTreeGroupsDialog";
|
||||
import ListIcon from '@mui/icons-material/List';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import {getAllParentNodes} from "./CallbacksTabsFileBrowser";
|
||||
import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../../MythicComponents/MythicNestedMenus";
|
||||
import {copyStringToClipboard} from "../../utilities/Clipboard";
|
||||
import FileCopyOutlinedIcon from '@mui/icons-material/FileCopyOutlined';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
|
||||
|
||||
export const CallbacksTabsFileBrowserTree = ({ treeRootData, treeAdjMatrix, fetchFolderData, setTableData, taskListing, tableOpenedPathId, showDeletedFiles}) => {
|
||||
export const getOpenIDFromNode = (node) => {
|
||||
return `${node.group};${node.host};${node.full_path_text}`;
|
||||
}
|
||||
export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatrix, fetchFolderData, setTableData, taskListing, tableOpenedPathId, showDeletedFiles, tabInfo, selectedFolderData, baseUIFeature}) => {
|
||||
const [openNodes, setOpenNodes] = React.useState({});
|
||||
const groupName = React.useRef("");
|
||||
const [openViewGroupsDialog, setOpenViewGroupDialog] = React.useState(false);
|
||||
const lastOpenedNodeRef = React.useRef({group: "", host: "", full_path_text: ""});
|
||||
const toggleNodeExpanded = (nodeId, nodeData) => {
|
||||
//console.log("toggleNodeExpanded", nodeId, nodeData);
|
||||
let lastOpenedNodeNewState = true;
|
||||
let lastOpenedNodeID = getOpenIDFromNode(lastOpenedNodeRef.current);
|
||||
if(!lastOpenedNodeRef.current?.has_children){
|
||||
lastOpenedNodeNewState = false;
|
||||
}
|
||||
lastOpenedNodeRef.current = {...nodeData};
|
||||
setTableData(nodeData);
|
||||
fetchFolderData(nodeData);
|
||||
setOpenNodes({
|
||||
...openNodes,
|
||||
[nodeId]: true
|
||||
[getOpenIDFromNode(nodeData)]: true,
|
||||
//[lastOpenedNodeID]: lastOpenedNodeNewState
|
||||
});
|
||||
};
|
||||
const toggleNodeCollapsed = (nodeId, nodeData) => {
|
||||
const toggleNodeCollapsed = (nodeId, nodeData) => {
|
||||
setOpenNodes({
|
||||
...openNodes,
|
||||
[nodeId]: false
|
||||
[getOpenIDFromNode(nodeData)]: false
|
||||
});
|
||||
};
|
||||
const onSelectNode = (nodeId, nodeData) => {
|
||||
setTableData(nodeData);
|
||||
};
|
||||
const onSelectNode = React.useCallback( (nodeId, nodeData) => {
|
||||
if(nodeData.root){
|
||||
setTableData(nodeData);
|
||||
}else if(nodeData.is_group){
|
||||
groupName.current = nodeData.group;
|
||||
setOpenViewGroupDialog(true);
|
||||
}else {
|
||||
//console.log(nodeData);
|
||||
//setTableData(nodeData);
|
||||
toggleNodeExpanded(nodeId, nodeData);
|
||||
}
|
||||
|
||||
}, [openNodes, setTableData]);
|
||||
React.useEffect( () => {
|
||||
let group = tableOpenedPathId.group;
|
||||
let host = tableOpenedPathId.host;
|
||||
let allPaths = [...getAllParentNodes(tableOpenedPathId),];
|
||||
const additionalOpenNodes = allPaths.reduce( (prev, cur) => {
|
||||
return {...prev, [getOpenIDFromNode({group: group, host: host, full_path_text: cur})]: true}
|
||||
}, {});
|
||||
setOpenNodes({
|
||||
...openNodes,
|
||||
[tableOpenedPathId]: true
|
||||
[getOpenIDFromNode(tableOpenedPathId)]: true,
|
||||
...additionalOpenNodes
|
||||
});
|
||||
}, [tableOpenedPathId]);
|
||||
const contextMenuOptions = [
|
||||
React.useEffect( () => {
|
||||
let group = selectedFolderData.group;
|
||||
let host = selectedFolderData.host;
|
||||
let allPaths = [...getAllParentNodes(selectedFolderData)];
|
||||
const additionalOpenNodes = allPaths.reduce( (prev, cur) => {
|
||||
return {...prev, [getOpenIDFromNode({group: group, host: host, full_path_text: cur})]: true}
|
||||
}, {});
|
||||
setOpenNodes({
|
||||
...openNodes,
|
||||
[getOpenIDFromNode(selectedFolderData)]: true,
|
||||
...additionalOpenNodes
|
||||
});
|
||||
}, [selectedFolderData]);
|
||||
const contextMenuOptions= (callback_id, callback_display_id, node) => [
|
||||
{
|
||||
name: "Copy to Clipboard", icon: null, click: () => {}, type: "menu",
|
||||
menuItems: [
|
||||
{
|
||||
name: 'Name', type: "item",
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
if(copyStringToClipboard(node.name_text)){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Full Path', type: "item",
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
if(copyStringToClipboard(node.full_path_text)){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Metadata', type: "item",
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
if(copyStringToClipboard(JSON.stringify(node?.metadata, null, 2))){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Task Listing',
|
||||
click: ({event, node}) => {
|
||||
taskListing(node);
|
||||
name: 'List', type: "item", icon: <ListIcon color="warning" style={{ paddingRight: '5px'}} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
taskListing(node, callback_id, callback_display_id);
|
||||
}
|
||||
},
|
||||
];
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const contextMenuData = React.useRef({options: []});
|
||||
const onContextMenu = React.useCallback( ({event, item, itemTreeData}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
contextMenuData.current = {item, itemTreeData,
|
||||
x: event.clientX,
|
||||
y:event.clientY,
|
||||
dropdownAnchorRef: event.currentTarget};
|
||||
if(contextMenuData.current.item?.root){
|
||||
|
||||
}else if(contextMenuData.current.item?.is_group){
|
||||
|
||||
}else {
|
||||
let options = [{
|
||||
name: contextMenuData.current?.item?.name_text, icon: null, disabled: true, type: "item",
|
||||
click: () => {}
|
||||
}];
|
||||
options.push(...contextMenuOptions(tabInfo["callbackID"], tabInfo["displayID"], contextMenuData.current?.item));
|
||||
if(contextMenuData.current?.item?.callback?.["display_id"] !== tabInfo["displayID"]){
|
||||
options.push({
|
||||
name: "Original Callback: " + contextMenuData.current?.item?.callback?.["display_id"],
|
||||
icon: null, click: () => {}, type: "menu",
|
||||
menuItems: [
|
||||
...contextMenuOptions(contextMenuData.current?.item?.callback?.id, contextMenuData.current?.item?.callback?.display_id, contextMenuData.current?.item)
|
||||
]
|
||||
})
|
||||
}
|
||||
contextMenuData.current.options = options;
|
||||
setOpenContextMenu(true);
|
||||
}
|
||||
}, []);
|
||||
const handleMenuItemClick = (event, click) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
click({event});
|
||||
//contextMenuOptions[index].click({event,
|
||||
// node: {...contextMenuData.current?.item, group: contextMenuData.current?.itemTreeData?.group, host: contextMenuData.current?.itemTreeData?.host},
|
||||
// callback_id, callback_display_id
|
||||
//});
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
const handleClose = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
//console.log(selectedFolderData)
|
||||
return(
|
||||
<FileBrowserVirtualTree
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
treeRootData={treeRootData}
|
||||
treeAdjMatrix={treeAdjMatrix}
|
||||
openNodes={openNodes}
|
||||
onSelectNode={onSelectNode}
|
||||
onExpandNode={toggleNodeExpanded}
|
||||
onCollapseNode={toggleNodeCollapsed}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
<>
|
||||
<FileBrowserVirtualTree
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
treeRootData={treeRootData}
|
||||
treeAdjMatrix={treeAdjMatrix}
|
||||
openNodes={openNodes}
|
||||
selectedFolderData={selectedFolderData}
|
||||
onSelectNode={onSelectNode}
|
||||
onExpandNode={toggleNodeExpanded}
|
||||
onCollapseNode={toggleNodeCollapsed}
|
||||
onContextMenu={onContextMenu}
|
||||
tabInfo={tabInfo}
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
{openViewGroupsDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
maxWidth={"xl"}
|
||||
open={openViewGroupsDialog}
|
||||
onClose={() => {setOpenViewGroupDialog(false);}}
|
||||
innerDialog={
|
||||
<ViewCallbackMythicTreeGroupsDialog group_name={groupName.current}
|
||||
onClose={() => {setOpenViewGroupDialog(false);}} />
|
||||
}
|
||||
/>
|
||||
}
|
||||
{openContextMenu &&
|
||||
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
|
||||
<Dropdown
|
||||
isOpen={contextMenuData.current.dropdownAnchorRef}
|
||||
onOpen={setOpenContextMenu}
|
||||
externallyOpen={openContextMenu}
|
||||
absoluteX={contextMenuData.current.x}
|
||||
absoluteY={contextMenuData.current.y}
|
||||
anchorReference={"anchorPosition"}
|
||||
style={{
|
||||
//top: contextMenuData.current.y,
|
||||
//left: contextMenuData.current.x
|
||||
}}
|
||||
menu={[
|
||||
...contextMenuData.current?.options?.map((option, index) => (
|
||||
option.type === 'item' ? (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
disabled={option.disabled}
|
||||
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}
|
||||
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
|
||||
>
|
||||
{menuOption.icon}{menuOption.name}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
}
|
||||
/>
|
||||
) : null))
|
||||
]}/>
|
||||
</ClickAwayListener>
|
||||
}
|
||||
</>
|
||||
|
||||
)
|
||||
};
|
||||
};
|
||||
export const CallbacksTabsFileBrowserTree = React.memo(CallbacksTabsFileBrowserTreePreMemo);
|
||||
@@ -1,22 +1,13 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import React 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 List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import CardHeader from '@mui/material/CardHeader';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import makeStyles from '@mui/styles/makeStyles';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import {useMutation} from '@apollo/client';
|
||||
import {hideCallbacksMutation} from './CallbackMutations';
|
||||
import { CardContent } from '@mui/material';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import {CallbacksTableLastCheckinCell} from "./CallbacksTableRow";
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
|
||||
|
||||
|
||||
const callbacksAndFeaturesQuery = gql`
|
||||
@@ -25,46 +16,28 @@ query callbacksAndFeatures{
|
||||
id
|
||||
display_id
|
||||
host
|
||||
ip
|
||||
user
|
||||
process_name
|
||||
pid
|
||||
description
|
||||
last_checkin
|
||||
dead
|
||||
payload {
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
agent_type
|
||||
}
|
||||
}
|
||||
mythictree_groups_string
|
||||
}
|
||||
}`;
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
margin: 'auto',
|
||||
},
|
||||
paper: {
|
||||
width: 200,
|
||||
height: 500,
|
||||
},
|
||||
button: {
|
||||
margin: theme.spacing(0.5, 0),
|
||||
},
|
||||
divider: {
|
||||
backgroundColor: "rgb(100, 170, 204)",
|
||||
border: "2px solid rgba(100, 170, 204)"
|
||||
}
|
||||
}));
|
||||
|
||||
function not(a, b) {
|
||||
return a.filter((value) => b.indexOf(value) === -1);
|
||||
}
|
||||
|
||||
function intersection(a, b) {
|
||||
return a.filter((value) => b.indexOf(value) !== -1);
|
||||
}
|
||||
|
||||
export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
const classes = useStyles();
|
||||
const [checked, setChecked] = React.useState([]);
|
||||
const [left, setLeft] = React.useState([]);
|
||||
const [right, setRight] = React.useState([]);
|
||||
const leftChecked = intersection(checked, left);
|
||||
const rightChecked = intersection(checked, right);
|
||||
|
||||
const selectedData = React.useRef([]);
|
||||
const [initialData, setInitialData] = React.useState([]);
|
||||
const [hideCallback] = useMutation(hideCallbacksMutation, {
|
||||
onCompleted: data => {
|
||||
snackActions.success("Successfully hid callbacks!")
|
||||
@@ -76,7 +49,7 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
onClose();
|
||||
}
|
||||
});
|
||||
useQuery(callbacksAndFeaturesQuery,{
|
||||
const {loading} = useQuery(callbacksAndFeaturesQuery,{
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
const callbackData = data.callback.map( c => {
|
||||
@@ -84,158 +57,89 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
const display = `${c.display_id} - ${c.user}@${c.host} (${c.pid}) - ${c.description}`;
|
||||
return {...c, display};
|
||||
});
|
||||
setLeft(callbackData);
|
||||
setInitialData(callbackData);
|
||||
}
|
||||
});
|
||||
const handleToggle = (value) => () => {
|
||||
const 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([]);
|
||||
};
|
||||
const submitTasking = () => {
|
||||
if(right.length === 0){
|
||||
if(selectedData.current.length === 0){
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
let callbackIDs = right.map(c => c.display_id);
|
||||
let callbackIDs = selectedData.current.map(c => c.display_id);
|
||||
snackActions.info("Hiding callbacks...");
|
||||
hideCallback({variables: {callback_display_ids: callbackIDs}});
|
||||
}
|
||||
|
||||
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}}>
|
||||
{items.map((value) => {
|
||||
const labelId = `transfer-list-item-${value.id}-label`;
|
||||
return (
|
||||
<ListItem style={{padding:0}} key={value.id} role="listitem" button onClick={handleToggle(value)}>
|
||||
<ListItemIcon>
|
||||
<Checkbox
|
||||
checked={checked.indexOf(value) !== -1}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
inputProps={{ 'aria-labelledby': labelId }}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<div style={{display: "inline-flex", flexDirection: "column"}} >
|
||||
<ListItemText primary={value.display} />
|
||||
<div style={{display: "inline-flex"}}>
|
||||
{"Last Checkin: "}
|
||||
<CallbacksTableLastCheckinCell rowData={value} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
<ListItem />
|
||||
</List>
|
||||
</CardContent>
|
||||
</React.Fragment>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Hide Multiple Callbacks at Once</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", display: "flex", flexDirection: "column", position: "relative", maxHeight: "100%"}}>
|
||||
<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("Visible Callbacks", left)}
|
||||
</div>
|
||||
<div style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color={"primary"}
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color={"primary"}
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
disabled={leftChecked.length === 0}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color={"primary"}
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
disabled={rightChecked.length === 0}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
color={"primary"}
|
||||
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("Callbacks To Hide", right)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CallbacksTabsSelectTable initialData={initialData}
|
||||
loading={loading}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'last_checkin', sort: 'asc' }}
|
||||
tableId="hide-multiple-callbacks-table"
|
||||
tableLabel="Hide multiple callbacks"
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={submitTasking} variant="contained" color="warning">
|
||||
</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={submitTasking} tone="warning" variant="contained">
|
||||
Hide
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
|
||||
|
||||
const selectedData = React.useRef([]);
|
||||
const [initialData, setInitialData] = React.useState([]);
|
||||
const {loading} = useQuery(callbacksAndFeaturesQuery,{
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
const callbackData = data.callback.map( c => {
|
||||
// for each callback, get a unique set of supported features
|
||||
const display = `${c.display_id} - ${c.user}@${c.host} (${c.pid}) - ${c.description}`;
|
||||
return {...c, display};
|
||||
});
|
||||
setInitialData(callbackData);
|
||||
}
|
||||
});
|
||||
const submitTasking = () => {
|
||||
if(selectedData.current.length === 0){
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onSubmit(selectedData.current);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Select Multiple Callbacks</DialogTitle>
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CallbacksTabsSelectTable initialData={initialData}
|
||||
loading={loading}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'display_id', sort: 'desc' }}
|
||||
tableId="select-multiple-callbacks-table"
|
||||
tableLabel="Select multiple callbacks"
|
||||
/>
|
||||
<DialogActions>
|
||||
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</MythicActionButton>
|
||||
<MythicActionButton colorMode="always" onClick={submitTasking} tone="warning" variant="contained">
|
||||
Use Selection
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 1137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user