mirror of
https://github.com/its-a-feature/Mythic.git
synced 2026-11-09 02:30:01 -10:00
Compare commits
666
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 | ||
|
|
3c934d9758 |
No files matched your search
@@ -2,9 +2,11 @@ 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"
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
# types: [published]
|
||||
push:
|
||||
branches:
|
||||
- 'Mythic3.3'
|
||||
- 'Mythic-v4.0.0'
|
||||
- 'master'
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
@@ -44,7 +44,7 @@ env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
# Branch for pushing release changes
|
||||
RELEASE_BRANCH: master
|
||||
RELEASE_BRANCH: "Mythic-v4.0.0"
|
||||
|
||||
jobs:
|
||||
# Builds the base container image and pushes it to the container registry
|
||||
@@ -55,27 +55,27 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
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
|
||||
@@ -99,28 +99,28 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: postgres-docker
|
||||
file: postgres-docker/.docker/Dockerfile
|
||||
file: postgres-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_POSTGRES_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_POSTGRES_IMAGE_NAME }}:latest
|
||||
@@ -141,28 +141,28 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: rabbitmq-docker
|
||||
file: rabbitmq-docker/.docker/Dockerfile
|
||||
file: rabbitmq-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_RABBITMQ_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_RABBITMQ_IMAGE_NAME }}:latest
|
||||
@@ -183,28 +183,28 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: documentation-docker
|
||||
file: documentation-docker/.docker/Dockerfile
|
||||
file: documentation-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_DOCUMENTATION_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_DOCUMENTATION_IMAGE_NAME }}:latest
|
||||
@@ -225,30 +225,30 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: jupyter-docker
|
||||
file: jupyter-docker/.docker/Dockerfile
|
||||
file: jupyter-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_JUPYTER_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_JUPYTER_IMAGE_NAME }}:latest
|
||||
@@ -269,30 +269,30 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: hasura-docker
|
||||
file: hasura-docker/.docker/Dockerfile
|
||||
file: hasura-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_GRAPHQL_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_GRAPHQL_IMAGE_NAME }}:latest
|
||||
@@ -313,30 +313,30 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: nginx-docker
|
||||
file: nginx-docker/.docker/Dockerfile
|
||||
file: nginx-docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_NGINX_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_NGINX_IMAGE_NAME }}:latest
|
||||
@@ -357,31 +357,29 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
with:
|
||||
lfs: true
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: mythic-react-docker
|
||||
context: .
|
||||
file: mythic-react-docker/.docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
@@ -413,20 +411,20 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
- name: Log in to the container registry
|
||||
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
|
||||
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@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: 'arm64,arm'
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
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}
|
||||
@@ -437,10 +435,10 @@ jobs:
|
||||
sed -i "s|^const MythicDockerLatest =.*$|const MythicDockerLatest = \"${VERSION}\"|" constants.go
|
||||
|
||||
- name: Build and push the cli container image
|
||||
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
|
||||
with:
|
||||
context: Mythic_CLI
|
||||
file: Mythic_CLI/.docker/Dockerfile
|
||||
file: Mythic_CLI/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_CLI_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_CLI_IMAGE_NAME }}:latest
|
||||
@@ -472,7 +470,7 @@ jobs:
|
||||
steps:
|
||||
# Pull in the repository code
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
|
||||
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
|
||||
|
||||
# update names to lowercase
|
||||
- name: Lowercase the server container image name
|
||||
@@ -552,7 +550,7 @@ jobs:
|
||||
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-docker/Dockerfile', 'Mythic_CLI/Dockerfile', 'Mythic_CLI/Makefile', 'Mythic_CLI/src/cmd/config/constants.go', 'postgres-docker/Dockerfile', 'rabbitmq-docker/Dockerfile', 'documentation-docker/Dockerfile', 'jupyter-docker/Dockerfile', 'hasura-docker/Dockerfile', 'nginx-docker/Dockerfile', 'mythic-react-docker/Dockerfile']"
|
||||
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
|
||||
|
||||
@@ -167,3 +167,11 @@ _ReSharper*/
|
||||
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
|
||||
+774
-1
@@ -4,6 +4,779 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [4.0.9] - 2026-11-04
|
||||
|
||||
### Changed
|
||||
|
||||
- applied a v4 version of PR #607 to address cursors in payloadtype/c2 sync and remote postgres connections
|
||||
|
||||
## [4.0.7] - 2026-07-31
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Reworked authentication for protected HTTP and GraphQL requests:
|
||||
- Protected requests no longer authenticate from the `mythic` cookie; all clients must send access tokens and API tokens with the `Authorization: Bearer <token>` header.
|
||||
- Replaced JWT-based API tokens with opaque `mtk_` tokens that are hashed at rest and shown only when created.
|
||||
- Be sure to save your API Tokens when you generate them because you cannot recover them in the UI or database!
|
||||
- Existing API tokens must be regenerated after upgrading.
|
||||
- Added resource-level read/write scopes to API tokens and enforced them across Hasura actions, webhooks, and Mythic RPC calls.
|
||||
- Write scopes include the corresponding read scope.
|
||||
- Tokens can use explicit scopes, resource wildcards such as `task.*`, or the global `*` scope.
|
||||
- A token can only grant scopes already available to its caller.
|
||||
- Using a JWT (access_token) through the web UI automatically have a global `*` scope.
|
||||
- Removed the `/api/v1.4` prefix from Mythic action and webhook routes and updated the Hasura action base URL accordingly.
|
||||
- Normalized the remaining snake_case Hasura action names to camelCase, including:
|
||||
- `callbackgraphedge_add` and `callbackgraphedge_remove` to `callbackgraphedgeAdd` and `callbackgraphedgeRemove`
|
||||
- `config_check` to `configCheck`
|
||||
- `download_bulk` to `downloadBulk`
|
||||
- `dynamic_query_function` to `dynamicQueryFunction`
|
||||
- `rebuild_payload` to `rebuildPayload`
|
||||
- `redirect_rules` to `redirectRules`
|
||||
- `reissue_task` and `reissue_task_handler` to `reissueTask` and `reissueTaskHandler`
|
||||
- `typedarray_parse_function` to `typedarrayParseFunction`
|
||||
- `meHook` was replaced by the expanded `whoami` query
|
||||
- Standardized task and callback action arguments on operation-scoped display IDs.
|
||||
- Integrations should migrate ambiguous fields such as `task_id`, `callback_id`, `tasks`, and `callbacks` to the corresponding `task_display_id`, `callback_display_id`, `task_display_ids`, and `callback_display_ids` fields.
|
||||
- Added authenticated RabbitMQ context propagation and scope enforcement for container RPC requests and responses.
|
||||
- All containers must use compatible container libraries that preserve the Mythic auth-context header!
|
||||
- Changed eventing `mythic.apitoken` inputs from a scalar value to an object with an explicit `type` and optional `scopes` list.
|
||||
- Replaced wrapper payload type allowlists with `wrapper_payload_requirements`.
|
||||
- Wrapper containers should declare requirement rules, and payload builders can return `build_metadata` used to select compatible payloads.
|
||||
|
||||
### Added
|
||||
|
||||
- Added operation chat with standard and AI-backed channels.
|
||||
- Supports channel creation, locking, archiving, muting, unread state, history, full-text search, and message editing/deletion.
|
||||
- Supports streamed AI responses, cancellation, retry, model and channel metadata, and per-channel bot API tokens.
|
||||
- Added structured tool-use output, operator approval and input requests, selectable choices, delegation metadata, and sub-agent summaries/final output.
|
||||
- Added Markdown rendering and clickable metadata references in chat.
|
||||
- Added eventing steps that can pause for operator approval or runtime input and resume after a response is submitted.
|
||||
- Added scoped API-token discovery and introspection through `apiTokenScopeDefinitions`, `scopeCheck`, and the expanded `whoami` response.
|
||||
- Added operator-owned command and generic aliases with payload type/chat container scoping, nested expansion, and import/export support.
|
||||
- Added task parameter reference expansion:
|
||||
- `@cred` references can resolve credential fields or complete structured credential parameters.
|
||||
- `@link` references can resolve payload links and track payload-on-host relationships.
|
||||
- Resolution details are stored with the task and sent to payload type containers.
|
||||
- Added richer build and C2 parameter definitions:
|
||||
- Parameter groups, display names, display names for choices, hide conditions, and dynamic queries.
|
||||
- Complex dynamic choices and `other_parameters` context.
|
||||
- `JSONString` parameters with inline JSON Schema rendering.
|
||||
- Saved, named build-parameter instances alongside saved C2 parameter instances.
|
||||
- Added conditional wrapper compatibility based on payload build metadata, and the wrapper's selected build parameters.
|
||||
- Added payload build metadata and paginated discovery of payloads compatible with a wrapper.
|
||||
- Added offset-based file transfers alongside chunk-number transfers, including resume support and byte-level progress tracking.
|
||||
- Added an interactive file-editor task response protocol with version history, conflict detection, staged previews, refresh, force-overwrite, and close actions.
|
||||
- Added tracked C2 file-hosting state with update, retry, stop, and remove operations.
|
||||
- Added a payload-type agent RPC path for asynchronous payload-wide requests to callbacks.
|
||||
- Added richer credentials with subtype, structured identity, metadata, custom display values, update support, and dedicated JWT and Kerberos renderers.
|
||||
- Added `custom_rpc_timeout` for RPC calls that are expected to run longer without retrying duplicate work.
|
||||
|
||||
### Changed
|
||||
|
||||
- Modernized the React UI with standardized dialogs, action buttons, state/status chips, page headers, colors, pagination, filtering, and virtualized resizable tables.
|
||||
- Reworked callback tasking and output:
|
||||
- Added xterm-based interactive terminal output and improved keyboard/scroll handling.
|
||||
- Improved task response pagination, streaming, Markdown/terminal display, single-task views, filters, and completion tracking.
|
||||
- Updated callback tabs, rows, C2 path diagrams, and the callback file/process browsers.
|
||||
- Updated the payload builder, wrapper builder, C2 profile pages, eventing editor, search pages, payload tables, browser scripts, reporting, installed services, settings, and dashboard to use the new UI components and workflows.
|
||||
- Added authenticated media, screenshot, file preview, upload, and download handling now that browser cookie authentication is no longer used.
|
||||
- Improved the file browser's incremental insert/delete processing, directory deletion behavior, container file browsing, and large update performance.
|
||||
- Improved proxy traffic, delegate routing, callback graph path caching, RabbitMQ dispatch, and Mythic RPC performance.
|
||||
- Improved task/response sequencing and callback/task display-ID allocation under concurrent activity.
|
||||
- Updated API-token creation for bot accounts so an operation member can create a token for a bot in the same operation, subject to view mode and delegated scopes.
|
||||
- Updated C2, payload, and build-parameter synchronization to use stored compatibility metadata instead of legacy resync RPCs.
|
||||
- Updated the documentation UI with light/dark themes, collapsible navigation, preserved sidebar state, and in-page navigation.
|
||||
- Updated the React release build to use chunked assets, gzip compression, and the UI baked into the published image by default.
|
||||
- Local `build_ui` output is mounted only when local UI build context is enabled.
|
||||
- Updated the bundled Jupyter Mythic package to `0.3.0-rc8`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented duplicate container `OnStart` calls and duplicate file-upload processing.
|
||||
- Fixed out-of-order and repeated file chunks inflating transfer progress or prematurely completing downloads.
|
||||
- Fixed task completion callback races and duplicate callback-function execution.
|
||||
- Fixed response decoding so one malformed response no longer discards the rest of a response batch.
|
||||
- Fixed operation alert counts becoming stale during concurrent event-log updates, deletes, or warning resolution.
|
||||
- Fixed file-browser update ordering, upload tracking, remote-path handling, and large insert/delete operations.
|
||||
- Fixed artifact search and multiple search-table rendering/filtering issues.
|
||||
- Fixed payload and C2 synchronization races, container-down warning resolution, SQL-query handling, and hosted-file state tracking.
|
||||
- Fixed multiple callback table, single-task view, browser-script, eventing, payload, and responsive-layout issues.
|
||||
|
||||
### Removed
|
||||
|
||||
- Removed the obsolete operation-specific browser-script mapping table.
|
||||
- Removed the legacy browser-script `screenshot`, `download`, and `search` result renderers; use the supported media, table, and plaintext response formats instead.
|
||||
- Removed the legacy payload type, C2 profile, and translation-container resync RPC send paths.
|
||||
|
||||
## [3.4.36] - 2026-06-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Addressed 3 issues from GitHub about spectators and overly scoped hasura filters
|
||||
|
||||
## [3.4.35] - 2026-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Merged PR #546 to address missing return in GenerateAPITokenWebhook that would allow non-admin users to generate API Tokens for Bot accounts
|
||||
- Added server name to emitted log messages
|
||||
- Merged PR #545 to address missing operation_id in task's log messages for logging containers
|
||||
- Updated C2 profile code in the UI to properly handle `ui_position` from C2 Profile parameters in payload build
|
||||
|
||||
## [3.4.34] - 2026-04-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated way that SendAllOperationsMessage functions to reduce duplicate messages to event log
|
||||
- Reduced the number of lookups per error message to increment count by caching error lookup data
|
||||
- Updated event log messages to ensure proper UTF8 encoding (and removal of bad characters)
|
||||
- Using channels to ensure no duplicate messages are created due to go concurrency
|
||||
|
||||
## [3.4.33] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where payload/command syncing would error out and leave dangling database connections
|
||||
- Updated the postgres docker image to use customized database info for health checks
|
||||
|
||||
## [3.4.32] - 2026-04-06
|
||||
|
||||
### Changed
|
||||
|
||||
- Merged PR to add GET route for /auth_acs/:containerName/:IDPName that was POST only
|
||||
|
||||
## [3.4.31] - 2026-03-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed code in `upload` to overwrite uploaded filename with destination filename in filemeta tracking
|
||||
- Updated `download` code to only write files to disk on new chunks for speed improvements
|
||||
- Updated the `rm` tracking to not blanket wildcard paths, but first check if it's a directory and make sure there's a trailing slash before wildcarding
|
||||
|
||||
## [3.4.30] - 2026-03-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Added a `received_chunk_ids` map to the database for `filemeta` to track not just chunks, but which chunks are received
|
||||
- this helps prevent duplicate chunks getting counted multiple times and causing faulty downloads
|
||||
- Added some default values for `DisplayPath` that were causing null constraint errors on the database
|
||||
- Fixed a remote path null value with uploading eventing files
|
||||
|
||||
## [3.4.29] - 2026-03-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Adjusted the MythicRPCFileSearch function to not return tracked copies of existing files
|
||||
|
||||
## [3.4.28] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where file transfers would fail if no remote path supplied
|
||||
|
||||
## [3.4.27] - 2026-03-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where mythic would try to write to a null message after processing a bad message
|
||||
|
||||
## [3.4.26] - 2026-03-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug in "Callback Imports" that wouldn't recognize exising build parameter types
|
||||
|
||||
## [3.4.25] - 2026-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated rpfwd to do burst proxy reads similar to socks
|
||||
|
||||
## [3.4.24] - 2026-02-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated PushC2 to funnel traffic into one channel instead of two
|
||||
- Updated socks proxy reads to do a basic burst for read sizes to reduce number of concurrent waiting messages
|
||||
- this increases throughput for socks traffic with a bit more bursty memory usage
|
||||
- Updated key reflection in agent messages to omit cwd and impersonation_context fields
|
||||
- Updated empty BFS paths to be cached as well so non-existent paths aren't recomputed a bunch
|
||||
|
||||
## [3.4.23] - 2026-02-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the payload type syncing to resync for all payload types, not just new wrappers
|
||||
- this fixes a bug where payload types syncing for the first time after their wrapper synced wouldn't match them up
|
||||
|
||||
## [3.4.22] - 2026-02-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where building payloads while c2 profiles were syncing would double issue tasks to c2 profile and hang
|
||||
- Fixed an issue here cached p2p paths weren't getting flushed properly
|
||||
|
||||
## [3.4.21] - 2026-01-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where downloaded files weren't having their sizes reflected after completion in the file browser
|
||||
|
||||
## [3.4.20] - 2026-01-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated IterateAndAct to treat a crypto value of "" the same as "none"
|
||||
|
||||
## [3.4.19] - 2025-12-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing of skipped steps to indicate skip instead of most recent step that was updated
|
||||
|
||||
## [3.4.18] - 2025-12-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated how processing happens for the `update_deleted` with file browser data
|
||||
- data is now processed as part of two channels to ensure ordering and supporting multi-chunk file browser data
|
||||
|
||||
## [3.4.17] - 2025-12-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where uploaded files were getting treated as folders in the file browsers
|
||||
- Fixed an issue where uploaded files would get the wrong timestamp in the file browser
|
||||
- Updated go modules
|
||||
|
||||
## [3.4.16] - 2025-12-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where supplying a cron job in eventing as something other than string would crash due to type conversion
|
||||
|
||||
## [3.4.15] - 2025-12-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with translation containers sometimes not getting associated with their payload types on first sync
|
||||
|
||||
## [3.4.14] - 2025-12-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Added database triggers for `filemeta`, `credential`, and `keylog` to update timestamp when other fields change
|
||||
|
||||
## [3.4.13] - 2025-12-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated MythicRPCTaskCreate to require OperatorID, TaskID, or EventStepInstanceID
|
||||
- This is an update to make sure the new task is associated with the right user and not the callback creator
|
||||
- Updated the default tasking to Split Tasking instead of the collapsable accordian tasking
|
||||
|
||||
## [3.4.12] - 2025-12-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Added more context to the Proxy start event log message
|
||||
- Added a new event log message when a Proxy stops
|
||||
- Updated the CustomBrowserExportFunctionMessage to also take in a callback group
|
||||
- Updated the SendMythicRPCFileCreate call to also allow creation by OperationID
|
||||
|
||||
## [3.4.11] - 2025-12-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `CustomBrowser` containers to allow customized file-like browser creation
|
||||
- This includes an "export" functionality that's a custom function you can implement per browser type
|
||||
- This includes a new field in `responses` for `custom_browser`
|
||||
- Added `SendMythicRPCCallbackTokenSearch`
|
||||
- Added `SendMythicRPCCustomBrowserSearch`
|
||||
- Added `verifier_regex` option to CommandParameters
|
||||
- Allows you to provide regex that must match the supplied value
|
||||
- Changed `mythictree` to move `has_children` out of metadata in support of CustomBrowsers
|
||||
- Changed `mythictree` to have new `display_path` field separate from `full_path` in support of CustomBrowsers
|
||||
- Added field to `payload` to track the version of the backing payload_type when the payload was created
|
||||
- This makes it easier to track payloads as you're doing payload type development/updates
|
||||
- Added `supported_c2` and `supported_wrapping` to Payload Types to track supported C2/Wrappers
|
||||
- This is now used to "resync" data rather than asking the container to resync back to Mythic
|
||||
- Changed `token_id` to int64 (from uint64) to match similar change in Postgres database to BigInt from just Integer
|
||||
- Updated operation event log auto resolver to use new `warining=true` vs `level='warning'` check
|
||||
- Added a 5s sleep between container's OnStart messages to help with multiple operations bombarding a container in rapid succession
|
||||
- Updated standard RPC timeouts to 10s instead of 5s
|
||||
- Updated auto re-hosting of files to have a 10s delay between files
|
||||
- Updated ContainerVersion out of bounds message to be a bit clearer
|
||||
- Updated `SendMythicRPCCallbackUpdate` to allow specifying the `dead` attribute
|
||||
- Updated `SendMythicRPCFileSearch` to allow specifying a PayloadUUID in addition to AgentFileID in `AgentFileID` field
|
||||
- Updated `SendMythicRPCResponseCreate` to go through the same process as an agent's `user_output`
|
||||
- This allows responses in this way to be intercepted and emitted to logging
|
||||
- Added some mutexes when syncing PayloadTypes to prevent possible race conditions
|
||||
- Updated file downloads to allow specifying a negative `total_chunks` until you're done and know how many chunks total there are
|
||||
- Added new `database` tab to file previews to run SQL queries on database right in the browser
|
||||
- Added the following new options for BuildParameter's HideConditionOperator
|
||||
- LessThan, GreaterThan, LessThanOrEqual, GreaterThanOrEqual, StartsWith, EndsWith, Contains, NotContains
|
||||
- [UI Updates](/MythicReactUI/CHANGELOG.MD#0383---2025-12-10)
|
||||
- [CLI Updates](/Mythic_CLI/Changelog.md#0321---2025-12-10)
|
||||
|
||||
## [3.4.10] - 2025-10-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with c2 profile logos not getting reset if removed
|
||||
|
||||
## [3.4.9] - 2025-10-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with eventing where payload data wasn't getting passed to new callback triggers in some situations
|
||||
|
||||
## [3.4.8] - 2025-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Added support for a new MythicRPCCallbackEdgeRemove RPC call to allow developers to selectively remove edges
|
||||
- Added support for a new MythicRPCHandleAgentJson RPC call to allow developers to submit standard Mythic JSON messages here for processing after using a translation container instead of hitting a bunch of various other RPC calls to create their needed objects
|
||||
- Updating MythicRPCCallbackEdgeSearch to also return self edges - i.e. an egress' edge to Mythic
|
||||
- This is a change where before it would only return P2P edges in the graph
|
||||
- Updated MythicRPCTaskCreate to take in either AgentCallbackID or CallbackID
|
||||
- Added a few more spots in agent message processing to emit error information to the operation event log
|
||||
|
||||
## [3.4.7] - 2025-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Added "ui_position" fields to BuildParameters and C2 Profile Parameters for display ordering when building payloads
|
||||
- Added "dynamic_query_function" option to BuildParameters to allow dynamic execution and choice creation during build process
|
||||
- Added "copy_of_file_id" to FileMeta to better tracking when Mythic creates copies of files
|
||||
- When uploading fileA to callback1, Mythic tracks this association so it can record things like upload location
|
||||
- If fileA (by same UUID) is uploaded again (callback1 with a different task or callback2), Mythic creates a copy of the file meta and tracks it
|
||||
- This allows Mythic to track the new upload location separate from the first upload
|
||||
- Both of these files are backed by the same file on disk, so deleting one deletes all copies
|
||||
- This is now better represented in the UI on the file search page
|
||||
|
||||
## [3.4.6] - 2025-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Adjusted how file browser permissions are treated by default
|
||||
- they're left as-is and not force-converted to an array
|
||||
|
||||
## [3.4.5] - 2025-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where creating payloads via scripting without providing values wasn't picking up dictionary based payload type c2 parameter deviations
|
||||
|
||||
## [3.4.4] - 2025-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated apitoken create mutation to also return creation_time
|
||||
|
||||
## [3.4.3] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with the SendMythicRPCCallbackGraphEdgeSearch returning null data for the callbacks
|
||||
|
||||
## [3.4.2] - 2025-10-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with default dictionary values for scripting parameters
|
||||
- Updated the send event log scripting call to account for mythic_sync and level=warning values
|
||||
- Updated delegate tasking to not include the `token` field if it's 0
|
||||
|
||||
## [3.4.1] - 2025-10-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with download tags not getting added to files
|
||||
- Updated last checkin time tracking to bulk update once/s instead of once/s per callback
|
||||
- the goal is to minimize unnecessary database writes that might spike the cpu for repeated actions
|
||||
- Updated to experimental golang json v2 for performance increases
|
||||
|
||||
## [3.4.0] - 2025-10-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Added "selected_os" array option to some eventing triggers to additionally limit not just on payload type but OS as well
|
||||
- this is the "os" you select when you build a payload
|
||||
- Modified the UI to allow creating new eventing workflows based on existing ones with a single button
|
||||
- Added group_name, supported_os, and hide_conditions to build parameters for more dynamic build experiences
|
||||
- Added supports_multiple_c2_in_build, supports_multiple_c2_instances, c2_parameter_deviations to payload types
|
||||
- This allows payload types to slightly tweak c2 profile configurations they support and indicating how many c2s are allowed per build
|
||||
- c2 parameter deviations allow indicating parameters are not supported at all, that they have a different set of choices, and that the default value can be something else
|
||||
- Added semver option for payload types to more accurately convey their current version outside of their description
|
||||
- Updated the payload build workflow in the UI
|
||||
- Added an option for payload types to hook into the `help` function and provide their own as part of their payload type definition
|
||||
- Added a notification to callback tabs if there's new task changes while you're not on the tab
|
||||
- Updated delegate tasks for the same target callback to be grouped together into a single message instead of a separate message per task
|
||||
- Fixed a bug with MythicRPCCallbackSearch where all the timestamps would be 0
|
||||
- Added support for C2 Profiles to include their own agent icons for the UI
|
||||
- Added support for C2 Profiles to report their own SemVer as well
|
||||
- Added support for TranslationContainers to report back their own SemVer
|
||||
- Added support for all consuming containers (eventing, auth, logging, webhook) to report back their own SemVer
|
||||
- Fixed bug in reporting where tasks weren't in order
|
||||
- Updated the CredentialCreateWebhook to support empty credentials
|
||||
- Added callback context menu option for triggering eventing workflows
|
||||
- Also added option to bulk issue eventing workflows from callbacks
|
||||
- Edited workflows so that all can be triggered manually
|
||||
- Edited the server so manul execution can still provide arbitrary extra data, not just keyword execution
|
||||
- Updated operationeventlog to have a dedicated `warning` field that's separate from the level
|
||||
|
||||
## [3.3.1-rc94] - 2025-09-05
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a processing bug for staging_translation messages that were getting their messages overwritten
|
||||
|
||||
## [3.3.1-rc93] - 2025-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the global setting initializer to properly update from global state
|
||||
|
||||
## [3.3.1-rc92] - 2025-08-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where consuming containers were never getting their OnStart called
|
||||
|
||||
## [3.3.1-rc91] - 2025-08-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixing bug with eventing causing issues with ints
|
||||
|
||||
## [3.3.1-rc90] - 2025-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated translation container errors to include missing translation container name
|
||||
- Added new global_setting table
|
||||
- You can now add a default user preferences config that gets applied to all new users
|
||||
- Added new invite_link table
|
||||
- invite links are now persistent across reboots
|
||||
- removed ability to delete invite links
|
||||
- Added new `mythic_parsed_params` field to Task entries
|
||||
- This shows how mythic parses the parameters from the UI
|
||||
- This is viewable from a task, click to "view all parameters"
|
||||
- Fixed some hasura operations to scope down to current operation only
|
||||
- Added new `semver` (semantic version) field to payload types
|
||||
- This will be updated in a later version to fetch data from updated pypi/go packages
|
||||
- Added new TriggerData for the following triggers to allow scoping down to specific commands for specific agents
|
||||
- TaskCreate, TaskStart, TaskFinish, TaskIntercept
|
||||
- This takes a map[string][]string format (a dictionary of string arrays)
|
||||
- The dictionary key is the name of the payload type, the array of strings are the specific commands to trigger on
|
||||
- An empty array of strings means this applies for all commands for that payload type
|
||||
- Added a check in handling messages from agents to check for the all 0 UUID
|
||||
- This is now reported back in the UI as a special error to indicate that the connectivity was a success
|
||||
- This works in tandem with updated C2 Profiles' sample messages to use all 0 UUID values
|
||||
- A new keyword is added for responses, `events`, which is an array where you specify the `keyword` (string) and `data` (dictionary) for an eventing workflow you want to trigger
|
||||
- File Hosting now auto checks and adds `/` if it was somehow missed
|
||||
- Additional checks were added to make removing files easier in case the file was deleted
|
||||
- Updated Eventing response intercept logic to cache existing workflows to reduce the overhead of fetching data for every response
|
||||
- Updated File Hosting tag process to only add tags after C2 Profile returned success for hosting the file
|
||||
- [UI Updates](/MythicReactUI/CHANGELOG.MD#0368---2025-08-21)
|
||||
|
||||
## [3.3.1-rc89] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the processing of strings/ints for required fields in create tasking eventing
|
||||
|
||||
## [3.3.1-rc88] - 2025-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug in cron eventing that would continue even after error
|
||||
|
||||
## [3.3.1-rc87] - 2025-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated syncing for payload types to only happen with new c2, not all c2
|
||||
- Updated syncing for translation containers to only force sync payload types when translation container is new
|
||||
|
||||
## [3.3.1-rc86] - 2025-07-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing of new file tracking to auto mark the file as completed for uploads
|
||||
|
||||
## [3.3.1-rc85] - 2025-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing of command_augment containers to auto add commands to callbacks on any sync
|
||||
|
||||
## [3.3.1-rc84] - 2025-07-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing for completion functions to not blow away subtask error codes
|
||||
|
||||
## [3.3.1-rc83] - 2025-07-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the create credential webhook to more uniquely check for duplicates by type and comment
|
||||
|
||||
## [3.3.1-rc82] - 2025-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the cookie value to have secure `false` but httpOnly `true` so that `http` only versions of the UI can still access images via cookies
|
||||
|
||||
## [3.3.1-rc81] - 2025-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the invite link code to have fallback mechanisms for determining the full link invite address
|
||||
|
||||
## [3.3.1-rc80] - 2025-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated back-end to not try to double-add commands when building payloads and checking dependencies
|
||||
|
||||
## [3.3.1-rc79] - 2025-06-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing for PushC2 to track the number of connected clients and only mark disconnected when they all exit
|
||||
|
||||
## [3.3.1-rc78] - 2025-06-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Added `payload_id` and `callback_id` as options for tags
|
||||
- Added `supports_multiple_c2_in_build` and `supports_multiple_c2_instances_in_build` database fields for Payload Types
|
||||
- This is prepping for a future update to allow Payload Types to identify if they support multiple c2 to be selected at build
|
||||
|
||||
## [3.3.1-rc77] - 2025-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the database to track if a task wants to reprocess with a new command or the original command
|
||||
- This was originally updated for `Command Augmentation` containers to allow proper tracking for when
|
||||
- the `CommandName` was changed for a new `CommandPayloadType`, but this also resulted in every time the
|
||||
- `CommandName` was changed in tasking that the processing would continue under the new command, which isn't always ideal
|
||||
- Now, only if you set `response.ReprocessAtNewCommandPayloadType` will the processing change to the new `CommandName`
|
||||
- This means that CommandA that sets its `response.CommandName = commandB` and does a `process_response` field will have the message processed at `CommandA` still
|
||||
- Setting the above AND `response.ReprocessAtNewCommandPayloadType` will have the `process_response` processed at `commandB` (specify the same payload type name is ok too)
|
||||
|
||||
## [3.3.1-rc76] - 2025-06-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing for agent messages to support new MythicRPCHandleAgentJson function
|
||||
- This allows developers to hook into more "standard" agent message processing from MythicRPC locations
|
||||
- Updated bulk downloading to force host if none exists ("UNKNOWN") and removes trailing "." if no file extension exists
|
||||
- Fixed an issue where sometimes a callback on checking in after getting hidden wouldn't re-appear in the active table
|
||||
|
||||
## [3.3.1-rc75] - 2025-06-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing of completion handlers for tasks to better account script only and child tasks
|
||||
- Added `cwd` and `impersonation_context` to callbacks as part of their `checkin` and update messages
|
||||
- Added new `callback` field to agent response dictionary
|
||||
- this field allows you to update any fields that you'd normally set in your `checkin` message
|
||||
- Added new field to tasks, `CommandParameterType`, to better track where processing should go if a task sets a new PayloadType as part of its processing
|
||||
- An example would be `forge` updating the command name and container where processing should continue - this new payload type is also tracked
|
||||
- Updated health check code for database to properly report success
|
||||
|
||||
## [3.3.1-rc74] - 2025-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing of commands for building payloads to include `dependencies` in a command's attributes
|
||||
- if `command1` is a dependency for `command2`, and the user selected command2 but not command1 when building,
|
||||
- then mythic will automatically add `command1` to the list of commands that need to be built into the agent
|
||||
- an example would be `dependencies = ["command1"]`
|
||||
|
||||
## [3.3.1-rc73] - 2025-06-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the UI to allow creation of new Artifacts (that aren't tied to specific tasks)
|
||||
- Updated payload updating information to a new GraphQL Action for:
|
||||
- description, callback_alert, delete, and callback_allowed
|
||||
- New data tracked for callback_allowed to optionally prevent new callbacks from payloads
|
||||
- Updated Artifact processing
|
||||
- Reporting an artifact will result in getting back an `id` for the artifact
|
||||
- That `id` can then be sent in future artifact messages to update its `needs_cleanup` and `resolved` status
|
||||
- Payloads now have a `callback_allowed` attribute
|
||||
- this is `true` by default, but setting it to false (toggling in the UI on the payloads page or callback metadata) will prevent future new callbacks from that payload
|
||||
- this feature was "implicitly" there when you delete a payload, but that is removed in favor of this new method
|
||||
- A new UI element will show on the payload if it's not marked for allowing callbacks
|
||||
|
||||
## [3.3.1-rc72] - 2025-6-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the MythicRPCCallbackUpdate call to allow no specifying C2 Profile when asking to update last checkin time
|
||||
|
||||
## [3.3.1-rc71]- 2025-05-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated `help` output to have `help` and `clear` alphabetically included
|
||||
- Adding regex checks for `help`, so you can do `help list.*` to find all commands that start with `list` that are loaded
|
||||
- File Uploads (Mythic -> Agent) now track and display chunks sent on the File Search page for Uploads
|
||||
- State of the vertical navbar (open/close) is saved in user preferences so it'll stay through refresh
|
||||
- Updated the "InviteLinks" UI button on the settings page to only be enabled if you're an admin and the feature is enabled
|
||||
- Updated UI and GraphQL to allow bulk file delete
|
||||
- Added new User Preference to hide browser-based tasking (file browser / process browser) from your normal tasking views
|
||||
- Updated file media viewer to dynamically update as more chunks for a file are received
|
||||
- Added a new `task:job_kill` supported UI feature that takes in a raw agent_task_id (uuid)
|
||||
- This shows up as a `skull` icon for tasking that has been picked up by the agent but hasn't completed
|
||||
- Updated UI callback drag columns to match vertical navbar color
|
||||
|
||||
## [3.3.1-rc70] - 2025-05-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the onStart operator selection to require the bot's current operation id to be the target operation
|
||||
- Fixed a logic bug in remove callback_tokens
|
||||
|
||||
## [3.3.1-rc69] - 2025-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the logic for automatic eventing ingestion to not check for files that don't exist
|
||||
|
||||
## [3.3.1-rc68] - 2025-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the server to have the version baked-in instead of reading a file on disk
|
||||
|
||||
## [3.3.1-rc67] - 2025-05-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated some processing of process data to update task_ids so that newer data gets updated in the UI
|
||||
|
||||
## [3.3.1-rc66] - 2025-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with processing a built payload's updated command list
|
||||
-
|
||||
## [3.3.1-rc65] - 2025-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with credential searching via MythicRPC
|
||||
- Updated healthcheck to include details about all installed services
|
||||
- Fixed issue in apitoken create for tasks
|
||||
- Updated post response token action to be "add" if none specified
|
||||
- Updated callbackToken action to be "add" if none specified
|
||||
- Updated command action to be "add" if none specified
|
||||
- Updated README sponsors
|
||||
|
||||
## [3.3.1-rc64] - 2025-04-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the payload build process to not double trigger c2 profile start
|
||||
- Updated the logging for payload builds to be when the payload is done instead of when it starts
|
||||
|
||||
## [3.3.1-rc63] - 2025-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated reporting backend to trim spaces on comma separated exclusion filters
|
||||
|
||||
## [3.3.1-rc62] - 2025-04-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing of timestamped for linked callbacks to not update nested relationships with egress connections
|
||||
- Updated mythictree update/delete to update the associated task_id as well to trigger updates in the UI
|
||||
- Added an `ORDER BY id DESC` for most MythicRPC Search calls
|
||||
|
||||
## [3.3.1-rc61] - 2025-04-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the socks bind to explicitly bind to IPv4 addresses
|
||||
|
||||
## [3.3.1-rc60] - 2025-04-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed typo in sql query
|
||||
|
||||
## [3.3.1-rc59] - 2025-04-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with the tag get or create rpc call's sqlx IN processing
|
||||
|
||||
## [3.3.1-rc58] - 2025-04-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with tag creation
|
||||
|
||||
## [3.3.1-rc57] - 2025-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated invite link checking to not count invite usage if the account wasn't created
|
||||
|
||||
## [3.3.1-rc56] - 2025-03-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated MythicRPCTaskSearch to allow specifying parent task to get all subtasks
|
||||
- Fixed bug in TagCreateWebhook
|
||||
- Added MythicRPCTagCreate
|
||||
- Added MythicRPCTagSearch
|
||||
- Added MythicRPCTagTypeGetOrCreate
|
||||
- Added new trigger - tag_create
|
||||
- Updated DynamicQueryFunction to take in OtherParameters - the names/values of other paramters in the task
|
||||
- If a command has `task_response:interactive` in its supported ui features, then Mythic will open up a new interactive component in the UI
|
||||
- If that command ALSO has `task:process_interactive_tasks` in their supported ui, then these follow-on tasks will also go to that parent task's create tasking function first
|
||||
- If you set this, you can check for this type of task:
|
||||
- `taskData.Task.IsInteractiveTask` - boolean True or False
|
||||
- `taskData.Task.InteractiveTaskType` - the type of input (normal input, ctrl characters, etc)
|
||||
- There's an enum, `InteractiveTask` that can provide insight into what the int TaskTypes mean
|
||||
- `taskData.Args.GetCommandLine()` - get what they actually typed
|
||||
- `taskData.Args.SetManualArgs("new args\n")` - potentially update the input to something else (don't forget `\n` if you need it!)
|
||||
- Otherwise, follow-on tasks in this new interactive component go directly to the agent
|
||||
|
||||
## [3.3.1-rc55] - 2025-03-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing of files in file browser for better handling of deleted files
|
||||
|
||||
## [3.3.1-rc54] - 2025-03-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the response intercept function to not trigger on empty user outputs
|
||||
|
||||
## [3.3.1-rc53] - 2025-03-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixing struct tag for conditional eventing responses
|
||||
|
||||
## [3.3.1-rc52] - 2025-03-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing of `clear` and `help` in event logs to properly send the data through
|
||||
- Updated RPC timeout for RabbitMQ to 5s with 3 retries
|
||||
- Updated RabbitMQ retry loop to include redeclaring channels to help avoid rabbitmq locks
|
||||
|
||||
## [3.3.1-rc51] - 2025-03-11
|
||||
|
||||
### Changed
|
||||
@@ -1602,4 +2375,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [2.3.12] - 2022-08-25
|
||||
### Changed
|
||||
- Updated P2P communications spec to return `mythic_uuid` and `new_uuid` (same value). Eventually `mythic_uuid` will be removed entirely to help reduce the number of mandatory `mythic` strings in agents.
|
||||
- Updated P2P communications spec to return `mythic_uuid` and `new_uuid` (same value). Eventually `mythic_uuid` will be removed entirely to help reduce the number of mandatory `mythic` strings in agents.
|
||||
@@ -130,4 +130,49 @@ 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.
|
||||
|
||||
MIT license
|
||||
===========
|
||||
|
||||
Copyright (c) 2017 sql.js authors (see AUTHORS)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
# Some portions of the Makefile taken from:
|
||||
Copyright 2017 Ryusei Yamaguchi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -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/
|
||||
@@ -4,6 +4,679 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.3.113] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug in the process browser that would set a filter to undefined instead of ""
|
||||
|
||||
## [0.3.112] - 2026-05-17
|
||||
|
||||
- Updated the single task view to show interactive tasking surrounding results
|
||||
|
||||
## [0.3.111] - 2026-05-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with useLazyQuery spamming requests
|
||||
|
||||
## [0.3.110] - 2026-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Added more generic MythicPageBody and MythicServerHeader components to wrap most pages/headers
|
||||
- Added a new border color you can select for borders of a few components
|
||||
- Updated a few places with hard-coded px values to more scalable rem values
|
||||
|
||||
## [0.3.109] - 2026-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the UI to sort c2 profile parameters during payload build by ui_position if specified
|
||||
|
||||
## [0.3.107] - 2026-03-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed typo in file browser for remove_folder
|
||||
|
||||
## [0.3.106] - 2026-03-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with clicking file uploads in modals
|
||||
|
||||
## [0.3.105] - 2026-03-20
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated some of the file upload pieces in the UI to support drag-and-drop
|
||||
- Added an `autoScroll` button to interactive tasking so you can force auto scroll or not
|
||||
- Fixed some of the drag and drop modal components
|
||||
|
||||
## [0.3.104] - 2026-03-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Added ability to drag and move some modals and copy data from behind them
|
||||
|
||||
## [0.3.103] - 2026-03-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed issue with file uploads for payload/wrapper builds when starting from existing payload
|
||||
|
||||
## [0.3.102] - 2026-03-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Slight tweak to `tabs` browser script capability to cap the max size of the tabs
|
||||
- Fixed a scrolling issue on the single task view page
|
||||
|
||||
## [0.3.101] - 2026-02-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with database view in split view tasking that would cause overflow
|
||||
|
||||
## [0.3.100] - 2026-02-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the eventing page to only stream in updates that match the current filter
|
||||
- Updated payload parameter array type to start empty
|
||||
|
||||
## [0.3.99] - 2026-02-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated process_browser and file_browser tables to allow column sorting, hiding, and saved to user preferences
|
||||
- Updated process_browser to have a new column for integrity_level
|
||||
|
||||
## [0.3.98] - 2026-02-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the default shortcuts to remove Reporting and add CreatePayload
|
||||
- Updated Task Search on Parameters to include agent parameters, not just original and display
|
||||
- Updated callback table bulk actions to include a note about callback last checkin times not updates in those windows
|
||||
|
||||
## [0.3.97] - 2026-02-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated browser script tables to rever sort after toggling ASC/DESC to match other virtualized tables in Mythic
|
||||
|
||||
## [0.3.96] - 2026-01-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the file browser to support `file_browser:remove` and `file_browser:remove_folder`
|
||||
- this allows separate commands for deleting files vs deleting folders (or the same command if you want)
|
||||
|
||||
## [0.3.95] - 2026-01-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated tasking data for browser scripts to also provide callback's pid
|
||||
- Updated browser scripting to provide new type: `tabs`
|
||||
- returning `{"tabs": [ {"title": "tab title here", "content": {"table": {table dict} } } ] }`
|
||||
- this creates one tab with title `tab title here` that renders a table
|
||||
|
||||
## [0.3.94] - 2026-01-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a UI caching bug in split tasking view that would hide tasks on clicking previously issued tasks
|
||||
|
||||
## [0.3.93] - 2026-01-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated UI to allow output background color and output text color to be set separate from the "theme"
|
||||
- Updated UI to reflect file size updates from file browser while streaming
|
||||
|
||||
## [0.3.92] - 2026-01-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug in dynamic query parameters in the modal that would sometimes error on choices
|
||||
|
||||
## [0.3.91] - 2026-01-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where c2 parameter deviations would try to iterate over null
|
||||
|
||||
## [0.3.90] - 2026-01-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Added a new modal to the UI when looking at a payload's commands to get insight into how to task them via scripting
|
||||
|
||||
## [0.3.89] - 2025-12-31
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an alignment issue in the UI for split callbacks that would cause a horizontal scroll bar
|
||||
- Fixed a height issue on the browser script page that wasn't going the full page height
|
||||
- Added an option to string dialog boxes to wrap the displayed content or not
|
||||
|
||||
## [0.3.88] - 2025-12-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where payload creation wouldn't properly show "False" settings from previously used configs
|
||||
- Fixed a few instances of double scrollbar in modals
|
||||
|
||||
## [0.3.87] - 2025-12-18
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the file media viewer to also fetch updates more dynamically
|
||||
|
||||
## [0.3.85] - 2025-12-15
|
||||
|
||||
### Changed
|
||||
|
||||
- This updates the default tasking view to split tasking
|
||||
- This adds the task to the top of the task output in the split tasking view for easier tracking
|
||||
|
||||
## [0.3.84] - 2025-12-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an ordering issues in backgrounds that got rid of the side bar colors in some browsers
|
||||
|
||||
## [0.3.83] - 2025-12-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated context menu for virtualized tables
|
||||
- Updated column adjustment icon for virtualized tables to be || instead of three vertical dots
|
||||
- Updated virtualized table header context menu display type
|
||||
- Updated minimum column width for virtualized columns to be 100 instead of 20
|
||||
- Updated virtualized tree views to check item.has_children instead of item.metadata.has_children
|
||||
- this is in support of custom browsers
|
||||
- Updated context menu for callbacks to display custom browser support
|
||||
- Updated callback tabs to have customFileBasedBrowser type for new custom browser support
|
||||
- Added custom browser table and tree views
|
||||
- Updated file browser to come more inline with custom browsers
|
||||
- Updated context menu for virtualized tree to have some copy options
|
||||
- Fixed an issue with `\\` in tasking on the command line when not surrounded by quotes
|
||||
- Updated token select in browser views to not be so squished
|
||||
- Updated split tasking view to align content
|
||||
- Added new `database` view for file previews with sql.js and wasm
|
||||
- Updated `icon` usage in browser scripts to support just supplying the font awesome name instead of a curated list
|
||||
- https://fontawesome.com/v6/search?s=solid&ic=free-collection all of these should be available
|
||||
- Updated task display to allow showing of operator names that bypassed OPSEC checks
|
||||
- Updated task buttons for split view to be along the right-hand side instead of a floating speeddial button at the bottom
|
||||
- Added verifier_regex to command parameters
|
||||
- Fixed a UI bug where display names of command parameter descriptions/names would overflow
|
||||
- Added support for broader range of hide conditions in payload type build parameters
|
||||
- Fixed a UI bug where payload selection options for wrappers weren't all sorted by time
|
||||
- Updated Eventing Instance Table view to be more in line with other virtualized tables
|
||||
- Updated view to show "selected" eventing workflow and task in split view tasking to be easier to see
|
||||
- Updated detailed payload views to show parameter names and descriptions, just like the building views
|
||||
- Updated hovering over payloads to show payload type semver when payload was created vs currently installed version
|
||||
- Added CustomBrowser entry to the "Installed Services" page
|
||||
- Added CustomBrowser search page
|
||||
- Fixed Task/Callback links on Token search page
|
||||
- Updated UI Configuration settings to support `showOPSECBypassUsername` and new `navBarBottomColor`
|
||||
- the latter allows you to have gradients for the side navbar instead of just a solid color
|
||||
|
||||
## [0.3.82] - 2025-11-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with creating payloads without including any C2 profiles
|
||||
|
||||
## [0.3.81] - 2025-10-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Updating event log message default display state
|
||||
|
||||
## [0.3.80] - 2025-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug where hidden values would still get sent to build process for payloads
|
||||
|
||||
## [0.3.79] - 2025-10-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Added new UiPosition and DynamicQueryFunction capabilities to the payload build process
|
||||
- Added new filemeta "copy_of_file" attribute to be shown on file search pages
|
||||
|
||||
## [0.3.78] - 2025-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with previous payload selection in payload creation
|
||||
|
||||
## [0.3.77] - 205-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with cli parsing with quotes for parameters
|
||||
- Fixing an issue with file browser parameter arrays treated like dictionaries
|
||||
|
||||
## [0.3.76] - 2025-10-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the process for filter by command attribute in task modals
|
||||
|
||||
## [0.3.75] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with payload type's c2 variations for dictionaries wasn't getting picked up by default
|
||||
|
||||
## [0.3.74] - 2025-10-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with displayed boolean values always showing true for payload configs
|
||||
- Fixed an issue with files from pre-configured payloads when generating new payloads
|
||||
|
||||
## [0.3.73] - 2025-10-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with linking modal displaying too many alerts and not tracking new payloads initially
|
||||
|
||||
## [0.3.72] - 2025-10-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a "cancel" button not closing a modal for eventing wizard
|
||||
- Fixed an issue with `File` types showing links for files not uploaded yet when building payloads
|
||||
|
||||
## [0.3.71] - 2025-10-07
|
||||
|
||||
### Changed
|
||||
|
||||
- updated the position of the c2 profile icons on the graph views
|
||||
|
||||
## [0.3.70] - 2025-10-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the Payload and Wrapper creation pages
|
||||
- Added support for PayloadType new fields:
|
||||
- c2_parameter_deviations: ability to set choices, default_value for c2-specific fields
|
||||
- supports_multiple_c2_in_build: ability to signify if more than one c2 is allowed or not
|
||||
- supports_multiple_c2_instances_in_build: ability to signify if more than one instance of a specific 2 is allowed or not (i.e. 2 HTTPX profiles)
|
||||
- semver: display a semantic version for Payload Types if set
|
||||
|
||||
## [0.3.69] - 2025-08-21
|
||||
|
||||
- Fixed an issue with browser script tables getting hidden by the container
|
||||
|
||||
## [0.3.68] - 2025-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated browser script tables to have filter options again
|
||||
- Updated browser script tables to have option to copy out entire table as JSON, CSV, or TSV
|
||||
- Updated browser script tables to have option to copy out entire table as Pretty Printed Text
|
||||
- Updated task's "View All Parameters" option to show the new mythic_parsed_parameters field
|
||||
- Updated UI to support new map[string][]array format needed by new eventing trigger data
|
||||
- Updated eventing stepper UI wizard to support new trigger data
|
||||
- Updated file hosting dialog to sort available c2 profiles by name
|
||||
- Updated file search pages to remove entries completely if they're deleted
|
||||
- Updated invite links and global settings to use new database focused data
|
||||
|
||||
## [0.3.67] - 2025-08-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the timer for checking jwt remaining time to only happen if you're logged in
|
||||
- Updated
|
||||
## [0.3.66] - 2025-08-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the processing of the UI creation for eventing to process numbers vs strings better
|
||||
|
||||
## [0.3.65] - 2025-08-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated preferences to always fetch and not get cached
|
||||
|
||||
## [0.3.64] - 2025-07-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the task parameter search to also include display_params, not just original_params
|
||||
|
||||
## [0.3.63] - 2025-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the `pid` field to the callback search table and the ability to search by pid
|
||||
|
||||
## [0.3.62] - 2025-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the webhook/auth buttons to properly check for subscription / online status for tests
|
||||
|
||||
## [0.3.61] - 2025-07-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with image tiling getting reset on changes
|
||||
|
||||
## [0.3.59] - 2025-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated some of the search pages to better account for changing search options
|
||||
- Fixed the callback group search for tasks
|
||||
|
||||
## [0.3.58] - 2025-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated some of the context in tag search for tasks to include more callback information
|
||||
- Added fields for Callback/Payload tags to show up
|
||||
|
||||
## [0.3.57] - 2025-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the option to show more fields right above the tasking bar
|
||||
|
||||
## [0.3.56] - 2025-07-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Added the cursor view back to ace text fields
|
||||
- Updated the menu dropdown button to not fade into colored rows as much
|
||||
|
||||
## [0.3.55] - 2025-07-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated task dialog to allow removal of credentials
|
||||
- Updated task dialog on ambiguous parameters to open modal
|
||||
- Updated task dialog to sort credentials most recent at the top to oldest at the bottom
|
||||
|
||||
## [0.3.54] - 2025-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the cooking parsing to trim fields
|
||||
|
||||
## [0.3.53] - 2025-06-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with updating a user's email in the UI
|
||||
|
||||
## [0.3.52] - 2025-06-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed some alignment issues in the search page
|
||||
- Added an option when searching tasks to filter by operator
|
||||
- Added the ability to click to zoom into rendered media images
|
||||
|
||||
## [0.3.51] - 2025-06-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Added a payload diff capability via the Actions dropdown on any payload
|
||||
|
||||
## [0.3.50] - 2025-06-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the single task view page to not show console view
|
||||
- Merged in PR with a few slight tweaks for console view
|
||||
|
||||
## [0.3.49] - 2025-06-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the UI to leverage the new payload/callback tagging capabilities
|
||||
|
||||
## [0.3.48] - 2025-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with cli parsing where JavaScript would think `1` is a valid JSON string
|
||||
|
||||
## [0.3.46] - 2025-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the task modals to show the name and description at once
|
||||
|
||||
## [0.3.45] - 2025-06-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated tasking field to show more info about cwd and impersonation_context
|
||||
- Added user setting to show/hide new cwd and impersonation_context tabs
|
||||
- Added new tab for extra_info field in callbacks to show
|
||||
- Added ability to color cwd, impersonation context, and extra info separately
|
||||
- Added ability to generate new payload based on slightly edited config from the payload's Action dropdown
|
||||
|
||||
## [0.3.44] - 2025-06-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the console view to have a output divider
|
||||
- Updated the tasking prompt color to be a separate color you can specify in your settings
|
||||
- Updated task/callback links to match the tasking prompt color so they blend better
|
||||
- Updated the highlighting of text fields to be consistent grey instead of based on the secondary color
|
||||
- should help with some whiplash from editing colors
|
||||
- Updated the time formatting for console view
|
||||
- Update the command selection pane when building payloads to not show commands you can't include
|
||||
|
||||
## [0.3.43] - 2025-06-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the background image to not tile
|
||||
|
||||
## [0.3.42] - 2025-06-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the way that the "Console" view works for tasking
|
||||
- Added `cwd` and `impersonation_context` to UI for callbacks
|
||||
|
||||
## [0.3.41] - 2025-06-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Added more safeguards when importing theme configs to make sure there's valid values
|
||||
|
||||
## [0.3.40] - 2025-06-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the default value for blank arrays to be `[]` instead of `[""]`
|
||||
|
||||
## [0.3.39] - 2025-06-06
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed some rounded corners on the dashboards page
|
||||
- Updated the Payload Types / C2 Profiles page to have some context if there's no container installed of each type
|
||||
- Fixed some hasura permissions that were not scoping fetches down to the current operation but all operations you were assigned to
|
||||
|
||||
## [0.3.38] - 2025-06-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Added new button in Artifacts search page to add new artifacts
|
||||
|
||||
## [0.3.37] - 2025-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed bug with viewing files for eventing workflows
|
||||
- Updated comment view on credential types for tasking
|
||||
- Updated resize bars to be a bit thinner so that their color is less intrustive
|
||||
- Updated the default dark-mode color for the side bar to be a bit darker
|
||||
|
||||
## [0.3.36] - 2025-06-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a bug with double-clicking column headers for the active callbacks page that would break while saving preferences
|
||||
|
||||
## [0.3.35] - 2025-05-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the spacing in the payloads table to not have each row extra large when not full
|
||||
|
||||
## [0.3.34] - 2025-05-30
|
||||
|
||||
### Changed
|
||||
|
||||
- State of the vertical navbar (open/close) is saved in user preferences so it'll stay through refresh
|
||||
- Updated the "InviteLinks" UI button on the settings page to only be enabled if you're an admin and the feature is enabled
|
||||
- Updated UI and GraphQL to allow bulk file delete
|
||||
- Added new User Preference to hide browser-based tasking (file browser / process browser) from your normal tasking views
|
||||
- Updated file media viewer to dynamically update as more chunks for a file are received
|
||||
- Added a new `task:job_kill` supported UI feature that takes in a raw agent_task_id (uuid)
|
||||
- This shows up as a `skull` icon for tasking that has been picked up by the agent but hasn't completed
|
||||
- Updated UI callback drag columns to match vertical navbar color
|
||||
|
||||
## [0.3.33] - 2025-05-21
|
||||
|
||||
### Changed
|
||||
|
||||
- There was an issue with some apollo useLazyQuery functions, specifically for searching and they are updated
|
||||
|
||||
## [0.3.32] - 2025-05-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed a UI update issue with process browser task ids
|
||||
- Updated adjacency matrix for process data to check for loops
|
||||
|
||||
## [0.3.31] - 2025-05-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with updated datagrid components for toggling hiding apitokens
|
||||
|
||||
## [0.3.30] - 2025-05-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with updated datagrid components in a few UI elements
|
||||
|
||||
## [0.3.29] - 2025-05-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue with updated datagrid components in a few UI elements
|
||||
|
||||
## [0.3.28] - 2025-05-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the cli parsing to accept partial match tab complete for static choice lists
|
||||
- Updated MaterialUI libraries
|
||||
- Updated tags to only show first letter until you hover over them to help minimize space
|
||||
- Updated Payloads page to be less busy and indicate meaningful data better
|
||||
- Updated PayloadTypes&C2Profiles page to Installed Services page
|
||||
- Added tabs for each kind of installed service with counts
|
||||
- Removed dedicated "Consuming Services" page
|
||||
- Updated default shortcuts for vertical nav bar
|
||||
- Removed horizontal top nav bar and now only use the vertical navbar
|
||||
- Fixed an issue with multi-select on the payload builders page
|
||||
- Updated dashboard home page to have "Operator", "Lead", and "Custom" views
|
||||
- Custom view is saved to your preferences and allows you to choose which dashboard elements on which rows you want
|
||||
- Dashboard elements have additional action buttons and shortcuts
|
||||
|
||||
## [0.3.27] - 2025-04-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated processing for font size so virtualized row height is properly updated
|
||||
|
||||
## [0.3.26] - 2025-04-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the tag search for files to not distinct on tag id but file id
|
||||
|
||||
## [0.3.25] - 2025-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the reporting table to indicate comma separated values are needed
|
||||
|
||||
## [0.3.24] - 2025-04-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the lab field for text fields to default to True
|
||||
|
||||
## [0.3.23] - 2025-04-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the graph view's edge color to be more intuitive - green/red vs blue/orange
|
||||
- Updated the color of selecting agents in the grpah view to be more apparent
|
||||
|
||||
## [0.3.22] - 2025-04-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed credential table to not expand columns
|
||||
|
||||
## [0.3.21] - 2025-04-01
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated vertical nav bar to link to home when clicking mythic icon
|
||||
- Updated spacing on fields for home dashboard
|
||||
- Updated response/task sorting on interactive tasks
|
||||
- Added a few more tooltips for existing icons
|
||||
- Updated a few clickable icons to be icon buttons
|
||||
- Updated payload generation parameter displays
|
||||
- Fixed a few warning messages from MUI
|
||||
|
||||
## [0.3.20] - 2025-03-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated the "table/graph" speeddial button on the active callbacks page to be a side bar instead
|
||||
- Updated the reverse search to include original params, not just command names
|
||||
- Removed confirmation popup for non-agent payload types during payload creation for c2 profiles and an exit command
|
||||
- Updated Eventing workflow wizard to support the new tag_create trigger
|
||||
- Updated CreateTag API call to go through `createTag` action vs `insert_tag_one` to hook into new eventing
|
||||
- Added ability to re-trigger dynamic query functions in the modals
|
||||
- Added the ability to pass along other parameters as well and their values for the dynamic query function
|
||||
- Updated the backdrop for dynamic query calls to just be that element instead of the whole screen
|
||||
- Updated the `tags` page
|
||||
|
||||
## [0.3.19] - 2025-03-19
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed an issue where the "deleted" status wouldn't get updated for file browser UI
|
||||
|
||||
## [0.3.18] - 2025-03-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Fixed the vertical extra icons on short layouts that wouldn't auto expand to show them
|
||||
|
||||
## [0.3.17] - 2025-03-11
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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
+5192
-5073
File diff suppressed because it is too large.
Load diff
+42
-34
@@ -6,66 +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.10.5",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@hello-pangea/dnd": "^17.0.0",
|
||||
"@mui/icons-material": "^5.15.20",
|
||||
"@mui/lab": "^5.0.0-alpha.138",
|
||||
"@mui/material": "^5.15.20",
|
||||
"@mui/x-charts": "^6.19.8",
|
||||
"@mui/x-data-grid": "^6.20.1",
|
||||
"@mui/x-date-pickers": "^6.20.1",
|
||||
"ace-builds": "^1.35.0",
|
||||
"anser": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"elkjs": "^0.8.2",
|
||||
"graphql": "^16.8.2",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"@mui/icons-material": "^7.0.2",
|
||||
"@mui/lab": "^7.0.0-beta.11",
|
||||
"@mui/material": "^7.0.2",
|
||||
"@mui/utils": "^7.0.2",
|
||||
"@mui/x-charts": "^8.1.0",
|
||||
"@mui/x-date-pickers": "^8.1.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.6.0",
|
||||
"ace-builds": "^1.40.1",
|
||||
"anser": "^2.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.4.17",
|
||||
"elkjs": "^0.10.0",
|
||||
"graphql": "^16.10.0",
|
||||
"graphql-ws": "^6.0.4",
|
||||
"html-to-image": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"ip6": "^0.2.10",
|
||||
"json5": "^2.2.3",
|
||||
"jwt-decode": "^3.1.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"randexp": "^0.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-moment": "^1.1.3",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"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-tooltip": "^5.28.0",
|
||||
"react-virtualized": "^9.22.5",
|
||||
"react-virtualized-auto-sizer": "^1.0.24",
|
||||
"react-window": "^1.8.10",
|
||||
"reactflow": "^11.11.3",
|
||||
"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",
|
||||
"semver": "^7.6.2",
|
||||
"styled-components": "^6.1.11",
|
||||
"sql.js": "^1.13.0",
|
||||
"styled-components": "^6.1.17",
|
||||
"uuid": "^9.0.1",
|
||||
"webpack": "^5.94.0",
|
||||
"webpack": "^5.105.0",
|
||||
"word-wrap": "^1.2.5"
|
||||
},
|
||||
"scripts": {
|
||||
"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-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 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"
|
||||
"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": [
|
||||
|
||||
@@ -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>
|
||||
|
||||
+268
-43
@@ -1,9 +1,9 @@
|
||||
import { makeVar } from '@apollo/client';
|
||||
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 = [
|
||||
{
|
||||
@@ -19,96 +19,314 @@ export const taskTimestampDisplayFieldOptions = [
|
||||
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 = {
|
||||
fontSize: 12,
|
||||
fontFamily: "Verdana, Arial, sans-serif",
|
||||
navBarOpen: false,
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
showMedia: true,
|
||||
hideUsernames: false,
|
||||
showIP: false,
|
||||
showHostname: false,
|
||||
showCallbackGroups: false,
|
||||
showOPSECBypassUsername: false,
|
||||
taskingDisplayFields: defaultTaskingDisplayFields,
|
||||
useDisplayParamsForCLIHistory: true,
|
||||
interactType: "interact",
|
||||
interactType: "interactSplit",
|
||||
taskTimestampDisplayField: "timestamp",
|
||||
callbacks_table_columns: ["Interact", "Host", "Domain", "User", "Description", "Last Checkin", "Agent", "IP", "PID"],
|
||||
callbacks_table_filters: {},
|
||||
autoTaskLsOnEmptyDirectories: false,
|
||||
["experiment-responseStreamLimit"]: 50,
|
||||
hideBrowserTasking: false,
|
||||
hideTaskingContext: false,
|
||||
taskingContextFields: ["impersonation_context", "cwd"],
|
||||
["experiment-responseStreamLimit"]: 200,
|
||||
chatSelectedChannelID: 0,
|
||||
virtualizedTablePadding: 8,
|
||||
sideShortcuts: defaultShortcuts,
|
||||
palette: {
|
||||
primary: {
|
||||
dark: "#465b73",
|
||||
light: "#75859b",
|
||||
dark: "#7dd3fc",
|
||||
light: "#2563eb",
|
||||
},
|
||||
error: {
|
||||
dark: '#da3237',
|
||||
light: '#c42c32'
|
||||
dark: '#fb7185',
|
||||
light: '#dc2626'
|
||||
},
|
||||
success: {
|
||||
dark: '#44b636',
|
||||
light: '#0e7004',
|
||||
dark: '#34d399',
|
||||
light: '#15803d',
|
||||
},
|
||||
secondary: {
|
||||
dark: '#bebebe',
|
||||
light: '#a6a5a5'
|
||||
dark: '#94a3b8',
|
||||
light: '#64748b'
|
||||
},
|
||||
info: {
|
||||
dark: '#2184d3',
|
||||
light: '#4990b2'
|
||||
dark: '#22d3ee',
|
||||
light: '#0891b2'
|
||||
},
|
||||
warning: {
|
||||
dark: "#f57c00",
|
||||
light: "#ffb74d",
|
||||
dark: "#fbbf24",
|
||||
light: "#b45309",
|
||||
},
|
||||
background: {
|
||||
dark: '#303030',
|
||||
light: '#f6f6f6'
|
||||
dark: '#0b0f14',
|
||||
light: '#f6f8fb'
|
||||
},
|
||||
paper: {
|
||||
dark: '#424242',
|
||||
light: '#ececec'
|
||||
dark: '#111820',
|
||||
light: '#ffffff'
|
||||
},
|
||||
surfaceRaised: {
|
||||
dark: '#16212b',
|
||||
light: '#ffffff'
|
||||
},
|
||||
surfaceMuted: {
|
||||
dark: '#0f1720',
|
||||
light: '#fafbfc'
|
||||
},
|
||||
tableHeader: {
|
||||
dark: '#484848',
|
||||
light: '#c4c4c4'
|
||||
dark: '#17212c',
|
||||
light: '#e8eef5'
|
||||
},
|
||||
tableHover: {
|
||||
dark: "#3c3c3c",
|
||||
light: "#e8e8e8",
|
||||
dark: "#1e2a36",
|
||||
light: "#eaf1f8",
|
||||
},
|
||||
pageHeader: {
|
||||
dark: '#706c6e',
|
||||
light: '#706c6e'
|
||||
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: "#ffffff",
|
||||
light: "#000000",
|
||||
dark: "#e6edf3",
|
||||
light: "#111827",
|
||||
},
|
||||
textSecondary: {
|
||||
dark: "#a7b2c2",
|
||||
light: "#526174",
|
||||
},
|
||||
textDisabled: {
|
||||
dark: "#6f7c8d",
|
||||
light: "#93a1b2",
|
||||
},
|
||||
selectedCallbackColor: {
|
||||
dark: '#26456e',
|
||||
light: '#c6e5f6',
|
||||
dark: '#123a5a',
|
||||
light: '#dbeafe',
|
||||
},
|
||||
selectedCallbackHierarchyColor: {
|
||||
dark: '#273e5d',
|
||||
light: '#deeff8',
|
||||
dark: '#172d49',
|
||||
light: '#edf6ff',
|
||||
},
|
||||
backgroundImage: {
|
||||
dark: null,
|
||||
light: null
|
||||
},
|
||||
navBarIcons: {
|
||||
dark: '#ffffff',
|
||||
dark: '#dce8f8',
|
||||
light: '#ffffff'
|
||||
},
|
||||
navBarText: {
|
||||
dark: '#ffffff',
|
||||
dark: '#dce8f8',
|
||||
light: '#ffffff'
|
||||
},
|
||||
navBarColor: {
|
||||
dark: "#3c4d67",
|
||||
light: "#3c4d67",
|
||||
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);
|
||||
|
||||
|
||||
@@ -138,7 +356,7 @@ export const successfulRefresh = (data) => {
|
||||
let now = new Date();
|
||||
let serverNow = new Date(data.user.current_utc_time);
|
||||
const difference = (serverNow.getTime() - now.getTime()) ;
|
||||
let me = {...meState().user};
|
||||
let me = mergeRefreshedUser(meState().user, data.user);
|
||||
me.server_skew = difference;
|
||||
me.login_time = now;
|
||||
meState({
|
||||
@@ -152,6 +370,14 @@ export const successfulRefresh = (data) => {
|
||||
localStorage.setItem("user", JSON.stringify(me));
|
||||
}
|
||||
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");
|
||||
@@ -175,4 +401,3 @@ export const FailedRefresh = (restart_websockets) =>{
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1228
-192
File diff suppressed because it is too large.
Load diff
@@ -17,11 +17,12 @@ const subscribe_payloads = gql`
|
||||
level
|
||||
resolved
|
||||
source
|
||||
warning
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EventFeedNotifications(props) {
|
||||
export function EventFeedNotifications() {
|
||||
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
|
||||
//const fromNow = React.useRef( );
|
||||
useSubscription(subscribe_payloads, {
|
||||
@@ -33,7 +34,10 @@ export function EventFeedNotifications(props) {
|
||||
},
|
||||
onData: ({data}) => {
|
||||
if (data.data.operationeventlog_stream.length > 0){
|
||||
if(data.data.operationeventlog_stream[0].level === "api" || data.data.operationeventlog_stream[0].level === "debug" ){
|
||||
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){
|
||||
@@ -41,14 +45,14 @@ export function EventFeedNotifications(props) {
|
||||
}
|
||||
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].level === "warning") {
|
||||
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].level === "warning") {
|
||||
if (data.data.operationeventlog_stream[0].warning) {
|
||||
snackActions.warning(data.data.operationeventlog_stream[0].message, {autoClose: 3000});
|
||||
} else {
|
||||
snackActions.info(
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -1,13 +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}) => {
|
||||
export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
|
||||
const theme = useTheme();
|
||||
if(payload_type === ""){
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<MythicStyledTooltip title={payload_type}>
|
||||
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
|
||||
style={{...style}}/>
|
||||
<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,6 +13,10 @@ 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={
|
||||
@@ -26,14 +29,14 @@ export function MythicConfirmDialog(props) {
|
||||
</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,7 +1,6 @@
|
||||
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 Table from '@mui/material/Table';
|
||||
@@ -18,10 +17,85 @@ 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";
|
||||
|
||||
export function MythicDialog(props) {
|
||||
const descriptionElementRef = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
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>
|
||||
);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -30,35 +104,107 @@ 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"
|
||||
onMouseDown={dialogOnClick}
|
||||
onContextMenu={dialogOnContextMenu}
|
||||
>
|
||||
{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);
|
||||
@@ -80,178 +226,267 @@ export function MythicModifyStringDialog(props) {
|
||||
}, [props.value]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", margin: 0, padding: 0}}>
|
||||
{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%"
|
||||
height="100%"
|
||||
fontSize={14}
|
||||
showPrintMargin={false}
|
||||
wrapEnabled={props.wrap ? props.wrap : false}
|
||||
minLines={props.maxRows ? props.maxRows : 10}
|
||||
maxLines={props.maxRows ? props.maxRows : 10}
|
||||
wrapEnabled={wrap}
|
||||
value={comment}
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
useWorker: false
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
showInvisibles: false,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
Close
|
||||
</Button>
|
||||
{props.onSubmit &&
|
||||
<Button onClick={onCommitSubmit} variant="contained" color="success">
|
||||
{props.onSubmitText ? props.onSubmitText : "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(!Array.isArray(permissionDict) && typeof permissionDict !== 'string'){
|
||||
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" style={{wordBreak: "break-all", maxWidth: "100%"}}>{props.title}</DialogTitle>
|
||||
|
||||
<TableContainer className="mythicElement" style={{paddingLeft: "10px"}}>
|
||||
<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: "15%", wordBreak: "break-all"} : {wordBreak: "break-all"}}>{header}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tableType === "dictionary" ? (
|
||||
comment.map( (element, index) => (
|
||||
<TableRow key={'row' + index} hover>
|
||||
<TableCell style={{wordBreak: "break-all"}}>{element.name}</TableCell>
|
||||
{element.new_table ?
|
||||
(
|
||||
<TableContainer 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: "15%", wordBreak: "break-all"} : {wordBreak: "break-all"}}>{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", whiteSpace: "pre-wrap"}}>{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: "15%", wordBreak: "break-all"} : {wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{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} style={{wordBreak: "break-all"}}>{convertValueToContextValue(key, row[key], props.me)}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) }
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -275,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>
|
||||
@@ -295,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);
|
||||
@@ -386,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
export const reorder = (
|
||||
list,
|
||||
startIndex,
|
||||
@@ -8,4 +10,11 @@ export const reorder = (
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ 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 WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
@@ -40,31 +39,65 @@ const StyledAutoSizer = styled(AutoSizer)((
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.rowContainer}`]: {},
|
||||
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%",
|
||||
},
|
||||
|
||||
[`& .${classes.row}[data-deleted="true"]`]: {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
|
||||
[`& .${classes.rowButtonWrapper}`]: {
|
||||
width: theme.spacing(3),
|
||||
textAlign: "center",
|
||||
"&:hover": {
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline"
|
||||
}
|
||||
alignSelf: "stretch",
|
||||
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.34)}`,
|
||||
flex: "0 0 14px",
|
||||
marginLeft: 7,
|
||||
},
|
||||
|
||||
[`& .${classes.rowButton}`]: {
|
||||
width: theme.spacing(3)
|
||||
alignItems: "center",
|
||||
display: "inline-flex",
|
||||
flex: "0 0 auto",
|
||||
justifyContent: "center",
|
||||
width: theme.spacing(3),
|
||||
},
|
||||
|
||||
[`& .${classes.rowLabel}`]: {
|
||||
marginLeft: theme.spacing(0.5)
|
||||
color: theme.palette.text.primary,
|
||||
fontFamily: "inherit",
|
||||
fontSize: theme.typography.pxToRem(13),
|
||||
lineHeight: 1.35,
|
||||
margin: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
|
||||
[`& .${classes.rowLabel}[data-muted="true"]`]: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
|
||||
[`& .${classes.heading}`]: {
|
||||
@@ -102,9 +135,20 @@ const StyledAutoSizer = styled(AutoSizer)((
|
||||
},
|
||||
|
||||
[`& .${classes.icon}`]: {
|
||||
flex: "0 0 auto",
|
||||
height: 16,
|
||||
marginLeft: 3,
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
height: 20,
|
||||
width: 20,
|
||||
width: 16,
|
||||
},
|
||||
|
||||
[`& .${classes.icon}[data-folder="true"]`]: {
|
||||
color: theme.folderColor,
|
||||
},
|
||||
|
||||
[`& .${classes.icon}[data-empty="true"]`]: {
|
||||
color: theme.emptyFolderColor,
|
||||
},
|
||||
|
||||
[`& .${classes.details}`]: {
|
||||
@@ -120,7 +164,11 @@ const StyledAutoSizer = styled(AutoSizer)((
|
||||
|
||||
[`& .${classes.paper}`]: {
|
||||
width: '100%',
|
||||
marginBottom: theme.spacing(2),
|
||||
height: "100%",
|
||||
marginBottom: 0,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
|
||||
[`& .${classes.table}`]: {
|
||||
@@ -164,7 +212,6 @@ const VirtualTreeRow = React.memo(({
|
||||
const itemTreeData = ListProps.data[ListProps.index];
|
||||
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 theme = useTheme();
|
||||
const handleOnClickButton = (e) => {
|
||||
e.stopPropagation();
|
||||
if (itemTreeData.isOpen) {
|
||||
@@ -192,90 +239,57 @@ const VirtualTreeRow = React.memo(({
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
<div className={`hoverme ${selectedPath()}`}
|
||||
<div className={`${classes.rowContainer} hoverme ${selectedPath()}`.trim()}
|
||||
style={ListProps.style}
|
||||
onContextMenu={handleContextClick}
|
||||
onClick={handleOnClickRow}>
|
||||
<div style={{display: 'flex' , marginBottom: "1px", flexGrow: 1, width: "100%"}}>
|
||||
<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' : '' }}
|
||||
|
||||
>
|
||||
|
||||
{itemTreeData.is_group ? (
|
||||
<WidgetsIcon style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginLeft: "3px",
|
||||
marginRight: "5px" }} />
|
||||
<WidgetsIcon className={classes.icon} />
|
||||
): itemTreeData.root ? (
|
||||
<ComputerIcon style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginLeft: '3px',
|
||||
marginRight: '5px' }} />
|
||||
<ComputerIcon className={classes.icon} />
|
||||
) : !itemTreeData.can_have_children ? (
|
||||
<DescriptionIcon style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginLeft: '3px',
|
||||
marginRight: '5px' }} />
|
||||
<DescriptionIcon className={classes.icon} />
|
||||
) : itemTreeData.isOpen ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faFolderOpen}
|
||||
style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginLeft: '3px',
|
||||
marginRight: '5px',
|
||||
color: item?.metadata?.has_children ? theme.folderColor : theme.palette.text.secondary,
|
||||
}}
|
||||
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={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginLeft: '3px',
|
||||
marginRight: '5px',
|
||||
color: item?.metadata?.has_children ? theme.folderColor : theme.palette.text.secondary, }}
|
||||
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' style={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
<CheckCircleOutlineIcon fontSize='small' color="success" />
|
||||
<MythicStyledTooltip title='Successfully listed contents of folder'>
|
||||
<CheckCircleOutlineIcon className={classes.icon} fontSize='small' color="success" />
|
||||
</MythicStyledTooltip>
|
||||
) : item.success === false && itemTreeData.depth > 0 ? (
|
||||
<MythicStyledTooltip title='Failed to list contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
<ErrorIcon fontSize='small' color="error" />
|
||||
<MythicStyledTooltip title='Failed to list contents of folder'>
|
||||
<ErrorIcon className={classes.icon} fontSize='small' color="error" />
|
||||
</MythicStyledTooltip>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -435,33 +449,35 @@ const FileBrowserVirtualTreePreMemo = ({
|
||||
}
|
||||
}, [selectedFolderData, flattenedNodes]);
|
||||
return flattenedNodes.length > 0 ? (
|
||||
<StyledAutoSizer>
|
||||
{(AutoSizerProps) => (
|
||||
<List
|
||||
itemData={flattenedNodes}
|
||||
direction="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}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
</StyledAutoSizer>
|
||||
<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}
|
||||
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}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
</StyledAutoSizer>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
export const FileBrowserVirtualTree = React.memo(FileBrowserVirtualTreePreMemo);
|
||||
@@ -2,17 +2,19 @@ import React from 'react';
|
||||
import {snackActions} from "../utilities/Snackbar";
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import {MythicDialog} from "./MythicDialog";
|
||||
import {Link} from '@mui/material';
|
||||
import {useMutation, useLazyQuery, gql} from '@apollo/client';
|
||||
import {gql} from '@apollo/client';
|
||||
import {b64DecodeUnicode} from "../pages/Callbacks/ResponseDisplay";
|
||||
import {PreviewFileMediaDialog} from "../pages/Search/PreviewFileMedia";
|
||||
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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -31,45 +33,70 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
|
||||
}
|
||||
setOpenPreviewMediaDialog(true);
|
||||
}
|
||||
const [getFileInformation] = useLazyQuery(getfileInformationQuery, {
|
||||
onCompleted: (data) => {
|
||||
setFileData( {...fileData, filename: b64DecodeUnicode(data.filemeta[0].filename_text)});
|
||||
if(display_link === "" || display_link === undefined){
|
||||
setFileData( {...fileData,
|
||||
filename: b64DecodeUnicode(data.filemeta[0].filename_text),
|
||||
display_link: b64DecodeUnicode(data.filemeta[0].filename_text)});
|
||||
}
|
||||
},
|
||||
onError: (data) => {
|
||||
snackActions.error("Failed to fetch instance data: " + data);
|
||||
console.log(data);
|
||||
},
|
||||
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(filename === "" || filename === undefined){
|
||||
getFileInformation({variables: {file_id: fileData.agent_file_id}})
|
||||
if(agent_file_id !== '' && agent_file_id !== undefined){
|
||||
getFileInformation({variables: {file_id: agent_file_id}})
|
||||
.then(({data}) => getFileInformationSuccess(data)).catch((data) => getFileInformationError(data));
|
||||
}
|
||||
}, [filename]);
|
||||
}, [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: "25px", bottom: "5px", position: "relative", cursor: "pointer", display: "inline-block"}}
|
||||
style={{height: "20px", position: "relative", cursor: "pointer", display: "inline-block"}}
|
||||
onClick={onPreviewMedia} />
|
||||
</MythicStyledTooltip>
|
||||
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
|
||||
<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}
|
||||
</Link>
|
||||
</FileDownloadLinkWithAuth>
|
||||
{openPreviewMediaDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
|
||||
onClose={(e)=>{setOpenPreviewMediaDialog(false);}}
|
||||
onClose={onClose}
|
||||
innerDialog={<PreviewFileMediaDialog
|
||||
agent_file_id={fileData.agent_file_id}
|
||||
filename={fileData.filename}
|
||||
onClose={(e)=>{setOpenPreviewMediaDialog(false);}} />}
|
||||
onClose={onClose} />}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {snackActions} from '../utilities/Snackbar';
|
||||
import {mythicFetch} from '../utilities/MythicConnection';
|
||||
|
||||
export const UploadTaskFile = async (file, comment) => {
|
||||
let formData = new FormData();
|
||||
@@ -11,7 +12,7 @@ export const UploadTaskFile = async (file, comment) => {
|
||||
return null;
|
||||
}
|
||||
try{
|
||||
const upload_response = await fetch('/api/v1.4/task_upload_file_webhook', {
|
||||
const upload_response = await mythicFetch('/task_upload_file_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
@@ -21,7 +22,11 @@ export const UploadTaskFile = async (file, comment) => {
|
||||
});
|
||||
try{
|
||||
const upload_result = upload_response.json().then(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 => {
|
||||
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
|
||||
console.log("Error trying to get json response", error.toString());
|
||||
@@ -37,13 +42,53 @@ export const UploadTaskFile = async (file, comment) => {
|
||||
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 fetch('/api/v1.4/eventing_import_webhook', {
|
||||
const upload_response = await mythicFetch('/eventing_import_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
@@ -75,7 +120,7 @@ export const UploadEventGroupFile = async (file, eventgroup_id) => {
|
||||
formData.append("file", file);
|
||||
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
|
||||
try{
|
||||
const upload_response = await fetch('/api/v1.4/eventing_register_file_webhook', {
|
||||
const upload_response = await mythicFetch('/eventing_register_file_webhook', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
@@ -100,4 +145,4 @@ export const UploadEventGroupFile = async (file, eventgroup_id) => {
|
||||
snackActions.error(error.toString());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,12 @@ export const Dropdown = React.forwardRef(
|
||||
minWidth,
|
||||
absoluteX,
|
||||
absoluteY,
|
||||
anchorReference,
|
||||
anchorReference="anchorEl",
|
||||
style,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
MenuListProps,
|
||||
PaperProps,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -65,13 +67,18 @@ export const Dropdown = React.forwardRef(
|
||||
: props.children
|
||||
});
|
||||
};
|
||||
const anchorEl = isOpen && isOpen.currentTarget ? isOpen.currentTarget : isOpen;
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
elevation={5}
|
||||
PaperProps={{ sx: { minWidth: minWidth ?? 0 } }}
|
||||
PaperProps={{
|
||||
...PaperProps,
|
||||
sx: { minWidth: minWidth ?? 0, ...(PaperProps?.sx || {}) }
|
||||
}}
|
||||
MenuListProps={MenuListProps}
|
||||
style={{zIndex: 100000, position: "absolute"}}
|
||||
anchorEl={isOpen}
|
||||
anchorEl={anchorReference === "anchorEl" ? anchorEl : undefined}
|
||||
transition={"true"}
|
||||
open={!!externallyOpen}
|
||||
anchorPosition={anchorReference === "anchorEl" ? undefined : {top: absoluteY, left: absoluteX}}
|
||||
@@ -216,6 +223,9 @@ const NestedMenuItem = React.forwardRef((props, ref) => {
|
||||
</MenuItem>
|
||||
<Menu
|
||||
hideBackdrop
|
||||
disablePortal
|
||||
disableEnforceFocus={false}
|
||||
disableAutoFocus={false}
|
||||
style={{ pointerEvents: "none", zIndex: 100000}}
|
||||
anchorEl={menuItemRef.current}
|
||||
anchorOrigin={{
|
||||
@@ -228,12 +238,13 @@ const NestedMenuItem = React.forwardRef((props, ref) => {
|
||||
}}
|
||||
css={customTheme}
|
||||
open={!!open}
|
||||
autoFocus={false}
|
||||
disableAutoFocus
|
||||
disableEnforceFocus
|
||||
onClose={() => {
|
||||
setIsSubMenuOpen(false);
|
||||
}}
|
||||
MenuListProps={{
|
||||
'aria-hidden': false,
|
||||
role: 'menu'
|
||||
}}
|
||||
>
|
||||
<div ref={menuContainerRef} style={{ pointerEvents: "auto" }}>
|
||||
{children}
|
||||
@@ -249,4 +260,4 @@ 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -6,55 +6,86 @@ import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../MythicNeste
|
||||
const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const rowClassName = data.gridUUID + "row" + rowIndex;
|
||||
const rowHighlight = rowIndex % 2 === 0 ? 'MythicResizableGridRowHighlight' : '';
|
||||
const [contextMenuOptions, setContextMenuOptions] = React.useState(data?.rowContextMenuOptions || []);
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const item = data.items[rowIndex][columnIndex];
|
||||
const cellStyle = item?.props?.cellData?.cellStyle || {};
|
||||
const rowStyle = data.items[rowIndex][columnIndex]?.props?.rowData?.rowStyle || {};
|
||||
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.toggle(className, shouldAdd);
|
||||
}
|
||||
}
|
||||
}, [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
|
||||
},
|
||||
[data, rowIndex]
|
||||
[columnIndex, data, rowIndex]
|
||||
);
|
||||
const handleClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if(data.onRowClick){
|
||||
data.onRowClick({event, rowDataStatic: data.items[rowIndex][columnIndex]?.props?.rowData})
|
||||
}
|
||||
};
|
||||
const selectedClass = data.items[rowIndex][columnIndex]?.props?.rowData?.selected ? "selectedCallback" : "";
|
||||
const onMouseEnter = () => {
|
||||
const cells = document.getElementsByClassName(rowClassName);
|
||||
if(cells.length > 0){
|
||||
for(const cell of cells){
|
||||
cell.classList.add(classes.hoveredRow);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
const onMouseLeave = () => {
|
||||
const cells = document.getElementsByClassName(rowClassName);
|
||||
if(cells.length > 0){
|
||||
for(const cell of cells){
|
||||
cell.classList.remove(classes.hoveredRow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [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){
|
||||
onMouseLeave();
|
||||
if(openContextMenu){
|
||||
ownsContextRowRef.current = true;
|
||||
setContextRow(true);
|
||||
setHoveredRow(true);
|
||||
} else if(ownsContextRowRef.current){
|
||||
ownsContextRowRef.current = false;
|
||||
setContextRow(false);
|
||||
setHoveredRow(false);
|
||||
}
|
||||
}, [openContextMenu]);
|
||||
}, [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;
|
||||
}
|
||||
contextMenuLocationRef.current.x = event.clientX;
|
||||
@@ -77,7 +108,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
);
|
||||
return (
|
||||
<div style={{...style, ...cellStyle, ...rowStyle}}
|
||||
className={`${classes.cell} ${rowClassName} ${selectedClass}`}
|
||||
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -89,14 +120,14 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
{item}
|
||||
</div>
|
||||
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={contextMenuOptions}
|
||||
disableFilterMenu={item.disableFilterMenu} openContextMenu={openContextMenu}
|
||||
disableFilterMenu={item?.disableFilterMenu} openContextMenu={openContextMenu}
|
||||
contextMenuLocationRef={contextMenuLocationRef}
|
||||
setOpenContextMenu={setOpenContextMenu} handleMenuItemClick={handleMenuItemClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOptions, setOpenContextMenu, handleMenuItemClick, contextMenuLocationRef}) => {
|
||||
export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOptions, setOpenContextMenu, handleMenuItemClick, contextMenuLocationRef}) => {
|
||||
const handleClose = (event) => {
|
||||
//if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
|
||||
// return;
|
||||
@@ -120,6 +151,7 @@ const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOptions, se
|
||||
<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}
|
||||
@@ -130,11 +162,12 @@ const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOptions, se
|
||||
disabled={option.disabled}
|
||||
menu={
|
||||
option.menuItems.map((menuOption, indx) => (
|
||||
<DropdownMenuItem
|
||||
key={menuOption.name}
|
||||
disabled={menuOption.disabled}
|
||||
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
@@ -177,4 +210,4 @@ export default Cell;
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
*/
|
||||
*/
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {classes} from './styles';
|
||||
import Draggable from 'react-draggable';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
|
||||
const DraggableHandles = React.forwardRef(({ height, rowHeight, width, minColumnWidth, columnWidths, onStop }, ref) => {
|
||||
const [isDragging, setIsDragging] = useState(-1);
|
||||
const nodeRef = React.useRef(null);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classes.draggableHandlesContainer}
|
||||
style={{
|
||||
height: height,
|
||||
width: width,
|
||||
pointerEvents: isDragging >= 0 ? 'initial' : 'none',
|
||||
}}>
|
||||
{columnWidths.map((_, i) => {
|
||||
// leftOffset is the sum of the width of all columns left of i
|
||||
const leftOffset = columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<Draggable
|
||||
key={i}
|
||||
nodeRef={ref}
|
||||
axis='x'
|
||||
bounds={{
|
||||
left: minColumnWidth - columnWidths[i],
|
||||
right: Number.POSITIVE_INFINITY,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
offsetParent={document.body}
|
||||
|
||||
defaultPosition={{x: 0, y: 0}}
|
||||
position={{x: 0, y: 0}}
|
||||
onStart={() => {
|
||||
setIsDragging(i);
|
||||
}}
|
||||
onStop={(e, data) => {
|
||||
setIsDragging(-1);
|
||||
onStop(data.x, i);
|
||||
}}>
|
||||
<MoreVertIcon
|
||||
ref={nodeRef}
|
||||
className={isDragging === i ? classes.draggableHandlesClickAreaSelected : classes.draggableHandlesClickArea}
|
||||
style={{
|
||||
left: leftOffset + columnWidths[i] - 1 - 7,
|
||||
}}>
|
||||
</MoreVertIcon>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,10 @@ import { useCallback } from 'react';
|
||||
import useSingleAndDoubleClick from '../../utilities/useSingleAndDoubleClick';
|
||||
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,12 +15,17 @@ const HeaderCell = ({
|
||||
sortIndicatorIndex,
|
||||
sortDirection,
|
||||
headerNameKey = "name",
|
||||
isResizing = false,
|
||||
onResizePointerDown = () => {},
|
||||
VariableSizeGridProps: { style, rowIndex, columnIndex, data },
|
||||
}) => {
|
||||
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);
|
||||
@@ -39,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}}>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
+425
-167
@@ -5,15 +5,201 @@ import useScrollbarSize from 'react-scrollbar-size';
|
||||
import { VariableSizeGrid } from 'react-window';
|
||||
import HeaderCell from './HeaderCell';
|
||||
import Cell from './Cell';
|
||||
import DraggableHandles from './DraggableHandles';
|
||||
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 = 20;
|
||||
const MIN_FLEX_COLUMN_WIDTH = 150;
|
||||
const MIN_COLUMN_WIDTH = 50;
|
||||
const MIN_FLEX_COLUMN_WIDTH = 50;
|
||||
const AUTOSIZE_HORIZONTAL_PADDING = 40;
|
||||
const AUTOSIZE_HEADER_EXTRA_WIDTH = 30;
|
||||
const headerMenuIconStyle = {fontSize: "1rem", marginRight: "8px"};
|
||||
const normalizeHeaderContextMenuOption = (option) => {
|
||||
if(option?.name === "Filter Column"){
|
||||
return {
|
||||
...option,
|
||||
icon: <FilterAltOutlinedIcon style={headerMenuIconStyle} />,
|
||||
};
|
||||
}
|
||||
if(option?.menuItems){
|
||||
return {
|
||||
...option,
|
||||
menuItems: option.menuItems.map(normalizeHeaderContextMenuOption),
|
||||
};
|
||||
}
|
||||
return option;
|
||||
};
|
||||
|
||||
let autosizeCanvas;
|
||||
|
||||
const getNumericWidth = (value) => {
|
||||
const width = Number(value);
|
||||
return Number.isFinite(width) ? width : undefined;
|
||||
};
|
||||
const getColumnMinWidth = (column) => {
|
||||
const configuredMinWidth = getNumericWidth(column?.minWidth);
|
||||
return Math.max(configuredMinWidth || 0, column?.fillWidth ? MIN_FLEX_COLUMN_WIDTH : MIN_COLUMN_WIDTH);
|
||||
};
|
||||
const getColumnMaxWidth = (column) => {
|
||||
const configuredMaxWidth = getNumericWidth(column?.maxWidth);
|
||||
return configuredMaxWidth && configuredMaxWidth > 0 ? configuredMaxWidth : Infinity;
|
||||
};
|
||||
const clampColumnWidth = (column, width) => {
|
||||
const minWidth = getColumnMinWidth(column);
|
||||
const maxWidth = getColumnMaxWidth(column);
|
||||
const numericWidth = getNumericWidth(width) || minWidth;
|
||||
return Math.floor(Math.min(maxWidth, Math.max(numericWidth, minWidth)));
|
||||
};
|
||||
const getPreferredColumnWidth = (column) => {
|
||||
return column?.width === undefined ? getColumnMinWidth(column) : column.width;
|
||||
};
|
||||
const distributeFillWidth = ({columns, columnWidths, totalWidth}) => {
|
||||
const availableWidth = Math.max(0, Math.floor(totalWidth || 0));
|
||||
const currentWidth = columnWidths.reduce((a, b) => a + b, 0);
|
||||
const widthDifference = availableWidth - currentWidth;
|
||||
if(widthDifference <= 0 || columns.length === 0){
|
||||
return columnWidths;
|
||||
}
|
||||
const fillColumnIndexes = columns.reduce((previous, column, index) => {
|
||||
return column.fillWidth ? [...previous, index] : previous;
|
||||
}, []);
|
||||
const targetIndexes = fillColumnIndexes.length > 0 ? fillColumnIndexes : [columns.length - 1];
|
||||
let remainingWidth = widthDifference;
|
||||
const updatedColumnWidths = [...columnWidths];
|
||||
for(let i = 0; i < targetIndexes.length; i++){
|
||||
const columnIndex = targetIndexes[i];
|
||||
const share = i === targetIndexes.length - 1 ? remainingWidth : Math.floor(widthDifference / targetIndexes.length);
|
||||
const currentColumnWidth = updatedColumnWidths[columnIndex];
|
||||
const nextColumnWidth = clampColumnWidth(columns[columnIndex], currentColumnWidth + share);
|
||||
updatedColumnWidths[columnIndex] = nextColumnWidth;
|
||||
remainingWidth -= nextColumnWidth - currentColumnWidth;
|
||||
}
|
||||
return updatedColumnWidths;
|
||||
};
|
||||
const getInitialColumnWidths = ({columns, savedWidths = [], totalWidth = 0}) => {
|
||||
const hasSavedWidths = savedWidths.length === columns.length;
|
||||
const baseColumnWidths = columns.map((column, index) => {
|
||||
return clampColumnWidth(column, hasSavedWidths ? savedWidths[index] : getPreferredColumnWidth(column));
|
||||
});
|
||||
if(hasSavedWidths){
|
||||
return baseColumnWidths;
|
||||
}
|
||||
return distributeFillWidth({columns, columnWidths: baseColumnWidths, totalWidth});
|
||||
};
|
||||
const areColumnWidthsEqual = (first = [], second = []) => {
|
||||
if(first.length !== second.length){
|
||||
return false;
|
||||
}
|
||||
return first.every((width, index) => Math.floor(width || MIN_COLUMN_WIDTH) === Math.floor(second[index] || MIN_COLUMN_WIDTH));
|
||||
};
|
||||
const normalizeColumnWidths = (columnWidths = []) => {
|
||||
return columnWidths.map((columnWidth) => Math.floor(getNumericWidth(columnWidth) || MIN_COLUMN_WIDTH));
|
||||
};
|
||||
const getMeasureContext = () => {
|
||||
if(typeof document === "undefined"){
|
||||
return null;
|
||||
}
|
||||
if(autosizeCanvas === undefined){
|
||||
autosizeCanvas = document.createElement("canvas");
|
||||
}
|
||||
const context = autosizeCanvas.getContext("2d");
|
||||
if(context === null){
|
||||
return null;
|
||||
}
|
||||
const referenceElement = document.querySelector(`.${classes.cell}`) || document.body;
|
||||
const computedStyle = window.getComputedStyle(referenceElement);
|
||||
context.font = [
|
||||
computedStyle.fontStyle,
|
||||
computedStyle.fontVariant,
|
||||
computedStyle.fontWeight,
|
||||
computedStyle.fontSize,
|
||||
computedStyle.fontFamily,
|
||||
].join(" ");
|
||||
return context;
|
||||
};
|
||||
const getDisplayText = (value) => {
|
||||
if(value === undefined || value === null){
|
||||
return "";
|
||||
}
|
||||
if(typeof value === "string"){
|
||||
const trimmedValue = value.trim();
|
||||
if(trimmedValue.startsWith("[")){
|
||||
try{
|
||||
const parsedValue = JSON.parse(trimmedValue);
|
||||
if(Array.isArray(parsedValue) && parsedValue.length > 0){
|
||||
return getDisplayText(parsedValue[0]);
|
||||
}
|
||||
}catch(error){
|
||||
// Keep the original string when it is not a serialized array.
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if(typeof value === "number" || typeof value === "boolean"){
|
||||
return String(value);
|
||||
}
|
||||
if(Array.isArray(value)){
|
||||
return value.map((entry) => getDisplayText(entry)).filter(Boolean).join(", ");
|
||||
}
|
||||
if(React.isValidElement(value)){
|
||||
return getDisplayText(value.props?.cellData || value.props?.children);
|
||||
}
|
||||
if(typeof value === "object"){
|
||||
if(value.plaintext !== undefined){
|
||||
return getDisplayText(value.plaintext);
|
||||
}
|
||||
if(value.name !== undefined){
|
||||
return getDisplayText(value.name);
|
||||
}
|
||||
if(value.button?.name !== undefined){
|
||||
return getDisplayText(value.button.name);
|
||||
}
|
||||
if(value.display !== undefined){
|
||||
return getDisplayText(value.display);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const getCellAutosizeText = ({column, cell}) => {
|
||||
const cellProps = cell?.props || {};
|
||||
const columnKey = column?.key || column?.plaintext;
|
||||
const rowData = cellProps.rowData || {};
|
||||
const cellDataText = getDisplayText(cellProps.cellData);
|
||||
if(cellDataText !== ""){
|
||||
return cellDataText;
|
||||
}
|
||||
if(columnKey !== undefined){
|
||||
return getDisplayText(rowData?.[columnKey]);
|
||||
}
|
||||
return getDisplayText(cell);
|
||||
};
|
||||
const measureAutosizeText = (context, text) => {
|
||||
const normalizedText = getDisplayText(text);
|
||||
if(normalizedText === ""){
|
||||
return 0;
|
||||
}
|
||||
const lines = normalizedText.split(/\r?\n/);
|
||||
return lines.reduce((longestLineWidth, line) => {
|
||||
const textWidth = context ? context.measureText(line).width : line.length * 8;
|
||||
return Math.max(longestLineWidth, textWidth);
|
||||
}, 0);
|
||||
};
|
||||
const getAutosizedColumnWidth = ({column, columnIndex, headerNameKey, items}) => {
|
||||
const measureContext = getMeasureContext();
|
||||
const headerText = getDisplayText(column?.[headerNameKey] || column?.name || column?.plaintext);
|
||||
const headerWidth = measureAutosizeText(measureContext, headerText.toUpperCase()) + AUTOSIZE_HEADER_EXTRA_WIDTH;
|
||||
const widestCellWidth = items.reduce((widestWidth, itemRow) => {
|
||||
const cellText = getCellAutosizeText({column, cell: itemRow[columnIndex]});
|
||||
return Math.max(widestWidth, measureAutosizeText(measureContext, cellText));
|
||||
}, 0);
|
||||
return clampColumnWidth(column, Math.max(headerWidth, widestCellWidth) + AUTOSIZE_HORIZONTAL_PADDING);
|
||||
};
|
||||
|
||||
const CellRendererPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
return rowIndex === 0 ? null : <Cell style={style} rowIndex={rowIndex} columnIndex={columnIndex} data={data} />;
|
||||
@@ -21,12 +207,16 @@ const CellRendererPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
const CellRenderer = React.memo(CellRendererPreMemo);
|
||||
const innerElementType = React.forwardRef(({ children, style }, ref) => {
|
||||
const HeaderCellData = useContext(HeaderCellContext);
|
||||
const resizingColumnIndex = HeaderCellData.resizingColumnIndex;
|
||||
const resizeGuideLeft = resizingColumnIndex >= 0 ?
|
||||
HeaderCellData.columnWidths.slice(0, resizingColumnIndex + 1).reduce((a, b) => a + b, 0) :
|
||||
-1;
|
||||
const onHeaderDoubleClick = React.useCallback( (e, columnIndex) => {
|
||||
if (HeaderCellData.columns[columnIndex].disableAutosize) return;
|
||||
HeaderCellData.autosizeColumn({columnIndex});
|
||||
}, [HeaderCellData.columns, HeaderCellData.autosizeColumn]);
|
||||
return (
|
||||
<div ref={ref} style={style}>
|
||||
<div ref={ref} style={{...style, position: "relative"}}>
|
||||
{/* always render header cells */}
|
||||
<div
|
||||
className={classes.headerCellRow}
|
||||
@@ -44,6 +234,8 @@ const innerElementType = React.forwardRef(({ children, style }, ref) => {
|
||||
contextMenuOptions={HeaderCellData.contextMenuOptions}
|
||||
sortIndicatorIndex={HeaderCellData.sortIndicatorIndex}
|
||||
sortDirection={HeaderCellData.sortDirection}
|
||||
isResizing={HeaderCellData.resizingColumnIndex === i}
|
||||
onResizePointerDown={HeaderCellData.startColumnResize}
|
||||
VariableSizeGridProps={{
|
||||
style: {
|
||||
position: 'absolute',
|
||||
@@ -62,6 +254,15 @@ const innerElementType = React.forwardRef(({ children, style }, ref) => {
|
||||
</div>
|
||||
{/* render other cells as usual */}
|
||||
{children}
|
||||
{resizingColumnIndex >= 0 &&
|
||||
<div
|
||||
className={classes.resizeGuide}
|
||||
style={{
|
||||
left: resizeGuideLeft,
|
||||
height: style.height,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -92,19 +293,31 @@ const ResizableGridWrapper = ({
|
||||
const { width: scrollbarWidth } = useScrollbarSize();
|
||||
const localColumnsRef = React.useRef(columns);
|
||||
const initialSavedWidths = name === undefined ? [] : GetMythicSetting({setting_name: `${name}_column_widths`, default_value: {}, output: "json-array"});
|
||||
const [columnWidths, setColumnWidths] = useState(columns.map((column, index) => {
|
||||
if(initialSavedWidths.length > 0){
|
||||
return initialSavedWidths[index];
|
||||
}
|
||||
if(column.fillWidth){
|
||||
return Math.max(column?.width || 0, MIN_FLEX_COLUMN_WIDTH);
|
||||
}
|
||||
return Math.max(column?.width || 0, MIN_COLUMN_WIDTH);
|
||||
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;
|
||||
@@ -121,141 +334,196 @@ const ResizableGridWrapper = ({
|
||||
[rowHeight, headerRowHeight]
|
||||
);
|
||||
useEffect(() => {
|
||||
if(initialSavedWidths.length > 0 && localColumnsRef.current.length === columns.length){
|
||||
if(savedColumnWidths.length === columns.length && localColumnsRef.current.length === columns.length){
|
||||
return;
|
||||
}
|
||||
localColumnsRef.current = columns;
|
||||
const totalWidth = AutoSizerProps.width - scrollbarWidth;
|
||||
const updatedColumnWidths = columns.map((column, index) => {
|
||||
return column.width || MIN_COLUMN_WIDTH
|
||||
const updatedColumnWidths = getInitialColumnWidths({
|
||||
columns,
|
||||
savedWidths: [],
|
||||
totalWidth: AutoSizerProps.width - scrollbarWidth,
|
||||
});
|
||||
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]] += Math.max(totalWidthDiff / updatedWidthIndexs.length, MIN_FLEX_COLUMN_WIDTH);
|
||||
}
|
||||
//updatedColumnWidths[updatedWidthIndex] += totalWidthDiff;
|
||||
}
|
||||
setColumnWidths(updatedColumnWidths);
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths);
|
||||
if(didUpdateWidths && name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
|
||||
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [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 = useCallback( (x, columnIndex) => {
|
||||
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
|
||||
if (columnIndex === index) {
|
||||
return Math.floor(Math.max(columnWidth + x, MIN_COLUMN_WIDTH));
|
||||
}
|
||||
return Math.floor(columnWidth);
|
||||
});
|
||||
setColumnWidths(updatedColumnWidths);
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
|
||||
const startColumnResize = useCallback( (event, columnIndex) => {
|
||||
if(columns[columnIndex]?.disableResize){
|
||||
return;
|
||||
}
|
||||
|
||||
}, [columnWidths]);
|
||||
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}) => {
|
||||
if(columns[columnIndex].disableDoubleClick){
|
||||
const column = columns[columnIndex];
|
||||
if(column?.disableDoubleClick || column?.disableAutosize){
|
||||
return
|
||||
}
|
||||
const longestElementInColumn = Math.max(...items.map((itemRow) => {
|
||||
if(columns[columnIndex].key === undefined){
|
||||
if(columns[columnIndex].plaintext){
|
||||
columns[columnIndex].key = columns[columnIndex].plaintext;
|
||||
} else {
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
if(columns[columnIndex].key !== undefined){
|
||||
if(columns[columnIndex].key.includes("time")){
|
||||
return 30;
|
||||
}
|
||||
if(columns[columnIndex].key === "mythictree_groups"){
|
||||
return itemRow[columnIndex]?.props?.cellData.length;
|
||||
}
|
||||
if(columns[columnIndex].type === "size"){
|
||||
if(itemRow[columnIndex]?.props?.cellData){
|
||||
return String(itemRow[columnIndex]?.props?.cellData?.plaintext)?.length;
|
||||
}
|
||||
return itemRow[columnIndex].length;
|
||||
}
|
||||
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])
|
||||
}
|
||||
if(typeof itemRow[columnIndex]?.props?.cellData === 'string' ){
|
||||
return itemRow[columnIndex]?.props?.cellData.length;
|
||||
}
|
||||
|
||||
let data = itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key];
|
||||
if(columns[columnIndex].inMetadata){
|
||||
return itemRow[columnIndex]?.props?.cellData.length;
|
||||
}
|
||||
if(data === undefined){
|
||||
return MIN_COLUMN_WIDTH;
|
||||
}
|
||||
if(data.plaintext){
|
||||
return String(data.plaintext)?.length;
|
||||
} else if(data?.button?.name) {
|
||||
return String(data?.button?.name)?.length ;
|
||||
} else {
|
||||
return MIN_COLUMN_WIDTH;
|
||||
}
|
||||
//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 if(Array.isArray(itemRow[columnIndex]?.props?.cellData)){
|
||||
return itemRow[columnIndex]?.props?.cellData.join(", ").length;
|
||||
}else {
|
||||
return itemRow[columnIndex]?.props?.cellData?.length || -1;
|
||||
}
|
||||
|
||||
}));
|
||||
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
|
||||
const currentColumnWidths = columnWidthsRef.current;
|
||||
const updatedColumnWidths = currentColumnWidths.map((columnWidth, index) => {
|
||||
if (columnIndex === index) {
|
||||
if(isNaN(longestElementInColumn)){
|
||||
return MIN_COLUMN_WIDTH;
|
||||
}
|
||||
return Math.floor(Math.max(longestElementInColumn * 10 + 40, MIN_COLUMN_WIDTH));
|
||||
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
|
||||
}
|
||||
return Math.floor(columnWidth);
|
||||
});
|
||||
//console.log(updatedWidths, longestElementInColumn);
|
||||
setColumnWidths(updatedColumnWidths);
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
|
||||
if(didUpdateWidths){
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
}
|
||||
|
||||
}, [columnWidths]);
|
||||
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
|
||||
const autosizeAllColumns = useCallback( () => {
|
||||
const currentColumnWidths = columnWidthsRef.current;
|
||||
const updatedColumnWidths = currentColumnWidths.map((columnWidth, columnIndex) => {
|
||||
const column = columns[columnIndex];
|
||||
if(column?.disableDoubleClick || column?.disableAutosize){
|
||||
return Math.floor(columnWidth);
|
||||
}
|
||||
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
|
||||
});
|
||||
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){
|
||||
@@ -270,22 +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,
|
||||
};
|
||||
const localOnScroll = React.useCallback( ({scrollLeft}) => {
|
||||
|
||||
if (dragHandlesRef.current) {
|
||||
dragHandlesRef.current.scrollTo({ left: scrollLeft });
|
||||
}
|
||||
}, [dragHandlesRef.current])
|
||||
return (
|
||||
<>
|
||||
<HeaderCellContext.Provider value={headerCellData}>
|
||||
<VariableSizeGrid
|
||||
className={classes.grid}
|
||||
height={Math.max(1, AutoSizerProps.height)}
|
||||
width={AutoSizerProps.width}
|
||||
columnCount={columns.length}
|
||||
@@ -300,19 +565,9 @@ const ResizableGridWrapper = ({
|
||||
innerElementType={innerElementType}
|
||||
overscanRowCount={20}
|
||||
useIsScrolling={false}
|
||||
onScroll={localOnScroll}
|
||||
ref={gridRef}>
|
||||
{CellRenderer}
|
||||
</VariableSizeGrid>
|
||||
<DraggableHandles
|
||||
height={AutoSizerProps.height}
|
||||
rowHeight={headerRowHeight}
|
||||
width={AutoSizerProps.width}
|
||||
minColumnWidth={MIN_COLUMN_WIDTH}
|
||||
columnWidths={columnWidths}
|
||||
onStop={resizeColumn}
|
||||
ref={dragHandlesRef}
|
||||
/>
|
||||
</HeaderCellContext.Provider>
|
||||
|
||||
</>
|
||||
@@ -338,29 +593,31 @@ const MythicResizableGrid = ({
|
||||
name,
|
||||
}) => {
|
||||
return (
|
||||
<AutoSizer style={{height: "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 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -370,6 +627,7 @@ MythicResizableGrid.propTypes = {
|
||||
name: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
disableAutosize: PropTypes.bool,
|
||||
disableResize: PropTypes.bool,
|
||||
disableSort: PropTypes.bool
|
||||
})
|
||||
).isRequired,
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
const PREFIX = 'MythicResizableGrid';
|
||||
export const classes = {
|
||||
headerCellRow: `${PREFIX}-headerCellRow`,
|
||||
headerCell: `${PREFIX}-headerCell`,
|
||||
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`,
|
||||
cell: `${PREFIX}-cell`,
|
||||
cellInner: `${PREFIX}-cellInner`,
|
||||
draggableHandlesContainer: `${PREFIX}-draggableHandlesContainer`,
|
||||
draggableHandlesClickArea: `${PREFIX}-draggableHandlesClickArea`,
|
||||
draggableHandlesClickAreaSelected: `${PREFIX}-draggableHandlesClickAreaSelected`,
|
||||
draggableHandlesIndicator: `${PREFIX}-draggableHandlesIndicator`,
|
||||
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`,
|
||||
}
|
||||
@@ -12,12 +12,31 @@ mutation updatePreferences($preferences: jsonb!){
|
||||
}
|
||||
}
|
||||
`;
|
||||
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:
|
||||
hideUsernames
|
||||
showIP
|
||||
showHostname
|
||||
showCallbackGroups
|
||||
taskingDisplayFields
|
||||
showMedia
|
||||
interactType
|
||||
|
||||
@@ -38,7 +57,7 @@ export function useGetMythicSetting({setting_name, default_value}){
|
||||
return setting;
|
||||
}
|
||||
export function GetMythicSetting({setting_name, default_value}){
|
||||
const preferences = mePreferences();
|
||||
const preferences = getPreferenceSnapshot();
|
||||
return preferences?.[setting_name] === undefined ? default_value : preferences?.[setting_name];
|
||||
}
|
||||
export function useSetMythicSetting() {
|
||||
@@ -52,27 +71,35 @@ export function useSetMythicSetting() {
|
||||
}
|
||||
});
|
||||
return [
|
||||
({setting_name, value}) => {
|
||||
if(mePreferences()?.[setting_name] !== value){
|
||||
({setting_name, value, broadcast=true}) => {
|
||||
const currentPreferences = getPreferenceSnapshot();
|
||||
if(!areSettingValuesEqual(currentPreferences?.[setting_name], value)){
|
||||
const updatedPreferences = {
|
||||
...mePreferences(),
|
||||
...currentPreferences,
|
||||
[setting_name]: value,
|
||||
};
|
||||
mePreferences(updatedPreferences);
|
||||
latestPreferenceSnapshot = updatedPreferences;
|
||||
if(broadcast){
|
||||
mePreferences(updatedPreferences);
|
||||
}
|
||||
updateSetting({variables: {preferences: updatedPreferences}});
|
||||
}
|
||||
},
|
||||
({settings}) => {
|
||||
({settings, broadcast=true}) => {
|
||||
const updatedPreferences = {
|
||||
...mePreferences(),
|
||||
...settings
|
||||
...getPreferenceSnapshot(),
|
||||
...settings,
|
||||
};
|
||||
mePreferences(updatedPreferences);
|
||||
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([]);
|
||||
@@ -64,12 +61,12 @@ export function MythicSelectFromListDialog(props) {
|
||||
</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,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from '@mui/material';
|
||||
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
|
||||
|
||||
const PREFIX = 'MythicSnackDownload';
|
||||
|
||||
@@ -59,7 +59,7 @@ const Root = styled('div')((
|
||||
|
||||
[`& .${classes.checkIcon}`]: {
|
||||
fontSize: 20,
|
||||
color: '#b3b3b3',
|
||||
color: theme.palette.text.secondary,
|
||||
paddingRight: 4,
|
||||
},
|
||||
|
||||
@@ -77,10 +77,10 @@ export const MythicSnackDownload = (props) => {
|
||||
</Typography>
|
||||
<React.Fragment>
|
||||
<Typography gutterBottom>File ready for download</Typography>
|
||||
<Link color="textPrimary" 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>
|
||||
</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,4 +1,4 @@
|
||||
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';
|
||||
@@ -8,12 +8,15 @@ 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 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
|
||||
@@ -21,6 +24,7 @@ export function MythicTabPanel(props) {
|
||||
display: value === index ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
overflowY: "auto",
|
||||
@@ -38,7 +42,7 @@ export function MythicTabPanel(props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function a11yProps(index) {
|
||||
export function a11yProps(index) {
|
||||
return {
|
||||
id: `scrollable-auto-tab-${index}`,
|
||||
'aria-controls': `scrollable-auto-tabpanel-${index}`,
|
||||
@@ -60,16 +64,21 @@ export function MythicTabLabel(props) {
|
||||
getCallbackData,
|
||||
onDragTab,
|
||||
selectedIndex,
|
||||
highlight,
|
||||
newDataForTab,
|
||||
...other
|
||||
} = props;
|
||||
const onClick = (e) => {
|
||||
//e.stopPropagation();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onCloseTab({ tabID: tabInfo.tabID, index: index });
|
||||
};
|
||||
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);
|
||||
}
|
||||
@@ -85,27 +94,45 @@ export function MythicTabLabel(props) {
|
||||
setOpenContextMenu(false);
|
||||
};
|
||||
return (
|
||||
<Draggable draggableId={`callbacks-tab-${label}`} index={index} disableInteractiveElementBlocking={true}>
|
||||
<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
|
||||
className={selectedIndex === index || snapshot.isDragging ? "selectedCallback" : "" }
|
||||
label={
|
||||
<span onContextMenu={handleContextClick} style={{}} ref={dropdownAnchorRef}>
|
||||
{label}
|
||||
<IconButton component='div' size='small' onClick={onClick} {...other}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
<Badge color="success" variant="dot" invisible={!highlight} >
|
||||
{label}
|
||||
</Badge>
|
||||
</span>
|
||||
}
|
||||
{...a11yProps(index)}
|
||||
{...other}
|
||||
{...provided.dragHandleProps}
|
||||
style={{padding: "0px 5px 0px 5px", borderRadius: "4px", margin: 0,
|
||||
borderBottom: selectedIndex === index ? `2px solid grey` : '',
|
||||
backgroundColor: selectedIndex === index ? tabInfo.color : snapshot.isDragging ? tabInfo.color : ""}}
|
||||
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
|
||||
@@ -114,7 +141,7 @@ export function MythicTabLabel(props) {
|
||||
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper variant="outlined" className={"dropdownMenuColored"}>
|
||||
<Paper variant="outlined" className={"dropdownMenuColored bg-surface rounded border"}>
|
||||
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
|
||||
<MenuList id="split-button-menu" >
|
||||
{contextMenuOptions.map((option, index) => (
|
||||
@@ -125,6 +152,7 @@ export function MythicTabLabel(props) {
|
||||
{option.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={onClick}>Close Tab</MenuItem>
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
@@ -143,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>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,10 @@
|
||||
import {MythicActionButton} from "./MythicActionButton";
|
||||
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 MythicTextField from './MythicTextField';
|
||||
import {useQuery, gql, useMutation} from '@apollo/client';
|
||||
import { Select, Input, MenuItem, Link, IconButton } from '@mui/material';
|
||||
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 TableRow from '@mui/material/TableRow';
|
||||
import {Box, Select, MenuItem, Link, Typography} from '@mui/material';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
@@ -21,19 +15,45 @@ import { MythicDialog } from './MythicDialog';
|
||||
import {MythicConfirmDialog} from './MythicConfirmDialog';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import WebhookIcon from '@mui/icons-material/Webhook';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import {MythicChip} from './MythicChip';
|
||||
import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined';
|
||||
import {mythicFetch} from '../utilities/MythicConnection';
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {meState} from "../../cache";
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
|
||||
import {NewTagtypesDialog} from "../pages/Tags/NewTagtypesDialog";
|
||||
import {getTagReadableTextColor, TagTypeChip} from "./MythicTagChip";
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection,
|
||||
MythicFormField,
|
||||
MythicFormGrid,
|
||||
MythicFormNote
|
||||
} from "./MythicDialogLayout";
|
||||
|
||||
const getCreateTagTargetObject = (target_object) => {
|
||||
if(target_object === "task_id"){
|
||||
return "task_display_id";
|
||||
} else if(target_object === "callback_id"){
|
||||
return "callback_display_id";
|
||||
}
|
||||
return target_object;
|
||||
}
|
||||
|
||||
const createNewTagMutationTemplate = ({target_object}) => {
|
||||
const createTagTargetObject = getCreateTagTargetObject(target_object);
|
||||
// target_object should be something like "task_id"
|
||||
// target_object_id should be something like "89"
|
||||
return gql`
|
||||
mutation createNewTag($url: String!, $data: jsonb!, $source: String!, $${target_object}: Int!, $tagtype_id: Int!){
|
||||
insert_tag_one(object: {url: $url, data: $data, source: $source, ${target_object}: $${target_object}, tagtype_id: $tagtype_id}){
|
||||
mutation createNewTag($url: String!, $data: jsonb!, $source: String!, $${createTagTargetObject}: Int!, $tagtype_id: Int!){
|
||||
createTag(url: $url, data: $data, source: $source, ${createTagTargetObject}: $${createTagTargetObject}, tagtype_id: $tagtype_id){
|
||||
id
|
||||
status
|
||||
error
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -94,6 +114,8 @@ query getSingleTag($tag_id: Int!){
|
||||
mythictree_id
|
||||
operation_id
|
||||
response_id
|
||||
callback_id
|
||||
payload_id
|
||||
task_id
|
||||
taskartifact_id
|
||||
tagtype {
|
||||
@@ -105,15 +127,19 @@ query getSingleTag($tag_id: Int!){
|
||||
}
|
||||
}
|
||||
`
|
||||
export const TagsDisplay = ({tags}) => {
|
||||
export const TagsDisplay = ({tags, expand}) => {
|
||||
return (
|
||||
tags?.map( tt => (
|
||||
<TagChipDisplay tag={tt} key={tt.id} />
|
||||
<TagChipDisplay tag={tt} key={tt.id} expand={expand} />
|
||||
))
|
||||
)
|
||||
}
|
||||
const TagChipDisplay = ({tag}) => {
|
||||
const TagChipDisplay = ({tag, expand}) => {
|
||||
const theme = useTheme();
|
||||
const [openTagDisplay, setOpenTagDisplay] = React.useState(false);
|
||||
const [label, setLabel] = React.useState(expand ? tag.tagtype.name : tag.tagtype.name[0]);
|
||||
const color = tag?.tagtype?.color || "";
|
||||
const textColor = getTagReadableTextColor(theme, color);
|
||||
const onSelectTag = (event, tag) => {
|
||||
if(event){
|
||||
event.preventDefault();
|
||||
@@ -128,12 +154,30 @@ const TagChipDisplay = ({tag}) => {
|
||||
}
|
||||
setOpenTagDisplay(false);
|
||||
}
|
||||
const onMouseOver = () => {
|
||||
if(expand === undefined || !expand){
|
||||
setLabel(tag.tagtype.name);
|
||||
}
|
||||
}
|
||||
const onMouseOut = () => {
|
||||
if(expand === undefined || !expand){
|
||||
setTimeout( () => {
|
||||
setLabel(tag.tagtype.name[0]);
|
||||
}, 10000); // wait 10s then go back to just a single letter
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Chip label={tag.tagtype.name} size="small" onClick={(e) => onSelectTag(e)}
|
||||
style={{float: "right", backgroundColor:tag.tagtype.color,}}
|
||||
<MythicChip color={color} onMouseOver={onMouseOver} onMouseOut={onMouseOut} label={label} size="small" onClick={(e) => onSelectTag(e)}
|
||||
sx={{
|
||||
"& .MuiChip-label": {overflow: "visible"}
|
||||
backgroundColor: color + " !important",
|
||||
color: textColor + " !important",
|
||||
float: "right",
|
||||
height: "15px",
|
||||
"& .MuiChip-label": {
|
||||
color: "inherit",
|
||||
overflow: "visible"
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{openTagDisplay &&
|
||||
@@ -152,7 +196,7 @@ const StringTagDataEntry = ({name, value}) => {
|
||||
const colorRegex = ":color=[\"\'](?<color>.*?)[\"\']";
|
||||
const onClick = (e, url) => {
|
||||
e.preventDefault();
|
||||
fetch(url).then((response) => {
|
||||
mythicFetch(url).then((response) => {
|
||||
if (response.status !== 200) {
|
||||
snackActions.warning("HTTP " + response.status + " response");
|
||||
} else {
|
||||
@@ -186,9 +230,16 @@ const StringTagDataEntry = ({name, value}) => {
|
||||
</MythicStyledTooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
if(capturePieces[2].startsWith("http:") || capturePieces[2].startsWith("https:")){
|
||||
return (
|
||||
<Link href={capturePieces[2]} color="textPrimary" target={"_blank"} >{capturePieces[1]}</Link>
|
||||
)
|
||||
)
|
||||
} else {
|
||||
return <Typography>
|
||||
{capturePieces[1]} - ( {capturePieces[2]} )
|
||||
</Typography>
|
||||
}
|
||||
|
||||
} else if(value.startsWith("http:") || value.startsWith("https:")){
|
||||
return (
|
||||
<>
|
||||
@@ -199,6 +250,56 @@ const StringTagDataEntry = ({name, value}) => {
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const RenderedTagDataPreview = ({data}) => {
|
||||
const trimmedData = typeof data === "string" ? data.trim() : data;
|
||||
if(trimmedData === ""){
|
||||
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">No JSON data to render.</Box>;
|
||||
}
|
||||
try {
|
||||
const parsedData = typeof data === "string" ? JSON.parse(data) : data;
|
||||
if(parsedData === null || typeof parsedData !== "object"){
|
||||
return (
|
||||
<Box className="mythic-tag-data-preview-row gap-4 min-w-0 rounded grid bg-neutral-1 border-subtle">
|
||||
<Box className="mythic-tag-data-preview-key text-xs font-800 min-w-0 truncate text-muted whitespace-nowrap">value</Box>
|
||||
<Box className="mythic-tag-data-preview-value text-sm leading-135 min-w-0 wrap-anywhere text-primary">{String(parsedData)}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const entries = Array.isArray(parsedData) ?
|
||||
parsedData.map((value, index) => [`[${index}]`, value]) :
|
||||
Object.entries(parsedData);
|
||||
if(entries.length === 0){
|
||||
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">JSON object is empty.</Box>;
|
||||
}
|
||||
return (
|
||||
<Box className="mythic-tag-data-preview-list flex flex-column gap-3 min-w-0">
|
||||
{entries.map(([key, value]) => (
|
||||
<Box className="mythic-tag-data-preview-row gap-4 min-w-0 rounded grid bg-neutral-1 border-subtle" key={key}>
|
||||
<Box className="mythic-tag-data-preview-key text-xs font-800 min-w-0 truncate text-muted whitespace-nowrap">{key}</Box>
|
||||
<Box className="mythic-tag-data-preview-value text-sm leading-135 min-w-0 wrap-anywhere text-primary">
|
||||
{typeof value === "string" ? (
|
||||
<StringTagDataEntry name={key} value={value} />
|
||||
) : typeof value === "boolean" ? (
|
||||
value ? "True" : "False"
|
||||
) : typeof value === "object" && value !== null ? (
|
||||
<pre>{JSON.stringify(value, null, 2)}</pre>
|
||||
) : (
|
||||
String(value)
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
} catch (error) {
|
||||
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">Enter valid JSON to render a preview.</Box>;
|
||||
}
|
||||
}
|
||||
const TagReadonlyValue = ({children}) => (
|
||||
<Box className="mythic-tag-readonly-value text-sm leading-135 items-center flex min-w-0 wrap-anywhere w-full rounded bg-surface border-subtle text-primary">
|
||||
{children || "None"}
|
||||
</Box>
|
||||
)
|
||||
function ViewTagDialog(props) {
|
||||
const theme = useTheme();
|
||||
const [selectedTag, setSelectedTag] = React.useState({});
|
||||
@@ -222,6 +323,10 @@ function ViewTagDialog(props) {
|
||||
setObjectInfo({object_type: "task_id", object_id: data.tag_by_pk.task_id});
|
||||
}else if(data.tag_by_pk.taskartifact_id !== null){
|
||||
setObjectInfo({object_type: "taskartifact_id", object_id: data.tag_by_pk.taskartifact_id});
|
||||
}else if(data.tag_by_pk.payload_id !== null){
|
||||
setObjectInfo({object_type: "payload_id", object_id: data.tag_by_pk.payload_id});
|
||||
}else if(data.tag_by_pk.callback_id !== null){
|
||||
setObjectInfo({object_type: "callback_id", object_id: data.tag_by_pk.callback_id});
|
||||
}
|
||||
let newTag = {...data.tag_by_pk};
|
||||
let tagData = newTag;
|
||||
@@ -252,110 +357,82 @@ function ViewTagDialog(props) {
|
||||
}
|
||||
props.onClose(event);
|
||||
}
|
||||
|
||||
const stopPropagation = (e) => {
|
||||
e.stopPropagation();
|
||||
}
|
||||
const rawData = selectedTag?.is_json ? JSON.stringify(selectedTag?.data || {}, null, 2) : selectedTag?.data || "";
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title" onClick={(e) => e.stopPropagation()}>View Tag</DialogTitle>
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<TableCell style={{width: "20%"}}>Tag Type</TableCell>
|
||||
<TableCell style={{display: "inline-flex", flexDirection: "row", width: "100%"}}>
|
||||
<Chip label={selectedTag?.tagtype?.name||""} size="small" style={{float: "right", backgroundColor:selectedTag?.tagtype?.color||""}} />
|
||||
<ViewEditTags target_object={objectInfo.object_type} target_object_id={objectInfo.object_id} me={props.me} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>{selectedTag?.tagtype?.description || ""}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>
|
||||
{selectedTag?.source ||""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Reference URL</TableCell>
|
||||
<TableCell>
|
||||
{selectedTag?.url === "" ? (
|
||||
"No reference link provided"
|
||||
) : (
|
||||
<Link href={selectedTag?.url || "#"} color="textPrimary" target="_blank" referrerPolicy='no'>{selectedTag?.url ? "click here" : "No reference link provided"}</Link>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Data</TableCell>
|
||||
<TableCell>
|
||||
{selectedTag?.is_json ? (
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody>
|
||||
{Object.keys(selectedTag.data).map( key => (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>{key}</TableCell>
|
||||
{typeof selectedTag.data[key] === "string" ? (
|
||||
<TableCell style={{whiteSpace: "pre-wrap"}}>
|
||||
<StringTagDataEntry name={key} value={String(selectedTag.data[key])} />
|
||||
</TableCell>
|
||||
) : typeof selectedTag.data[key] === "object" ? (
|
||||
Array.isArray(selectedTag.data[key]) ? (
|
||||
<TableCell style={{whiteSpace: "pre-wrap"}}>{JSON.stringify(selectedTag.data[key], null, 2)}</TableCell>
|
||||
) : (
|
||||
<Table size={"small"} >
|
||||
{Object.keys(selectedTag.data[key]).map(key2 => (
|
||||
<TableRow key={key2} >
|
||||
<TableCell>{key2}</TableCell>
|
||||
<TableCell>
|
||||
<StringTagDataEntry name={key2} value={String(selectedTag.data[key][key2])} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
)
|
||||
) : typeof selectedTag.data[key] === "boolean" ? (
|
||||
<TableCell>{selectedTag.data[key] ? "True" : "False"}</TableCell>
|
||||
) :
|
||||
(
|
||||
<TableCell>{String(selectedTag.data[key])}</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
maxLines={20}
|
||||
highlightActiveLine={false}
|
||||
value={selectedTag?.data || ""}
|
||||
width={"100%"}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
wrapBehavioursEnabled: true,
|
||||
wrap: true
|
||||
}}/>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DialogActions onClick={(e) => e.stopPropagation()}>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
<MythicDraggableDialogTitle>View Tag</MythicDraggableDialogTitle>
|
||||
<DialogContent dividers={true} style={{width: "100%"}} onClick={stopPropagation}>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Tag Type"
|
||||
description={selectedTag?.tagtype?.description || "No description provided."}
|
||||
actions={
|
||||
objectInfo.object_type !== "" &&
|
||||
<ViewEditTags target_object={objectInfo.object_type} target_object_id={objectInfo.object_id} me={props.me} />
|
||||
}
|
||||
>
|
||||
{selectedTag?.tagtype &&
|
||||
<TagTypeChip tagtype={selectedTag.tagtype} />
|
||||
}
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="Reference Details">
|
||||
<MythicFormGrid minWidth="16rem">
|
||||
<MythicFormField label="Source">
|
||||
<TagReadonlyValue>{selectedTag?.source || "None"}</TagReadonlyValue>
|
||||
</MythicFormField>
|
||||
<MythicFormField label="External URL">
|
||||
<TagReadonlyValue>
|
||||
{selectedTag?.url ? (
|
||||
<Link href={selectedTag.url.startsWith("http:") || selectedTag.url.startsWith("https:") ? selectedTag.url : "#"}
|
||||
color="textPrimary" target="_blank" referrerPolicy='no'>
|
||||
{selectedTag.url}
|
||||
</Link>
|
||||
) : (
|
||||
"No reference link provided"
|
||||
)}
|
||||
</TagReadonlyValue>
|
||||
</MythicFormField>
|
||||
</MythicFormGrid>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="Tag Data">
|
||||
{selectedTag?.is_json ? (
|
||||
<Box className="mythic-tag-data-preview-frame mythic-tag-data-preview-frame-full min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
|
||||
<RenderedTagDataPreview data={selectedTag?.data || {}} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
showPrintMargin={false}
|
||||
minLines={8}
|
||||
maxLines={18}
|
||||
highlightActiveLine={false}
|
||||
readOnly={true}
|
||||
value={rawData}
|
||||
width={"100%"}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
wrapBehavioursEnabled: true,
|
||||
wrap: true
|
||||
}}/>
|
||||
</Box>
|
||||
)}
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter onClick={stopPropagation}>
|
||||
<MythicDialogButton onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -377,7 +454,11 @@ export function ViewEditTagsDialog(props) {
|
||||
setNewSource(data.tag[0].source);
|
||||
setNewURL(data.tag[0].url);
|
||||
try{
|
||||
setNewData(JSON.stringify(data.tag[0].data, null, 2));
|
||||
if(typeof data.tag[0].data !== "string"){
|
||||
setNewData(JSON.stringify(data.tag[0].data, null, 2));
|
||||
} else {
|
||||
setNewData(String(data.tag[0].data));
|
||||
}
|
||||
}catch(error){
|
||||
setNewData(String(data.tag[0].data));
|
||||
}
|
||||
@@ -449,8 +530,15 @@ const handleTaskTypeChange = (evt) => {
|
||||
setNewData(String(evt.target.value.data));
|
||||
}
|
||||
}
|
||||
const handleNewTagCreate = ({tagtype_id, source, url, data, id}) => {
|
||||
props.onClose();
|
||||
const handleNewTagCreate = ({tagtype_id, source, url, data, id, tagtype}) => {
|
||||
const newTag = {id, source, url, data, tagtype_id, tagtype};
|
||||
const newTags = [...existingTags, newTag].sort((a, b) => a.tagtype.name.localeCompare(b.tagtype.name));
|
||||
setExistingTags(newTags);
|
||||
setSelectedTag(newTag);
|
||||
setNewSource(source);
|
||||
setNewURL(url);
|
||||
setNewData(data);
|
||||
setOpenNewDialog(false);
|
||||
}
|
||||
const onAcceptDelete = () => {
|
||||
deleteTag({variables: {tag_id: selectedTag.id}});
|
||||
@@ -459,102 +547,128 @@ const onAcceptDelete = () => {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Edit Tags
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
{openNewDialog ?
|
||||
(<MythicDialog fullWidth={true} maxWidth="xl" open={openNewDialog}
|
||||
<MythicDraggableDialogTitle >Edit Tags </MythicDraggableDialogTitle>
|
||||
<DialogContent dividers={true} style={{width: "100%"}}>
|
||||
{openNewDialog && <MythicDialog fullWidth={true} maxWidth="lg" open={openNewDialog}
|
||||
onClose={()=>{setOpenNewDialog(false);}}
|
||||
innerDialog={<NewTagDialog me={props.me}
|
||||
target_object={props.target_object}
|
||||
target_object_id={props.target_object_id}
|
||||
target_object_id={props.target_object_id}
|
||||
target_object_display_id={props.target_object_display_id}
|
||||
onClose={()=>{setOpenNewDialog(false);}}
|
||||
onSubmit={handleNewTagCreate} />}
|
||||
/>) : null}
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<TableCell style={{width: "30%"}}>Select Existing Tag to Edit or Add New</TableCell>
|
||||
<TableCell style={{display: "inline-flex", flexDirection: "row-reverse"}}>
|
||||
<MythicStyledTooltip title={"Add New Tag"}>
|
||||
<IconButton variant='contained' color="success" style={{float: "right"}} onClick={() => setOpenNewDialog(true)} >
|
||||
<AddCircleOutlineIcon />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selectedTag}
|
||||
onChange={handleTaskTypeChange}
|
||||
input={<Input />}
|
||||
>
|
||||
{existingTags.map( (opt) => (
|
||||
<MenuItem value={opt} key={opt.id}>
|
||||
<Chip label={opt.tagtype.name} size="small" style={{float: "right", backgroundColor:opt.tagtype.color}} />
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
{selectedTag.id &&
|
||||
<IconButton size="small" style={{float: "right"}} onClick={()=>{setOpenDeleteDialog(true);}} color="error" variant="contained"><DeleteIcon/></IconButton>
|
||||
/>}
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Tag Selection"
|
||||
description={existingTags.length === 0 ? "No tags are attached yet." : "Choose the tag you want to update."}
|
||||
actions={
|
||||
<MythicStyledTooltip title={"Add New Tag"}>
|
||||
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="success" size="small" onClick={() => setOpenNewDialog(true)}>
|
||||
<AddCircleOutlineIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
>
|
||||
{existingTags.length === 0 ? (
|
||||
<MythicFormNote>
|
||||
Add a tag to capture source, reference, and structured data for this item.
|
||||
</MythicFormNote>
|
||||
) : (
|
||||
<Box sx={{display: "grid", gap: 1, gridTemplateColumns: {xs: "1fr", md: selectedTag.id ? "minmax(0, 1fr) auto" : "1fr"}, alignItems: "end"}}>
|
||||
<MythicFormField label="Existing Tag">
|
||||
<Select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={selectedTag}
|
||||
onChange={handleTaskTypeChange}
|
||||
renderValue={(tag) => tag?.id ? <TagTypeChip tagtype={tag.tagtype} /> : "Select a tag"}
|
||||
>
|
||||
{existingTags.map( (opt) => (
|
||||
<MenuItem value={opt} key={opt.id}>
|
||||
<TagTypeChip tagtype={opt.tagtype} />
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</MythicFormField>
|
||||
{selectedTag.id &&
|
||||
<MythicStyledTooltip title={"Delete Tag"}>
|
||||
<Box sx={{alignItems: "center", alignSelf: "end", display: "flex", height: 38}}>
|
||||
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="error" size="small" onClick={()=>{setOpenDeleteDialog(true);}}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</Box>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{selectedTag.id && selectedTag?.tagtype?.description &&
|
||||
<Box sx={{gridColumn: "1 / -1"}}>
|
||||
<MythicFormNote>{selectedTag.tagtype.description}</MythicFormNote>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
)}
|
||||
{openDelete &&
|
||||
<MythicConfirmDialog onClose={() => {setOpenDeleteDialog(false);}} onSubmit={onAcceptDelete} open={openDelete}/>
|
||||
}
|
||||
</MythicDialogSection>
|
||||
{selectedTag.id &&
|
||||
<>
|
||||
<MythicDialogSection title="Reference Details">
|
||||
<MythicFormGrid minWidth="16rem">
|
||||
<MythicFormField label="Source">
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" showLabel={false} marginTop="0" marginBottom="0" />
|
||||
</MythicFormField>
|
||||
<MythicFormField label="External URL">
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" showLabel={false} marginTop="0" marginBottom="0" />
|
||||
{newURL &&
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no' sx={{display: "inline-flex", mt: 0.5, fontSize: "0.78rem"}}>
|
||||
Open reference
|
||||
</Link>
|
||||
}
|
||||
{openDelete &&
|
||||
<MythicConfirmDialog onClose={() => {setOpenDeleteDialog(false);}} onSubmit={onAcceptDelete} open={openDelete}/>
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Tag Description</TableCell>
|
||||
<TableCell>{selectedTag?.tagtype?.description || ""}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>External URL</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL ? "click here" : ""}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>JSON Data</TableCell>
|
||||
<TableCell>
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
onChange={onEditorChange}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
maxLines={20}
|
||||
highlightActiveLine={true}
|
||||
value={newData}
|
||||
width={"100%"}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false
|
||||
}}/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</MythicFormField>
|
||||
</MythicFormGrid>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="JSON Data">
|
||||
<Box className="mythic-tag-data-split gap-6 min-w-0 w-full grid">
|
||||
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
onChange={onEditorChange}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
showPrintMargin={false}
|
||||
minLines={10}
|
||||
maxLines={18}
|
||||
highlightActiveLine={true}
|
||||
value={newData}
|
||||
width={"100%"}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
wrap: true
|
||||
}}/>
|
||||
</Box>
|
||||
<Box className="mythic-tag-data-preview-frame min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
|
||||
<RenderedTagDataPreview data={newData} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MythicDialogSection>
|
||||
</>
|
||||
}
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
{selectedTag.id &&
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
<MythicDialogButton intent="primary" onClick={onSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
}
|
||||
|
||||
</DialogActions>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -565,7 +679,8 @@ export function NewTagDialog(props) {
|
||||
const [newData, setNewData] = React.useState("");
|
||||
const [selectedTagType, setSelectedTagType] = React.useState("");
|
||||
const [existingTagTypes, setExistingTagTypes] = React.useState([]);
|
||||
const { loading, error } = useQuery(getTagtypesQuery, {
|
||||
const [openNewTagTypeDialog, setOpenNewTagTypeDialog] = React.useState(false);
|
||||
useQuery(getTagtypesQuery, {
|
||||
onCompleted: data => {
|
||||
setExistingTagTypes(data.tagtype);
|
||||
if(data.tagtype.length > 0){
|
||||
@@ -576,9 +691,14 @@ export function NewTagDialog(props) {
|
||||
});
|
||||
const [newTag] = useMutation(createNewTagMutationTemplate({target_object: props.target_object}), {
|
||||
onCompleted: data => {
|
||||
snackActions.success("Successfully created new tag!");
|
||||
props.onSubmit({source:newSource, url:newURL, data:newData, tagtype_id:selectedTagType.id, id: data.insert_tag_one.id});
|
||||
props.onClose()
|
||||
if(data.createTag.status === "success"){
|
||||
snackActions.success("Successfully created new tag!");
|
||||
props.onSubmit({source:newSource, url:newURL, data:newData, tagtype_id:selectedTagType.id, id: data.createTag.id, tagtype: selectedTagType});
|
||||
props.onClose()
|
||||
} else {
|
||||
snackActions.error(data.createTag.error);
|
||||
}
|
||||
|
||||
},
|
||||
onError: error => {
|
||||
snackActions.error(error.message);
|
||||
@@ -587,11 +707,16 @@ export function NewTagDialog(props) {
|
||||
const handleTaskTypeChange = (evt) => {
|
||||
setSelectedTagType(evt.target.value);
|
||||
}
|
||||
const onNewTagType = ({name, description, id, color}) => {
|
||||
const newTagType = {name, description, id, color};
|
||||
setExistingTagTypes((prev) => [...prev, newTagType].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedTagType(newTagType);
|
||||
}
|
||||
const onSubmit = () => {
|
||||
newTag({variables:
|
||||
{source:newSource, url:newURL, data:newData,
|
||||
tagtype_id:selectedTagType.id,
|
||||
[props.target_object]: props.target_object_id
|
||||
[getCreateTagTargetObject(props.target_object)]: props.target_object_display_id || props.target_object_id
|
||||
}})
|
||||
}
|
||||
const onChangeSource = (name, value, error) => {
|
||||
@@ -606,93 +731,108 @@ export function NewTagDialog(props) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Add New Tag</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<TableCell style={{width: "20%"}}>
|
||||
<Typography>
|
||||
Tag
|
||||
</Typography>
|
||||
<Typography size="small" component="span" style={{fontSize: theme.typography.pxToRem(15)}}>
|
||||
To create a new tag type click <Link style={{wordBreak: "break-all"}}
|
||||
color="textPrimary"
|
||||
href={"/new/tagtypes"}
|
||||
underline="always" target="_blank">
|
||||
here
|
||||
</Link>
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selectedTagType}
|
||||
onChange={handleTaskTypeChange}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
>
|
||||
{existingTagTypes.map( (opt) => (
|
||||
<MenuItem value={opt} key={opt.name}>
|
||||
<Chip label={opt.name} size="small" style={{ backgroundColor:opt.color}} />
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>External URL</TableCell>
|
||||
<TableCell>
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL}</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<TableCell>JSON Data</TableCell>
|
||||
<TableCell>
|
||||
<AceEditor
|
||||
{openNewTagTypeDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openNewTagTypeDialog}
|
||||
onClose={()=>{setOpenNewTagTypeDialog(false);}}
|
||||
innerDialog={
|
||||
<NewTagtypesDialog
|
||||
onClose={()=>{setOpenNewTagTypeDialog(false);}}
|
||||
onSubmit={onNewTagType}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
<MythicDraggableDialogTitle >Add New Tag</MythicDraggableDialogTitle>
|
||||
<DialogContent dividers={true} style={{width: "100%"}}>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Tag Type"
|
||||
description="Select the taxonomy entry this tag should use."
|
||||
actions={
|
||||
<Button className="mythic-compact-action bg-surface-raised border text-primary text-xs font-750 mythic-action-tone-hover mythic-tone-info rounded" size="small" variant="outlined" onClick={() => setOpenNewTagTypeDialog(true)}>
|
||||
Manage Tag Types
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<MythicFormField label="Tag">
|
||||
<Select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={selectedTagType}
|
||||
onChange={handleTaskTypeChange}
|
||||
displayEmpty
|
||||
renderValue={(tagtype) => tagtype?.id ? <TagTypeChip tagtype={tagtype} /> : "No tag types available"}
|
||||
>
|
||||
{existingTagTypes.map( (opt) => (
|
||||
<MenuItem value={opt} key={opt.name}>
|
||||
<TagTypeChip tagtype={opt} />
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</MythicFormField>
|
||||
{selectedTagType?.description &&
|
||||
<MythicFormNote>{selectedTagType.description}</MythicFormNote>
|
||||
}
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="Reference Details">
|
||||
<MythicFormGrid minWidth="16rem">
|
||||
<MythicFormField label="Source">
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" showLabel={false} marginTop="0" marginBottom="0" />
|
||||
</MythicFormField>
|
||||
<MythicFormField label="External URL">
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" showLabel={false} marginTop="0" marginBottom="0" />
|
||||
{newURL &&
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no' sx={{display: "inline-flex", mt: 0.5, fontSize: "0.78rem"}}>
|
||||
Open reference
|
||||
</Link>
|
||||
}
|
||||
</MythicFormField>
|
||||
</MythicFormGrid>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="JSON Data">
|
||||
<Box className="mythic-tag-data-split gap-6 min-w-0 w-full grid">
|
||||
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
onChange={onEditorChange}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
maxLines={20}
|
||||
showPrintMargin={false}
|
||||
minLines={10}
|
||||
maxLines={18}
|
||||
highlightActiveLine={true}
|
||||
value={newData}
|
||||
width={"100%"}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false
|
||||
useWorker: false,
|
||||
wrap: true
|
||||
}}/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
<Box className="mythic-tag-data-preview-frame min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
|
||||
<RenderedTagDataPreview data={newData} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
{selectedTagType !== "" &&
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
<MythicDialogButton intent="primary" onClick={onSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
}
|
||||
|
||||
</DialogActions>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
export const ViewEditTags = ({target_object, target_object_id, me}) => {
|
||||
export const ViewEditTags = ({target_object, target_object_id, target_object_display_id}) => {
|
||||
const me = useReactiveVar(meState);
|
||||
const [openTagDialog, setOpenTagDialog] = React.useState(false);
|
||||
const toggleTagDialog = (event, open) => {
|
||||
if(event){
|
||||
@@ -703,13 +843,16 @@ export const ViewEditTags = ({target_object, target_object_id, me}) => {
|
||||
}
|
||||
return(
|
||||
<React.Fragment>
|
||||
<IconButton onClick={(e) => toggleTagDialog(e, true)} size="small" style={{display: "inline-block", float: "right"}}><LocalOfferOutlinedIcon /></IconButton>
|
||||
<MythicActionButton iconOnly onClick={(e) => toggleTagDialog(e, true)} size="small"
|
||||
style={{display: "inline-block", float: "right", padding: "0px"}}>
|
||||
<LocalOfferOutlinedIcon />
|
||||
</MythicActionButton>
|
||||
{openTagDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openTagDialog}
|
||||
onClose={(e)=>{toggleTagDialog(e, false)}}
|
||||
innerDialog={<ViewEditTagsDialog me={me} target_object={target_object} target_object_id={target_object_id} onClose={(e)=>{toggleTagDialog(e, false)}} />}
|
||||
innerDialog={<ViewEditTagsDialog me={me} target_object={target_object} target_object_id={target_object_id} target_object_display_id={target_object_display_id} onClose={(e)=>{toggleTagDialog(e, false)}} />}
|
||||
/>}
|
||||
</React.Fragment>
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {getReadableTextColor, isValidHexColor} from './MythicColorInput';
|
||||
import {MythicChip} from './MythicChip';
|
||||
|
||||
export const getTagReadableTextColor = (theme, color) => {
|
||||
return isValidHexColor(color) ? getReadableTextColor(color) : theme.palette.text.primary;
|
||||
}
|
||||
|
||||
export const TagTypeChip = ({tagtype, label, sx={}}) => {
|
||||
const theme = useTheme();
|
||||
const color = tagtype?.color || "";
|
||||
const textColor = getTagReadableTextColor(theme, color);
|
||||
return (
|
||||
<MythicChip
|
||||
color={color}
|
||||
label={label || tagtype?.name || "Tag"}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: color || "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: theme.borderColor,
|
||||
color: textColor,
|
||||
...sx,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {useState} 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';
|
||||
@@ -35,11 +35,10 @@ export function MythicTextEditDialog(props) {
|
||||
}}/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,27 +5,47 @@ import {useDebounce} from "../utilities/useDebounce";
|
||||
const PREFIX = 'MythicTextField';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`
|
||||
root: `${PREFIX}Div-root`,
|
||||
textFieldRoot: `${PREFIX}-root`
|
||||
};
|
||||
|
||||
const Root = styled('div')({
|
||||
const Root = styled('div')(({theme}) => ({
|
||||
[`&.${classes.root}`]: {
|
||||
'& input:valid + fieldset': {
|
||||
borderColor: 'grey',
|
||||
borderWidth: 1,
|
||||
|
||||
},
|
||||
}));
|
||||
|
||||
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
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ValidationTextField = TextField;
|
||||
'& textarea:focus + textarea + fieldset': {
|
||||
borderLeftWidth: 2,
|
||||
//padding: '4px !important', // override inline-style
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const MythicTextField = ({
|
||||
placeholder,
|
||||
@@ -33,19 +53,20 @@ const MythicTextField = ({
|
||||
validate,
|
||||
width,
|
||||
onChange,
|
||||
requiredValue,
|
||||
requiredValue=false,
|
||||
type = "text",
|
||||
onEnter,
|
||||
autoFocus,
|
||||
autoComplete,
|
||||
showLabel,
|
||||
autoComplete=false,
|
||||
showLabel = true,
|
||||
variant = "outlined",
|
||||
inline,
|
||||
marginBottom = "5px",
|
||||
value,
|
||||
disabled = false,
|
||||
marginTop = "0px",
|
||||
marginTop = "5px",
|
||||
InputProps = {},
|
||||
inputProps = {},
|
||||
inputLabelProps = {},
|
||||
multiline = false,
|
||||
maxRows = 10,
|
||||
@@ -54,6 +75,7 @@ const MythicTextField = ({
|
||||
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;
|
||||
@@ -66,10 +88,8 @@ const MythicTextField = ({
|
||||
const newValue = evt.target.value;
|
||||
// Update local state immediately for responsive UI
|
||||
setLocalValue({value: newValue, event: evt});
|
||||
setLocalError(validate ? validate(newValue) : false);
|
||||
};
|
||||
const checkError = () => {
|
||||
return validate ? validate(localValue.value) : false
|
||||
}
|
||||
const onKeyPress = (event) => {
|
||||
if(event.key === "Enter") {
|
||||
if(event.shiftKey){
|
||||
@@ -85,6 +105,9 @@ const MythicTextField = ({
|
||||
handleChange(event);
|
||||
}
|
||||
}
|
||||
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": "",}}>
|
||||
@@ -95,29 +118,30 @@ const MythicTextField = ({
|
||||
onChange={handleChange}
|
||||
color={"secondary"}
|
||||
onKeyDown={onKeyPress}
|
||||
label={showLabel === undefined ? name : showLabel ? name : undefined}
|
||||
label={showLabel ? name : undefined}
|
||||
autoFocus={autoFocus}
|
||||
variant={variant === undefined ? "outlined" : variant}
|
||||
variant={variant}
|
||||
data-lpignore={true}
|
||||
autoComplete={autoComplete === undefined ? "new-password" : (autoComplete ? "on" : "new-password")}
|
||||
disabled={disabled === undefined ? false : disabled}
|
||||
required={requiredValue ? requiredValue : false}
|
||||
autoComplete={resolvedAutoComplete}
|
||||
disabled={disabled}
|
||||
required={requiredValue}
|
||||
InputLabelProps={inputLabelProps}
|
||||
multiline={multiline ? multiline : false}
|
||||
multiline={multiline}
|
||||
maxRows={maxRows}
|
||||
error={checkError()}
|
||||
type={type === undefined ? "text" : type}
|
||||
error={localError}
|
||||
type={type}
|
||||
onWheel={ event => event.target.blur() }
|
||||
InputProps={{...InputProps, spellCheck: false}}
|
||||
helperText={checkError() ? errorText : helperText}
|
||||
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
|
||||
helperText={localError ? errorText : helperText}
|
||||
style={{
|
||||
padding:0,
|
||||
marginBottom: marginBottom ? marginBottom : "5px",
|
||||
marginTop: marginTop ? marginTop: "0px",
|
||||
marginTop: marginTop ? marginTop: "5px",
|
||||
display: inline ? "inline-block": "",
|
||||
}}
|
||||
classes={{
|
||||
root: classes.root
|
||||
root: classes.textFieldRoot
|
||||
}} />
|
||||
</Root>
|
||||
);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
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';
|
||||
@@ -9,14 +9,11 @@ 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 PREFIX = 'MythicTransferList';
|
||||
|
||||
export const classes = {
|
||||
button: `${PREFIX}-button`,
|
||||
divider: `${PREFIX}-divider`
|
||||
};
|
||||
|
||||
@@ -26,21 +23,10 @@ export const StyledDivider = styled(Divider)((
|
||||
}
|
||||
) => ({
|
||||
[`&.${classes.divider}`]: {
|
||||
backgroundColor: "rgb(100, 170, 204)",
|
||||
border: "2px solid rgba(100, 170, 204)"
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
border: `2px solid ${theme.palette.primary.main}`
|
||||
}
|
||||
}));
|
||||
export const StyledButton = styled(Button)((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`&.${classes.button}`]: {
|
||||
margin: theme.spacing(0.5, 0),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const CustomListElement = ({value, onClick, itemKey}) => {
|
||||
const displayValue = itemKey ? value[itemKey] : value.value;
|
||||
const labelId = `transfer-list-item-${displayValue}-label`;
|
||||
@@ -61,10 +47,9 @@ const CustomListElement = ({value, onClick, itemKey}) => {
|
||||
const CustomList = ({title, items, left, onClick, itemKey}) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader title={title} />
|
||||
<StyledDivider classes={{root: classes.divider}}/>
|
||||
<CardContent style={{flexGrow: 1, height: "100%", width: "100%", overflowY: "auto", padding: 0}}>
|
||||
<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}>
|
||||
@@ -80,8 +65,8 @@ const CustomList = ({title, items, left, onClick, itemKey}) => {
|
||||
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData, parentRightData, itemKey}) => {
|
||||
@@ -148,45 +133,49 @@ const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData,
|
||||
<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 style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
|
||||
<StyledButton
|
||||
<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"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={data.filter( x => x.checked && x.left).length === 0}
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
disabled={data.filter( x => x.checked && x.right).length === 0}
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
</MythicActionButton>
|
||||
<MythicActionButton
|
||||
colorMode="always"
|
||||
tone="primary"
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllLeft}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</StyledButton>
|
||||
</MythicActionButton>
|
||||
|
||||
</div>
|
||||
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", overflowY: "auto", flexDirection: "column" }}>
|
||||
@@ -243,14 +232,13 @@ export function MythicTransferListDialog(props) {
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,692 +0,0 @@
|
||||
import React from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import clsx from 'clsx';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
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 ManageAccountsTwoToneIcon from '@mui/icons-material/ManageAccountsTwoTone';
|
||||
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 { TopAppBarEventLogNotifications } from './TopAppBarEventLogNotifications';
|
||||
import { EventFeedNotifications } from './EventFeedNotifications';
|
||||
import HelpTwoToneIcon from '@mui/icons-material/HelpTwoTone';
|
||||
import PhoneCallbackIcon from '@mui/icons-material/PhoneCallback';
|
||||
import {ReactComponent as ReactLogo} from './mythic_red_small.svg';
|
||||
import SpaceDashboardTwoToneIcon from '@mui/icons-material/SpaceDashboardTwoTone';
|
||||
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 LayersTwoToneIcon from '@mui/icons-material/LayersTwoTone';
|
||||
import TableChartTwoToneIcon from '@mui/icons-material/TableChartTwoTone';
|
||||
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 KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import HeadsetTwoToneIcon from '@mui/icons-material/HeadsetTwoTone';
|
||||
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 CameraAltTwoToneIcon from '@mui/icons-material/CameraAltTwoTone';
|
||||
import {mythicUIVersion} from '../index';
|
||||
import {MythicStyledTooltip} from './MythicComponents/MythicStyledTooltip';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
import ThumbDownTwoTone from '@mui/icons-material/ThumbDownTwoTone';
|
||||
import { MythicDialog } from './MythicComponents/MythicDialog';
|
||||
import {MythicFeedbackDialog} from './MythicComponents/MythicFeedbackDialog';
|
||||
import LocalOfferTwoToneIcon from '@mui/icons-material/LocalOfferTwoTone';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import LightModeTwoToneIcon from '@mui/icons-material/LightModeTwoTone';
|
||||
import DarkModeTwoToneIcon from '@mui/icons-material/DarkModeTwoTone';
|
||||
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import {TopAppBarEventingNotifications} from "./TopAppBarEventingNotifications";
|
||||
import {useQuery, gql} from '@apollo/client';
|
||||
import VerifiedTwoToneIcon from '@mui/icons-material/VerifiedTwoTone';
|
||||
import ConfirmationNumberIcon from '@mui/icons-material/ConfirmationNumber';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
|
||||
|
||||
const PREFIX = 'TopAppBar';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`,
|
||||
title: `${PREFIX}-title`,
|
||||
hide: `${PREFIX}-hide`,
|
||||
drawer: `${PREFIX}-drawer`,
|
||||
drawerPaper: `${PREFIX}-drawerPaper`,
|
||||
drawerHeader: `${PREFIX}-drawerHeader`,
|
||||
listSubHeader: `${PREFIX}-listSubHeader`,
|
||||
appBar: `${PREFIX}-appBar`,
|
||||
appBarShift: `${PREFIX}-appBarShift`,
|
||||
nested: `${PREFIX}-nested`,
|
||||
mythicElement: `${PREFIX}-mythicElement`,
|
||||
menuButton: `${PREFIX}-menuButton`,
|
||||
};
|
||||
|
||||
const StyledAppBar = styled(AppBar)((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.hide}`]: {
|
||||
display: 'none',
|
||||
},
|
||||
[`& .${classes.menuButton}`]: {
|
||||
display: 'inline-block',
|
||||
},
|
||||
[`&.${classes.appBar}`]: {
|
||||
width: "100%",
|
||||
backgroundColor: theme.topAppBarColor,
|
||||
transition: theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
borderTopRightRadius: theme.palette.mode === "dark" ? "3px" : 0,
|
||||
borderTopLeftRadius: theme.palette.mode === "dark" ? "3px" : 0,
|
||||
position: "sticky",
|
||||
overflow: "hidden",
|
||||
maxHeight: "48px"
|
||||
},
|
||||
[`&.${classes.appBarShift}`]: {
|
||||
maxWidth: `calc(100% - ${drawerWidth}px)`,
|
||||
marginLeft: drawerWidth,
|
||||
transition: theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.easeOut,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
borderTopRightRadius: theme.palette.mode === "dark" ? "3px" : 0,
|
||||
borderTopLeftRadius: theme.palette.mode === "dark" ? "3px" : 0,
|
||||
},
|
||||
}));
|
||||
const StyledDrawer = styled(Drawer)((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.title}`]: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
|
||||
[`&.${classes.drawer}`]: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
},
|
||||
|
||||
[`& .${classes.drawerPaper}`]: {
|
||||
width: drawerWidth,
|
||||
},
|
||||
|
||||
[`& .${classes.drawerHeader}`]: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: theme.spacing(0, 1),
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
|
||||
[`& .${classes.listSubHeader}`]: {
|
||||
borderTopLeftRadius: "3px",
|
||||
borderTopRightRadius: "3px",
|
||||
},
|
||||
|
||||
[`& .${classes.nested}`]: {
|
||||
paddingLeft: theme.spacing(4),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
const drawerWidth = 240;
|
||||
const GET_SETTINGS = gql`
|
||||
query getGlobalSettings {
|
||||
getGlobalSettings {
|
||||
settings
|
||||
}
|
||||
}
|
||||
`;
|
||||
export function TopAppBar(props) {
|
||||
const theme = useTheme();
|
||||
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 [serverVersion, setServerVersion] = React.useState("...");
|
||||
const [serverName, setServerName] = React.useState("...");
|
||||
useQuery(GET_SETTINGS, {fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
setServerVersion(data.getGlobalSettings.settings["MYTHIC_SERVER_VERSION"]);
|
||||
setServerName(data.getGlobalSettings.settings["MYTHIC_GLOBAL_SERVER_NAME"]);
|
||||
}
|
||||
});
|
||||
const handleDrawerOpen = () => {
|
||||
menuOpen(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
menuOpen(false);
|
||||
};
|
||||
|
||||
const handleToggleGlobal = () => {
|
||||
setOpenGlobal(!openGlobal);
|
||||
}
|
||||
const handleToggleCreate = () => {
|
||||
setOpenCreate(!openCreate);
|
||||
}
|
||||
const handleToggleOperations = () => {
|
||||
setOpenOperations(!openOperations);
|
||||
}
|
||||
const handleToggleData = () => {
|
||||
setOpenData(!openData);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{me?.user?.current_operation_id ? (<EventFeedNotifications me={me} />) : null }
|
||||
<StyledAppBar className={clsx(classes.appBar, {[classes.appBarShift]: isOpen,})}>
|
||||
|
||||
{ me?.loggedIn ? (
|
||||
<Toolbar variant="dense" style={{width: "100%"}} >
|
||||
<IconButton
|
||||
edge="start"
|
||||
className={clsx(isOpen && classes.hide, !isOpen && classes.menuButton)}
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
onClick={handleDrawerOpen}
|
||||
>
|
||||
<MenuIcon fontSize={"large"} />
|
||||
</IconButton>
|
||||
<span style={{width: "100%"}}>
|
||||
<span className={'hideOnSmallWidth'}>
|
||||
<TopBarShortcuts />
|
||||
{me?.user?.current_operation_id === 0 ? (
|
||||
<Link style={{display: "inline-flex", alignItems: "center", paddingRight: "10px", color: "#f84d4d",
|
||||
fontWeight: "bold",}} to="/new/operations">
|
||||
{"NO OPERATION!"}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link style={{paddingRight: "10px", color: "white", textDecoration: "none", fontSize: "1em"}} to="/new/operations">
|
||||
{me?.user?.current_operation}
|
||||
{me?.user?.current_operation_complete &&
|
||||
<IconButton disabled>
|
||||
<VerifiedTwoToneIcon style={{padding: 0, color: "white"}} />
|
||||
</IconButton>
|
||||
}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<TopBarRightShortcuts me={me} toggleTheme={props.toggleTheme} serverName={serverName} />
|
||||
</Toolbar>
|
||||
) : null
|
||||
}
|
||||
</StyledAppBar>
|
||||
<StyledDrawer
|
||||
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>
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Home
|
||||
</ListSubheader>
|
||||
}>
|
||||
<Typography style={{marginLeft: "15px", fontSize: 12}}>
|
||||
<b>Mythic Version:</b> v{serverVersion}<br/>
|
||||
<b>UI Version:</b> v{mythicUIVersion}<br/>
|
||||
</Typography>
|
||||
|
||||
<ListItem button component={Link} to='/new' key={"home"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon ><SpaceDashboardTwoToneIcon fontSize={"large"} className="mythicElement" /></ListItemIcon>
|
||||
<ListItemText primary={"Dashboard / Home"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Operational Views
|
||||
</ListSubheader>
|
||||
}>
|
||||
<ListItem className={classes.listSubHeader} button component={Link} to='/new/callbacks' key={"callbacks"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PhoneCallbackIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Active Callbacks"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.listSubHeader} component={Link} to='/new/payloads' key={"payloads"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FontAwesomeIcon icon={faBiohazard} size="2x"/></ListItemIcon>
|
||||
<ListItemText primary={"Payloads"} />
|
||||
</ListItem>
|
||||
<ListItem button onClick={handleToggleData}>
|
||||
<ListItemIcon><SearchIcon fontSize={"large"} /></ListItemIcon>
|
||||
<ListItemText>Search</ListItemText>
|
||||
{openData ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openData} unmountOnExit>
|
||||
<List component="div" disablePadding style={{border: 0}}>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=callbacks&searchField=Host&search=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PhoneCallbackIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Callbacks"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=tasks&searchField=Command+and+Parameters&search=&taskStatus=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><AssignmentIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Tasks"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=payloads&searchField=Filename&search=&taskStatus=&c2=All+C2&payloadtype=All+Payload+Types' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FontAwesomeIcon size={"2x"} icon={faBiohazard} /></ListItemIcon>
|
||||
<ListItemText primary={"Payloads"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?searchField=Filename&tab=files&location=Downloads&host=&search=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><AttachmentIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Files"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?t?searchField=Account&tab=credentials&search=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><VpnKeyIcon fontSize={"large"} className="mythicElement" /></ListItemIcon>
|
||||
<ListItemText primary={"Credentials"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=keylogs&searchField=Host&search='onClick={handleDrawerClose}>
|
||||
<ListItemIcon><KeyboardIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Keylogs"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=artifacts&searchField=Host&search=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FingerprintIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Artifacts"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=tokens&searchField=Host&search=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><ConfirmationNumberIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Tokens"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=socks' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FontAwesomeIcon size={"2x"} icon={faSocks} /></ListItemIcon>
|
||||
<ListItemText primary={"Proxies"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=processes&searchField=Name&search=&host=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><AccountTreeIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Processes"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/search?tab=tags&searchField=TagType&search=&host=' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><LocalOfferTwoToneIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Tags"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
</Collapse>
|
||||
<ListItem button className={classes.listSubHeader} component={Link} to='/new/mitre' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><TableChartTwoToneIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"ATT&CK"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.listSubHeader} component={Link} to='/new/reporting' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><FontAwesomeIcon size={"2x"} icon={faFlagCheckered} /></ListItemIcon>
|
||||
<ListItemText primary={"Reporting"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.listSubHeader} component={Link} to='/new/tagtypes' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><LocalOfferTwoToneIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Tags"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.listSubHeader} component={Link} to='/new/eventing' onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PlayCircleFilledTwoToneIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Eventing"} />
|
||||
</ListItem>
|
||||
|
||||
</List>
|
||||
<List
|
||||
subheader={
|
||||
<ListSubheader className={classes.listSubHeader} component="div" id="nested-list-subheader">
|
||||
Global Configurations
|
||||
</ListSubheader>
|
||||
}>
|
||||
<ListItem button onClick={handleToggleGlobal}>
|
||||
<ListItemIcon><LayersTwoToneIcon fontSize={"large"} /></ListItemIcon>
|
||||
<ListItemText>Services</ListItemText>
|
||||
{openGlobal ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openGlobal} unmountOnExit >
|
||||
<List component="div" disablePadding style={{border: 0}}>
|
||||
<ListItem button className={classes.nested} target="_blank" component={Link} to='/jupyter' key={"jupyter"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><CodeIcon fontSize={"large"} 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 fontSize={"large"} 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 fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Consuming Services"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem button onClick={handleToggleCreate}>
|
||||
<ListItemIcon><PostAddIcon fontSize={"large"} /></ListItemIcon>
|
||||
<ListItemText>Create</ListItemText>
|
||||
{openCreate ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openCreate} unmountOnExit>
|
||||
<List component="div" disablePadding style={{border: 0}}>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/createpayload' key={"createpayload"} onClick={handleDrawerClose} state={{from: 'TopAppBar'}}>
|
||||
<ListItemIcon><FontAwesomeIcon size={"2x"} icon={faBiohazard} /></ListItemIcon>
|
||||
<ListItemText primary={"Create Payload"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/createwrapper' key={"createwrapper"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><PostAddIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Create Wrapper"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
<ListItem button onClick={handleToggleOperations}>
|
||||
<ListItemIcon><SupervisorAccountIcon fontSize={"large"} /></ListItemIcon>
|
||||
<ListItemText>Operation Config</ListItemText>
|
||||
{openOperations ? <ExpandLess /> : <ExpandMore />}
|
||||
</ListItem>
|
||||
<Collapse in={openOperations} unmountOnExit>
|
||||
<List component="div" disablePadding style={{border: 0}}>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/payloadtypes' key={"payloadtypes"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><HeadsetTwoToneIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Agents & C2"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/operations' key={"modifyoperations"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><EditIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"Modify Operations"} />
|
||||
</ListItem>
|
||||
<ListItem button className={classes.nested} component={Link} to='/new/browserscripts' key={"browserscripts"} onClick={handleDrawerClose}>
|
||||
<ListItemIcon><CodeIcon fontSize={"large"} className="mythicElement"/></ListItemIcon>
|
||||
<ListItemText primary={"BrowserScripts"} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</Collapse>
|
||||
</List>
|
||||
<Divider />
|
||||
</StyledDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBarShortcuts({}){
|
||||
return (
|
||||
<>
|
||||
<MythicStyledTooltip title="C2 Profiles and Payload Types" >
|
||||
<IconButton component={Link} to='/new/payloadtypes'
|
||||
color="inherit"
|
||||
size="large" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<HeadsetTwoToneIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Payloads" arrow >
|
||||
<IconButton component={Link} to='/new/payloads' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<FontAwesomeIcon size={"lg"} icon={faBiohazard} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Search Operation" arrow >
|
||||
<IconButton component={Link} to='/new/search?tab=callbacks&searchField=Host&search=' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<SearchIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Files" arrow >
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Filename&tab=files&location=Downloads'
|
||||
color="inherit"
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<AttachmentIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Artifacts" arrow >
|
||||
<IconButton
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
component={Link}
|
||||
to='/new/search?searchField=Artifact&tab=artifacts'
|
||||
color="inherit">
|
||||
<FingerprintIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Proxies" arrow >
|
||||
<IconButton component={Link} to='/new/search?tab=socks' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<FontAwesomeIcon size={"lg"} icon={faSocks} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Screenshots" arrow >
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Filename&tab=files&location=Screenshots'
|
||||
color="inherit"
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
>
|
||||
<CameraAltTwoToneIcon fontSize={"large"} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Credentials" arrow >
|
||||
<IconButton
|
||||
component={Link}
|
||||
to='/new/search?searchField=Account&tab=credentials'
|
||||
color="inherit"
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<VpnKeyIcon fontSize={"large"} className="mythicElement" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Active Callbacks" arrow >
|
||||
<IconButton component={Link} to='/new/callbacks' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<PhoneCallbackIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Reporting" arrow >
|
||||
<IconButton component={Link} to='/new/reporting' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<FontAwesomeIcon size={"lg"} icon={faFlagCheckered} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="MITRE ATT&CK" arrow >
|
||||
<IconButton component={Link} to='/new/mitre' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<TableChartTwoToneIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Operation Tags" arrow >
|
||||
<IconButton component={Link} to='/new/tagtypes' color="inherit" disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<LocalOfferTwoToneIcon fontSize={"large"} className="mythicElement"/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Eventing Scripts" arrow >
|
||||
<TopAppBarEventingNotifications />
|
||||
</MythicStyledTooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
function TopBarRightShortcuts({me, toggleTheme, serverName}){
|
||||
const theme = useTheme();
|
||||
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 [openFeedbackForm, setOpenFeedbackForm] = React.useState(false);
|
||||
const handleLogout = () => {
|
||||
menuOpen(false);
|
||||
FailedRefresh(true);
|
||||
}
|
||||
const handleMenu = (event) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = (evt) => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleDocumentationMenu = (event) => {
|
||||
setDocumentationAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleDocumentationClose = (evt) => {
|
||||
setDocumentationAnchorEl(null);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
|
||||
>
|
||||
<MenuItem disabled divider={true} style={{display: "block"}}>
|
||||
<Typography paragraph={true} variant={"caption"} style={{marginBottom: "0"}}>Server Name:</Typography>
|
||||
<Typography paragraph={true} variant={"body1"} style={{marginBottom: "0", fontWeight: 600}}>{serverName}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem divider={true} style={{display: "block"}} component={Link} to="/new/settings" onClick={handleClose} name="settings">
|
||||
<Typography paragraph={true} variant="caption" style={{marginBottom: "0"}}>Signed in as:</Typography>
|
||||
<Typography paragraph={true} variant="body1" style={{marginBottom: "0", fontWeight: 600}}> {me?.user?.username || "" } </Typography>
|
||||
</MenuItem>
|
||||
<MenuItem 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", }}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<MythicStyledTooltip title="Send Support Feedback">
|
||||
<ThumbDownTwoTone fontSize={"large"} 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"}}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}>
|
||||
<MythicStyledTooltip title="Help">
|
||||
<HelpTwoToneIcon fontSize={"large"} className="mythicElement"/>
|
||||
<KeyboardArrowDownIcon />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={documentationAnchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={Boolean(documentationAnchorEl)}
|
||||
onClose={handleDocumentationClose}
|
||||
>
|
||||
<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>
|
||||
<TopAppBarEventLogNotifications />
|
||||
<IconButton
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
ref={settingsRef}
|
||||
color="inherit"
|
||||
style={{float: "right", }}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true} >
|
||||
<MythicStyledTooltip title="Settings or Logout">
|
||||
<ManageAccountsTwoToneIcon fontSize={"large"} className="mythicElement" />
|
||||
<KeyboardArrowDownIcon />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
{theme.palette.mode === 'dark' &&
|
||||
<IconButton
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
onClick={toggleTheme}
|
||||
style={{float: "right", paddingRight: "0px", color: '#2f0e67'}}
|
||||
>
|
||||
<MythicStyledTooltip title="Change to Light Mode">
|
||||
<DarkModeTwoToneIcon fontSize={"large"} className="mythicElement" />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
}
|
||||
{theme.palette.mode === 'light' &&
|
||||
<IconButton
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
onClick={toggleTheme}
|
||||
style={{float: "right", paddingRight: "0px", color: '#eacc1b'}}
|
||||
>
|
||||
<MythicStyledTooltip title="Change to Dark Mode">
|
||||
<LightModeTwoToneIcon fontSize={"large"} className="mythicElement" />
|
||||
</MythicStyledTooltip>
|
||||
</IconButton>
|
||||
}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,73 +2,53 @@ import React from 'react';
|
||||
import {useSubscription, gql } from '@apollo/client';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconButton } from '@mui/material';
|
||||
import {MythicStyledTooltip} from "./MythicComponents/MythicStyledTooltip";
|
||||
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_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 1) {
|
||||
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 TopAppBarEventLogNotifications(props) {
|
||||
const [alerts, setAlerts] = React.useState(alertCount());
|
||||
const { loading, error } = useSubscription(SUB_Event_Logs, {
|
||||
onError: data => {
|
||||
console.error(data);
|
||||
},
|
||||
onData: ({data}) => {
|
||||
|
||||
const newAlertCount = data.data.operation_stream[0].alert_count;
|
||||
if(newAlertCount !== alerts){
|
||||
setAlerts(newAlertCount);
|
||||
alertCount(newAlertCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MythicStyledTooltip title="Event Feed">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
component={Link}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
to='/new/EventFeed'
|
||||
style={{float: "right"}}>
|
||||
{error ? (
|
||||
<Badge color="secondary" badgeContent={"X"}>
|
||||
<NotificationsActiveTwoToneIcon fontSize={"large"} />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeContent={alerts}
|
||||
color="error" max={99}
|
||||
sx={{marginTop: "3px"}}
|
||||
>
|
||||
<NotificationsActiveTwoToneIcon fontSize={"large"}
|
||||
style={{marginTop: "-3px"}}/>
|
||||
</Badge>
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
);
|
||||
}
|
||||
export function TopAppBarVerticalEventLogNotifications(props) {
|
||||
export function TopAppBarVerticalEventLogNotifications() {
|
||||
const theme = useTheme();
|
||||
const me = React.useContext(MeContext);
|
||||
const [alerts, setAlerts] = React.useState(alertCount());
|
||||
const { loading, error } = useSubscription(SUB_Event_Logs, {
|
||||
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 newAlertCount = data.data.operation_stream[0].alert_count;
|
||||
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);
|
||||
@@ -90,4 +70,3 @@ export function TopAppBarVerticalEventLogNotifications(props) {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import React from 'react';
|
||||
import {useSubscription, gql } from '@apollo/client';
|
||||
import Badge from '@mui/material/Badge';
|
||||
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconButton } from '@mui/material';
|
||||
import {snackActions} from './utilities/Snackbar';
|
||||
import {MythicStyledTooltip} from "./MythicComponents/MythicStyledTooltip";
|
||||
|
||||
const OperationEventingCounts = gql`
|
||||
subscription OperationEventingCounts {
|
||||
eventgroupinstance_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 50) {
|
||||
id
|
||||
end_timestamp
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function TopAppBarEventingNotifications(props) {
|
||||
const [totalRunning, setTotalRunning] = React.useState([]);
|
||||
const { loading, error, data } = useSubscription(OperationEventingCounts, {
|
||||
onData: ({data}) => {
|
||||
const newRunningCounts = data.data.eventgroupinstance_stream.reduce( (prev, cur) => {
|
||||
let existingIndex = prev.findIndex( e => e.id === cur.id);
|
||||
if(existingIndex >= 0){
|
||||
if(cur.end_timestamp !== null){
|
||||
prev.splice(existingIndex, 1);
|
||||
}
|
||||
return [...prev];
|
||||
}
|
||||
if(cur.end_timestamp === null){
|
||||
return [...prev, cur];
|
||||
}
|
||||
return [...prev];
|
||||
}, totalRunning);
|
||||
setTotalRunning(newRunningCounts);
|
||||
},
|
||||
onError: data => {
|
||||
console.error(data);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<MythicStyledTooltip title="Eventing Counts">
|
||||
<IconButton
|
||||
color="inherit"
|
||||
component={Link}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
to='/new/Eventing'>
|
||||
{error ? (
|
||||
<Badge color="secondary" badgeContent={"X"}>
|
||||
<PlayCircleFilledTwoToneIcon fontSize={"large"} />
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge badgeContent={totalRunning.length} color="warning">
|
||||
<PlayCircleFilledTwoToneIcon fontSize={"large"} />
|
||||
</Badge>
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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`
|
||||
@@ -135,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}
|
||||
@@ -145,6 +146,6 @@ export function BrowserScripts({me}){
|
||||
onSubmitEdit={onSubmitEdit} onRevert={onRevert} onSubmitNew={onSubmitCreateNewBrowserScript}
|
||||
/>
|
||||
|
||||
</React.Fragment>
|
||||
</MythicPageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +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 AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import Button from '@mui/material/Button';
|
||||
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", marginLeft: "5px", marginRight: "5px"}} variant={"elevation"}>
|
||||
<Typography variant="h5" style={{textAlign: "left", display: "inline-block", marginLeft: "20px"}}>
|
||||
Browser Scripts
|
||||
</Typography>
|
||||
<Button size="small" onClick={() => setOpenNewScriptDialog(true)} style={{float: "right", marginRight: "30px", color: "white"}}
|
||||
startIcon={<AddCircleIcon color={"success"} style={{backgroundColor: "white", borderRadius: "10px"}}/>} >
|
||||
New Script
|
||||
</Button>
|
||||
{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} />
|
||||
} />
|
||||
) : 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 className="mythicElement" style={{maxHeight: "90%"}}>
|
||||
<Table stickyHeader={true} 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: "5rem"}}>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}
|
||||
@@ -62,9 +135,8 @@ export function BrowserScriptsTable(props){
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
</TableContainer>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +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);
|
||||
@@ -22,27 +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="info"
|
||||
inputProps={{ 'aria-label': 'checkbox', "track": "white" }}
|
||||
name="Active"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MythicStyledTooltip title={props.payloadtype.name}>
|
||||
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "35px", height: "35px"}} />
|
||||
</MythicStyledTooltip>
|
||||
</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}
|
||||
@@ -56,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 {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 => {
|
||||
@@ -102,9 +152,12 @@ export function EditScriptDialog(props) {
|
||||
useEffect( () => {
|
||||
if(selectedCommand !== ""){
|
||||
getAvailableTasks({variables: {command_id: selectedCommand}})
|
||||
}else{
|
||||
setAvailableTasks([]);
|
||||
setSelectedTask('');
|
||||
}
|
||||
|
||||
}, [selectedCommand]);
|
||||
}, [selectedCommand, getAvailableTasks]);
|
||||
useEffect( () => {
|
||||
if(props.script !== undefined){
|
||||
try{
|
||||
@@ -137,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);
|
||||
@@ -148,34 +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();
|
||||
}
|
||||
@@ -183,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_id + " / " + 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,6 +1,4 @@
|
||||
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 List from '@mui/material/List';
|
||||
@@ -8,10 +6,14 @@ 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 {gql, useQuery} from '@apollo/client';
|
||||
import { CardContent } from '@mui/material';
|
||||
import {classes, StyledButton, StyledDivider} from '../../MythicComponents/MythicTransferList';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import {
|
||||
MythicDialogButton,
|
||||
MythicDialogBody,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection
|
||||
} from '../../MythicComponents/MythicDialogLayout';
|
||||
|
||||
const getCommandsQuery = gql`
|
||||
query GetCallbackDetails($callback_id: Int!) {
|
||||
@@ -67,15 +69,14 @@ const CustomList = ({title, items, handleToggle}) => {
|
||||
))
|
||||
}, [items, handleToggle]);
|
||||
return (
|
||||
<>
|
||||
<CardHeader className={classes.cardHeader} title={title}/>
|
||||
<StyledDivider classes={{root: classes.divider}}/>
|
||||
<CardContent style={{flexGrow: 1, overflowY: "auto", padding: 0}}>
|
||||
<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>
|
||||
</CardContent>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
export function AddRemoveCallbackCommandsDialog(props) {
|
||||
@@ -184,69 +185,77 @@ export function AddRemoveCallbackCommandsDialog(props) {
|
||||
}
|
||||
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 title={leftTitle} items={left} handleToggle={handleToggle}/>
|
||||
</div>
|
||||
<div style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
|
||||
<StyledButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllRight}
|
||||
disabled={left.length === 0}
|
||||
aria-label="move all right"
|
||||
>
|
||||
>>
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedRight}
|
||||
aria-label="move selected right"
|
||||
>
|
||||
>
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleCheckedLeft}
|
||||
aria-label="move selected left"
|
||||
>
|
||||
<
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
className={classes.button}
|
||||
onClick={handleAllLeft}
|
||||
disabled={right.length === 0}
|
||||
aria-label="move all left"
|
||||
>
|
||||
<<
|
||||
</StyledButton>
|
||||
|
||||
</div>
|
||||
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||
<CustomList title={rightTitle} items={right} handleToggle={handleToggle} />
|
||||
</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);
|
||||
});
|
||||
});
|
||||
@@ -35,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
|
||||
}
|
||||
@@ -44,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
|
||||
}
|
||||
@@ -95,6 +95,9 @@ export const taskingDataFragment = gql`
|
||||
fragment taskData on task {
|
||||
comment
|
||||
parent_task_id
|
||||
agent_task_id
|
||||
tasking_location
|
||||
apitokens_id
|
||||
callback {
|
||||
display_id
|
||||
id
|
||||
@@ -104,6 +107,7 @@ export const taskingDataFragment = gql`
|
||||
domain
|
||||
ip
|
||||
mythictree_groups
|
||||
pid
|
||||
}
|
||||
callback_id
|
||||
commentOperator{
|
||||
@@ -144,14 +148,24 @@ export const taskingDataFragment = gql`
|
||||
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
|
||||
}
|
||||
@@ -164,6 +178,7 @@ export const taskingDataFragment = gql`
|
||||
domain
|
||||
ip
|
||||
mythictree_groups
|
||||
pid
|
||||
}
|
||||
completed
|
||||
subtask_group_name
|
||||
@@ -179,6 +194,9 @@ export const taskingDataFragment = gql`
|
||||
cmd
|
||||
supported_ui_features
|
||||
id
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
}
|
||||
command_name
|
||||
response_count
|
||||
@@ -210,8 +228,8 @@ export const taskingDataFragment = gql`
|
||||
}
|
||||
`;
|
||||
export const createTaskingMutation = gql`
|
||||
mutation createTasking($callback_id: Int, $callback_ids: [Int], $command: String!, $params: String!, $files: [String], $token_id: Int, $tasking_location: String, $original_params: String, $parameter_group_name: String, $parent_task_id: Int, $is_interactive_task: Boolean, $interactive_task_type: Int, $payload_type: String) {
|
||||
createTask(callback_id: $callback_id, callback_ids: $callback_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_id: $parent_task_id, is_interactive_task: $is_interactive_task, interactive_task_type: $interactive_task_type, payload_type: $payload_type) {
|
||||
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
|
||||
@@ -219,8 +237,8 @@ mutation createTasking($callback_id: Int, $callback_ids: [Int], $command: String
|
||||
}
|
||||
`;
|
||||
export const exportCallbackConfigQuery = gql`
|
||||
query exportCallbackConfigQuery($agent_callback_id: String!) {
|
||||
exportCallbackConfig(agent_callback_id: $agent_callback_id) {
|
||||
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,9 +1,6 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React, { useEffect } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { CallbacksTabs } from './CallbacksTabs';
|
||||
import SpeedDial from '@mui/material/SpeedDial';
|
||||
import SpeedDialIcon from '@mui/material/SpeedDialIcon';
|
||||
import SpeedDialAction from '@mui/material/SpeedDialAction';
|
||||
import TocIcon from '@mui/icons-material/Toc';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { CallbacksTop } from './CallbacksTop';
|
||||
@@ -12,46 +9,10 @@ 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';
|
||||
|
||||
const PREFIX = 'Callbacks';
|
||||
|
||||
const classes = {
|
||||
root: `${PREFIX}-root`,
|
||||
speedDial: `${PREFIX}-speedDial`,
|
||||
speedDialAction: `${PREFIX}-speedDialAction`,
|
||||
tooltip: `${PREFIX}-tooltip`,
|
||||
arrow: `${PREFIX}-arrow`
|
||||
};
|
||||
|
||||
const StyledSpeedDial = styled(SpeedDial)(({theme}) => ({
|
||||
[`&.${classes.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),
|
||||
},
|
||||
zIndex: 4,
|
||||
},
|
||||
|
||||
[`& .${classes.speedDialAction}`]: {
|
||||
backgroundColor: theme.palette.speedDialAction,
|
||||
},
|
||||
|
||||
[`& .${classes.tooltip}`]: {
|
||||
backgroundColor: theme.palette.background.contrast,
|
||||
color: theme.palette.text.contrast,
|
||||
boxShadow: theme.shadows[1],
|
||||
fontSize: 13,
|
||||
},
|
||||
|
||||
[`& .${classes.arrow}`]: {
|
||||
color: theme.palette.background.contrast,
|
||||
}
|
||||
}));
|
||||
export const getCallbackIdFromClickedTab = (tabId) => {
|
||||
if(tabId === null || tabId === undefined){return 0}
|
||||
if(tabId === ""){return 0}
|
||||
@@ -59,8 +20,12 @@ export const getCallbackIdFromClickedTab = (tabId) => {
|
||||
return Number(tabId.split("fileBrowser")[0]);
|
||||
}else if(tabId.includes("interact")){
|
||||
return Number(tabId.split("interact")[0]);
|
||||
}else if(tabId.includes("processBrowser")){
|
||||
}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;
|
||||
@@ -68,7 +33,10 @@ export const getCallbackIdFromClickedTab = (tabId) => {
|
||||
}
|
||||
|
||||
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, setClickedTabIdValue] = React.useState('');
|
||||
const openTabRef = React.useRef([]);
|
||||
@@ -85,6 +53,7 @@ export function Callbacks({me}) {
|
||||
}
|
||||
}
|
||||
setClickedTabIdValue(tabID);
|
||||
setNewDataForTab((prev) => {return {...prev, [tabID]: false}});
|
||||
}
|
||||
useEffect(() => {
|
||||
const oldTabs = localStorage.getItem('openTabs');
|
||||
@@ -243,13 +212,66 @@ export function Callbacks({me}) {
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SpeedDialWrapper setTopDisplay={setTopDisplay} />
|
||||
<Split direction="vertical"
|
||||
sizes={callbackTableSplitSizes}
|
||||
minSize={[0,0]}
|
||||
onDragEnd={(sizes) => localStorage.setItem('callbackTableSplitSizes', JSON.stringify(sizes))}
|
||||
style={{ height: "100%" }}>
|
||||
<div className="bg-gray-base">
|
||||
<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}
|
||||
@@ -257,7 +279,7 @@ export function Callbacks({me}) {
|
||||
onOpenTabs={onOpenTabs.current}
|
||||
me={me} clickedTabId={clickedTabId}/>
|
||||
</div>
|
||||
<div className="bg-gray-mid">
|
||||
<div >
|
||||
<CallbacksTabs
|
||||
onCloseTab={onCloseTab}
|
||||
onEditTabDescription={onEditTabDescription}
|
||||
@@ -269,109 +291,11 @@ export function Callbacks({me}) {
|
||||
me={me}
|
||||
onDragEnd={onDragEnd}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
newDataForTab={newDataForTab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
/>
|
||||
</div>
|
||||
</Split>
|
||||
</>
|
||||
);
|
||||
}
|
||||
/*
|
||||
<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 [openCallbackImport, setOpenCallbackImport] = React.useState(false);
|
||||
const actions = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
icon: <TocIcon />,
|
||||
name: 'Table layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('table');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <AssessmentIcon />,
|
||||
name: 'Graph layout',
|
||||
onClick: () => {
|
||||
setTopDisplay('graph');
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <PhoneForwardedIcon />,
|
||||
name: "Import Callback",
|
||||
onClick: () => {
|
||||
setOpenCallbackImport(true);
|
||||
}
|
||||
}
|
||||
],
|
||||
[] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
{openCallbackImport &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openCallbackImport}
|
||||
onClose={()=>{setOpenCallbackImport(false);}}
|
||||
innerDialog={<ImportCallbackConfigDialog onClose={()=>{setOpenCallbackImport(false);}} />}
|
||||
/>
|
||||
}
|
||||
<StyledSpeedDial
|
||||
ariaLabel='SpeedDial example'
|
||||
className={classes.speedDial}
|
||||
icon={<SpeedDialIcon />}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
}}
|
||||
onOpen={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
FabProps={{
|
||||
color: 'info', size: "small", variant: "extended",
|
||||
sx: {
|
||||
height: "25px", minWidth: "unset", width: "25px"
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
style={{ marginTop:"10px", marginRight: "20px"}}
|
||||
direction='down'>
|
||||
{actions.map((action) => (
|
||||
<SpeedDialAction
|
||||
arrow
|
||||
className={classes.speedDialAction}
|
||||
key={action.name}
|
||||
TooltipClasses={{
|
||||
".MuiTooltip-tooltip": classes.tooltip,
|
||||
".MuiTooltip-tooltipArrow": classes.arrow,
|
||||
}}
|
||||
icon={action.icon}
|
||||
tooltipTitle={action.name}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
))}
|
||||
</StyledSpeedDial>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
const SpeedDialWrapper = React.memo(SpeedDialWrapperPreMemo);
|
||||
@@ -1,10 +1,8 @@
|
||||
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 {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import {useMutation } from '@apollo/client';
|
||||
import {hideCallbackMutation, removeEdgeMutation, addEdgeMutation} from './CallbackMutations';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
@@ -19,10 +17,10 @@ import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import {CallbackGraphEdgesContext, CallbacksContext, OnOpenTabContext} from './CallbacksTop';
|
||||
import {Dropdown, DropdownMenuItem} from "../../MythicComponents/MythicNestedMenus";
|
||||
import {CallbackGraphEdgesContext, CallbacksContext} from './CallbacksTop';
|
||||
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {operatorSettingDefaults} from "../../../cache";
|
||||
import {useTaskReferenceSubmitter} from './taskingReferences';
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_PADDING_TOP = 1;
|
||||
@@ -60,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 = ["display_id", "user", "host", "ip", "domain", "os", "process_name"];
|
||||
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(["display_id", "user"]);
|
||||
const [selectedGroupBy, setSelectedGroupBy] = React.useState("None");
|
||||
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 {
|
||||
@@ -85,120 +81,108 @@ const GraphViewOptions = ({viewConfig, setViewConfig}) => {
|
||||
useEffect( () => {
|
||||
setViewConfig({...viewConfig, group_by: selectedGroupBy});
|
||||
}, [selectedGroupBy])
|
||||
const handleDropdownToggle = (evt) => {
|
||||
evt.stopPropagation();
|
||||
setDropdownOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
const handleMenuItemClick = (event, click) => {
|
||||
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: "0px", marginLeft: "25px"}} 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, backgroundColor: theme.palette.background.paper}} >
|
||||
<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, backgroundColor: theme.palette.background.paper}}>
|
||||
<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)}
|
||||
{groupByOptions.map((name) => (
|
||||
<MenuItem
|
||||
key={name}
|
||||
value={name}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" className="mythic-callback-graph-options-field mythic-callback-graph-options-field-wide">
|
||||
<InputLabel id="callback-graph-label-components-label">Callback Labels</InputLabel>
|
||||
<Select
|
||||
labelId="callback-graph-label-components-label"
|
||||
id="callback-graph-label-components"
|
||||
multiple
|
||||
value={selectedComponentOptions}
|
||||
onChange={handleChange}
|
||||
input={<OutlinedInput id="select-callback-graph-label-components" label="Callback Labels" />}
|
||||
MenuProps={MenuProps}
|
||||
renderValue={(selected) => selected.join(", ")}
|
||||
>
|
||||
{name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
}
|
||||
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<Dropdown
|
||||
isOpen={dropdownAnchorRef.current}
|
||||
onOpen={setDropdownOpen}
|
||||
externallyOpen={dropdownOpen}
|
||||
menu={
|
||||
options.map((option, index) => (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
disabled={option.disabled}
|
||||
onClick={(event) => handleMenuItemClick(event, option.click)}
|
||||
>
|
||||
{option.name}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -249,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: ["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}) => {
|
||||
@@ -314,19 +299,20 @@ export function CallbacksGraph({onOpenTab}){
|
||||
}
|
||||
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){
|
||||
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){
|
||||
@@ -348,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);
|
||||
@@ -365,7 +344,13 @@ 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});
|
||||
@@ -392,6 +377,7 @@ export function CallbacksGraph({onOpenTab}){
|
||||
}
|
||||
return (
|
||||
<div style={{maxWidth: "100%", "overflow": "hidden", height: "100%"}}>
|
||||
{taskReferenceSubmitDialog}
|
||||
|
||||
{manuallyRemoveEdgeDialogOpen &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={manuallyRemoveEdgeDialogOpen}
|
||||
@@ -413,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 &&
|
||||
@@ -430,4 +416,3 @@ export function CallbacksGraph({onOpenTab}){
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+66
-50
@@ -1,24 +1,25 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
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 IconButton from '@mui/material/IconButton';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {reorder} from "../../MythicComponents/MythicDraggableList";
|
||||
import {MythicDraggablePortal, reorder} from "../../MythicComponents/MythicDraggableList";
|
||||
import {
|
||||
Draggable,
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
} from "@hello-pangea/dnd";
|
||||
import { List, ListItem, ListItemText, Paper } from '@mui/material';
|
||||
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 theme = useTheme();
|
||||
const onDragEnd = ({ destination, source }) => {
|
||||
// dropped outside the list
|
||||
if (!destination) return;
|
||||
@@ -26,13 +27,12 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
|
||||
setItems(newItems);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
const newItems = items.map( c => {
|
||||
setItems((currentItems) => currentItems.map( c => {
|
||||
if(visible.includes(c.name)){
|
||||
return {...c, visible: true};
|
||||
}
|
||||
return {...c, visible: false};
|
||||
})
|
||||
setItems(newItems);
|
||||
}));
|
||||
}, [visible, hidden]);
|
||||
const onToggleVisibility = (i) => {
|
||||
const newItems = items.map( (c, index) => {
|
||||
@@ -49,21 +49,28 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Drag and Drop to Adjust the Order and Toggle Visibility</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%", margin: 0, padding: 0, background: theme.palette.background.main}}>
|
||||
<DraggableList items={items} onToggleVisibility={onToggleVisibility} onDragEnd={onDragEnd} />
|
||||
<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>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onReset} variant="contained" color="warning">
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={onReset} intent="warning">
|
||||
Reset
|
||||
</Button>
|
||||
<Button onClick={onFinish} variant="contained" color="success">
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={onFinish} intent="primary">
|
||||
Save
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -73,46 +80,55 @@ export const DraggableList = ({ items, onDragEnd, onToggleVisibility }) => {
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="callback-table-column-list">
|
||||
{(provided) => (
|
||||
<List style={{border: 0, padding: 0}} ref={provided.innerRef} {...provided.droppableProps}>
|
||||
<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}
|
||||
</List>
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
export const DraggableListItem = ({ item, index, onToggleVisibility }) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Draggable draggableId={item.key} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<ListItem
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
component={Paper}
|
||||
disabled={!item.visible}
|
||||
sx={snapshot.isDragging ? { background: theme.palette.secondary.main } : {
|
||||
|
||||
}}
|
||||
>
|
||||
<DragHandleIcon style={{marginRight: "10px"}}/>
|
||||
<ListItemText primary={item.name} />
|
||||
<IconButton onClick={() => onToggleVisibility(index)} style={{float: "right", margin: 0, padding: 0}}>
|
||||
{item.visible ? (
|
||||
<VisibilityIcon color={"success"} />
|
||||
) : (
|
||||
<VisibilityOffIcon color={"error"} />
|
||||
)
|
||||
|
||||
}
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
)}
|
||||
{(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>
|
||||
);
|
||||
};
|
||||
|
||||
+75
-17
@@ -1,5 +1,5 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -10,15 +10,17 @@ 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 {HexColorInput, HexColorPicker} from 'react-colorful';
|
||||
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 = () => {
|
||||
if(color === "ffffff" || color === "000000"){
|
||||
const normalizedColor = color?.toLowerCase() || "";
|
||||
if(normalizedColor === "" || normalizedColor === "ffffff" || normalizedColor === "#ffffff" ||
|
||||
normalizedColor === "000000" || normalizedColor === "#000000"){
|
||||
props.onSubmit(comment, "");
|
||||
} else {
|
||||
props.onSubmit(comment, color);
|
||||
@@ -39,6 +41,11 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
}
|
||||
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>
|
||||
@@ -48,6 +55,7 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
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}
|
||||
@@ -55,29 +63,79 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
focus={true}
|
||||
onChange={onChange}
|
||||
setOptions={{
|
||||
useWorker: false
|
||||
tabSize: 4,
|
||||
useWorker: false,
|
||||
showInvisibles: false,
|
||||
}}
|
||||
/>
|
||||
<HexColorPicker color={color} onChange={setColor} style={{width: "100%"}} />
|
||||
<HexColorInput color={color} onChange={setColor} style={{width: "80%"}} />
|
||||
<Button onClick={()=>setColor("ffffff")} color={"success"}>
|
||||
Clear
|
||||
</Button>
|
||||
<Box sx={{width: "100%", height: 25, backgroundColor: color}} >
|
||||
<Typography style={{color: "white", display: "inline-block", marginRight: "10px"}}>DarkMode Text With Color Background</Typography>
|
||||
<Typography style={{color: "black", display: "inline-block"}}>LightMode Text With Color Background</Typography>
|
||||
<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>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
{props.onSubmit &&
|
||||
<Button onClick={onCommitSubmit} variant="contained" color="success">
|
||||
<MythicActionButton colorMode="always" onClick={onCommitSubmit} tone="success" variant="contained">
|
||||
Submit
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
-25
@@ -1,23 +1,26 @@
|
||||
import React, {useEffect} 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 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 {HexColorInput, HexColorPicker} from 'react-colorful';
|
||||
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) => {
|
||||
setComment(parseInt(value));
|
||||
if(isNaN(parseInt(value))){
|
||||
setComment(0);
|
||||
} else {
|
||||
setComment(parseInt(value));
|
||||
}
|
||||
}
|
||||
useEffect( () => {
|
||||
setComment(props.trigger_on_checkin_after_time);
|
||||
@@ -27,23 +30,73 @@ export function CallbacksTableEditTriggerOnCheckinDialog(props) {
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">{"Adjust this callback's trigger threshold"}</DialogTitle>
|
||||
<DialogContent dividers={true} style={{height: "100%"}}>
|
||||
<Typography>
|
||||
{"This adjusts how long, in minutes, this callback must not checkin before finally checking in to trigger an eventing workflow (trigger is callback_checkin). A zero value means never trigger."}
|
||||
</Typography>
|
||||
<MythicTextField autoFocus={true} onChange={onChange} type={"number"} value={comment} onEnter={onSubmit} name={"trigger threshold in minutes"} showLabel={false} />
|
||||
<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>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
{props.onSubmit &&
|
||||
<Button onClick={onSubmit} variant="contained" color="success">
|
||||
Submit
|
||||
</Button>
|
||||
<MythicDialogButton onClick={onSubmit} intent="primary">
|
||||
Save Threshold
|
||||
</MythicDialogButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import React, {useCallback, useEffect, useContext} from 'react';
|
||||
import {IconButton} from '@mui/material';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import { MythicDisplayTextDialog} from '../../MythicComponents/MythicDisplayTextDialog';
|
||||
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
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';
|
||||
@@ -15,25 +13,26 @@ import {
|
||||
} from './CallbackMutations';
|
||||
import {useMutation } from '@apollo/client';
|
||||
import SnoozeIcon from '@mui/icons-material/Snooze';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {faQuestion, faSkullCrossbones, faRobot} 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 { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import { MythicSelectFromRawListDialog } from '../../MythicComponents/MythicSelectFromListDialog';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import { areEqual } from 'react-window';
|
||||
import {CallbackGraphEdgesContext, OnOpenTabContext} from './CallbacksTop';
|
||||
import Moment from 'react-moment';
|
||||
import moment from 'moment';
|
||||
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
|
||||
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, callbackDropdown}) =>{
|
||||
const theme = useTheme();
|
||||
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown, onOpenTriggerDialog, onOpenUnlockDialog}) =>{
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const onOpenTab = useContext(OnOpenTabContext);
|
||||
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact"})
|
||||
@@ -100,44 +99,85 @@ export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown}) =>{
|
||||
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 style={{paddingRight: "5px"}}/>;
|
||||
let defaultInteractIcon = <KeyboardIcon fontSize="small" />;
|
||||
if(interactType === "interactSplit"){
|
||||
defaultInteractIcon = <VerticalSplitIcon style={{paddingRight: "5px"}}/>;
|
||||
defaultInteractIcon = <VerticalSplitIcon fontSize="small" />;
|
||||
} else if(interactType === "interactConsole"){
|
||||
defaultInteractIcon = <TerminalIcon style={{paddingRight: "5px"}}/>;
|
||||
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}`} style={{display: "inline-flex", alignItems: "flex-start"}}>
|
||||
<IconButton style={{padding: 0, margin: 0}} color={rowDataStatic.integrity_level > 2 ? "error" : ""}
|
||||
onClick={(evt) => {evt.stopPropagation();localOnOpenTab()}}
|
||||
>
|
||||
{rowDataStatic.locked ? (<LockIcon style={{marginRight: "10px"}} />):(defaultInteractIcon)}
|
||||
</IconButton>
|
||||
{rowDataStatic.display_id}
|
||||
<IconButton
|
||||
style={{margin: 0, padding: 0}}
|
||||
color={rowDataStatic.integrity_level > 2 ? "error" : ""}
|
||||
aria-haspopup="menu"
|
||||
onClick={handleDropdownToggle}
|
||||
ref={dropdownAnchorRef}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
</IconButton>
|
||||
<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>
|
||||
|
||||
{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`}>
|
||||
<NotificationsActiveTwoToneIcon color={"success"}/>
|
||||
<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()}>
|
||||
<FontAwesomeIcon icon={faSocks} size="lg" style={{color: theme.palette.success.main}}/>
|
||||
<MythicStatusChip compact icon={<FontAwesomeIcon icon={faSocks} />} iconOnly label="Proxy active" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
areEqual)
|
||||
@@ -147,48 +187,41 @@ export const CallbacksTableStringCell = React.memo(({rowData, cellData}) => {
|
||||
)
|
||||
}, areEqual)
|
||||
export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me}) => {
|
||||
const adjustOutput = (newTime) => {
|
||||
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").subtract(me?.user?.server_skew || 0, 'millisecond').fromNow(true)
|
||||
}
|
||||
return moment(rowData.last_checkin + "Z", "YYYY-MM-DDTHH:mm:ss.SSSSSSZ").subtract(me?.user?.server_skew || 0, 'millisecond').fromNow(true);
|
||||
//return newTime;
|
||||
}
|
||||
const theme = useTheme();
|
||||
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
|
||||
return ""
|
||||
}
|
||||
if(rowData.last_checkin === "1970-01-01T00:00:00"){
|
||||
return (
|
||||
<>
|
||||
{rowData.dead &&
|
||||
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
|
||||
<FontAwesomeIcon disabled icon={faSkullCrossbones} style={{
|
||||
color: theme.palette.error.main, cursor: "pointer", marginRight: "10px",}} />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{"Streaming Now"}
|
||||
</>
|
||||
<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 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">
|
||||
{rowData.dead &&
|
||||
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
|
||||
<FontAwesomeIcon icon={faSkullCrossbones} style={{
|
||||
color: theme.palette.error.main, cursor: "pointer", marginRight: "10px",}} />
|
||||
<MythicStatusChip compact iconOnly status="dead" />
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
<Moment filter={adjustOutput} interval={1000} parse={"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"}
|
||||
<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>
|
||||
|
||||
)
|
||||
@@ -219,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) => {
|
||||
@@ -239,16 +273,14 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
}, [cellData]);
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "inline-flex", alignItems: "center", height: "100%"}}>
|
||||
<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", width: "unset"}} />
|
||||
</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);}}
|
||||
@@ -262,13 +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 onOpenTab = useContext(OnOpenTabContext);
|
||||
const [activeEgress, setActiveEgress] = React.useState(theme.palette.success.main);
|
||||
const [hasEgressRoute, setHasEgressRoute] = React.useState(true);
|
||||
const [openC2Dialog, setOpenC2Dialog] = React.useState(false);
|
||||
const [callbackgraphedges, setCallbackgraphedges] = React.useState([]);
|
||||
const [callbackgraphedgesAll, setCallbackgraphedgesAll] = React.useState([]);
|
||||
@@ -276,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, localRowData]);
|
||||
useEffect( () => {
|
||||
const getEdges = (activeOnly) => {
|
||||
//update our aggregate of callbackgraphedges for both src and dst that involve us
|
||||
@@ -341,21 +357,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
|
||||
setCallbackgraphedgesAll(myEdges);
|
||||
|
||||
}, [initialCallbackGraphEdges, localRowData]);
|
||||
useEffect( () => {
|
||||
//determine if there are any active routes left at all
|
||||
//console.log(localRowData.display_id, callbackgraphedges)
|
||||
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);
|
||||
}
|
||||
}, [callbackgraphedges, theme.palette.success.main, theme.palette.error.main]);
|
||||
useEffect( () => {
|
||||
if(rowData.id !== localRowData.id){
|
||||
setLocalRowData(rowData);
|
||||
@@ -364,12 +365,31 @@ export const CallbacksTableC2Cell = React.memo(({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}
|
||||
@@ -396,34 +416,35 @@ 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} style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faRobot} />
|
||||
}
|
||||
switch(rowData.payload.os.toLowerCase()){
|
||||
case "windows":
|
||||
return <FontAwesomeIcon icon={faWindows} 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} style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faLinux} />
|
||||
case "macos":
|
||||
return <FontAwesomeIcon icon={faApple} style={{cursor: "pointer"}} onClick={displayOSInfo}/>
|
||||
return <FontAwesomeIcon icon={faApple} />
|
||||
case "chrome":
|
||||
return <FontAwesomeIcon icon={faChrome} style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faChrome} />
|
||||
case "android":
|
||||
return <FontAwesomeIcon icon={faAndroid} style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faAndroid} />
|
||||
default:
|
||||
return <FontAwesomeIcon icon={faQuestion} style={{cursor: "pointer"}} onClick={displayOSInfo} />
|
||||
return <FontAwesomeIcon icon={faQuestion} />
|
||||
}
|
||||
}, [rowData?.payload?.os]);
|
||||
const displayOSInfo = React.useCallback( () => {
|
||||
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);}}
|
||||
@@ -439,8 +460,7 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
|
||||
|
||||
)
|
||||
}, areEqual);
|
||||
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo}) => {
|
||||
const theme = useTheme();
|
||||
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});
|
||||
@@ -453,9 +473,15 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div style={{height: "100%", display: "flex", alignItems: "center"}}>
|
||||
<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} maxWidth={"md"}
|
||||
@@ -474,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)
|
||||
@@ -10,14 +10,20 @@ import { CallbacksTabsFileBrowserLabel, CallbacksTabsFileBrowserPanel } from './
|
||||
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}) {
|
||||
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clickedTabId, setClickedTabId, onEditTabDescription, contextMenuOptions, me, newDataForTab, setNewDataForTab}) {
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -31,7 +37,9 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
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);
|
||||
}
|
||||
@@ -39,11 +47,13 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
};
|
||||
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]);
|
||||
@@ -65,99 +75,125 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
},]
|
||||
|
||||
return (
|
||||
<div style={{width: "100%", maxWidth: "100%", display: 'flex', flexDirection: 'column', flexGrow: 1, height: "100%" }}>
|
||||
<AppBar color='default' position={"static"} >
|
||||
<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}
|
||||
style={{ }}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
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}
|
||||
selectedIndex={value}
|
||||
onDragTab={onDragTab}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Tabs>
|
||||
<>
|
||||
<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>
|
||||
@@ -177,6 +213,7 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
@@ -196,6 +233,7 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
onCloseTab={onCloseTabLocal}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
@@ -214,12 +252,32 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
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
|
||||
@@ -232,10 +290,12 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
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':
|
||||
@@ -250,6 +310,7 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
onCloseTab={onCloseTabLocal}
|
||||
key={'tabpanel' + tab.tabID + tab.tabType}
|
||||
tabInfo={tab}
|
||||
setNewDataForTab={setNewDataForTab}
|
||||
value={value}
|
||||
index={index}
|
||||
me={me}
|
||||
@@ -261,5 +322,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</CallbackBrowserTreeProvider>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1001
File diff suppressed because it is too large.
Load diff
@@ -1,19 +1,19 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import { MythicTabPanel, MythicTabLabel } from '../../MythicComponents/MythicTabPanel';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import { gql, useLazyQuery, useQuery, useSubscription } from '@apollo/client';
|
||||
import { gql, useLazyQuery, useSubscription } from '@apollo/client';
|
||||
import { snackActions } from '../../utilities/Snackbar';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import MythicTextField from '../../MythicComponents/MythicTextField';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { CallbacksTabsFileBrowserTree } from './CallbacksTabsFileBrowserTree';
|
||||
import { CallbacksTabsFileBrowserTable } from './CallbacksTabsFileBrowserTable';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { MythicModifyStringDialog } from '../../MythicComponents/MythicDialog';
|
||||
import { Backdrop } from '@mui/material';
|
||||
import {Backdrop} from '@mui/material';
|
||||
import {CircularProgress} from '@mui/material';
|
||||
import {TaskFromUIButton} from './TaskFromUIButton';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
@@ -26,10 +26,11 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import {b64DecodeUnicode} from "./ResponseDisplay";
|
||||
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
|
||||
import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
|
||||
import {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
|
||||
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
|
||||
|
||||
const fileDataFragment = gql`
|
||||
fragment fileObjData on mythictree {
|
||||
@@ -47,10 +48,12 @@ const fileDataFragment = gql`
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
host
|
||||
id
|
||||
can_have_children
|
||||
has_children
|
||||
success
|
||||
full_path_text
|
||||
name_text
|
||||
@@ -65,14 +68,6 @@ const fileDataFragment = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
const rootFileQuery = gql`
|
||||
${fileDataFragment}
|
||||
query myRootFolderQuery {
|
||||
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: "file"} }) {
|
||||
...fileObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const folderQuery = gql`
|
||||
${fileDataFragment}
|
||||
query myFolderQuery($parent_path_text: String!, $parents: [String]) {
|
||||
@@ -96,18 +91,6 @@ const folderQuery = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
const fileDataSubscription = gql`
|
||||
${fileDataFragment}
|
||||
subscription liveData($now: timestamp!) {
|
||||
mythictree_stream(
|
||||
batch_size: 1000,
|
||||
cursor: {initial_value: {timestamp: $now}},
|
||||
where: {tree_type: {_eq: "file"} }
|
||||
) {
|
||||
...fileObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const fileBrowserTaskSub = gql`
|
||||
subscription fileBrowserTasks($now: timestamp!, $callback_id: Int!){
|
||||
task_stream(
|
||||
@@ -142,6 +125,15 @@ export const getAllParentNodes = (node) => {
|
||||
}
|
||||
return newNodes;
|
||||
}
|
||||
const mergeUniqueById = (existing = [], incoming = []) => [
|
||||
...new Map([...existing, ...incoming].map((item) => [item.id, item])).values(),
|
||||
];
|
||||
const mergeUniqueValues = (existing = [], incoming = []) => [
|
||||
...new Map([...existing, ...incoming].map((item) => [
|
||||
item && typeof item === "object" ? JSON.stringify(item) : `${typeof item}:${item}`,
|
||||
item,
|
||||
])).values(),
|
||||
];
|
||||
export function CallbacksTabsFileBrowserLabel(props) {
|
||||
const [description, setDescription] = React.useState('File Browser: ' + props.tabInfo.displayID);
|
||||
const [openEditDescriptionDialog, setOpenEditDescriptionDialog] = React.useState(false);
|
||||
@@ -173,7 +165,7 @@ export function CallbacksTabsFileBrowserLabel(props) {
|
||||
]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MythicTabLabel label={description} onDragTab={props.onDragTab} {...props} contextMenuOptions={contextMenuOptions} />
|
||||
<MythicTabLabel label={description} highlight={props.newDataForTab[props.tabInfo.tabID]} onDragTab={props.onDragTab} {...props} contextMenuOptions={contextMenuOptions} />
|
||||
{openEditDescriptionDialog && (
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
@@ -196,12 +188,28 @@ export function CallbacksTabsFileBrowserLabel(props) {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) => {
|
||||
export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNewDataForTab, baseUIFeature }) => {
|
||||
const fromNow = React.useRef(getSkewedNow());
|
||||
const active = index === value;
|
||||
const markInactiveChange = useCallback(() => {
|
||||
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {
|
||||
...previous,
|
||||
[tabInfo.tabID]: true,
|
||||
});
|
||||
}, [setNewDataForTab, tabInfo.tabID]);
|
||||
const {
|
||||
treeRootDataRef,
|
||||
treeAdjMtx,
|
||||
version: treeVersion,
|
||||
hydrated: treeHydrated,
|
||||
setTreeAdjMtx,
|
||||
} = useCallbackBrowserTree({
|
||||
treeType: "file",
|
||||
mode: "file",
|
||||
active,
|
||||
onInactiveChange: markInactiveChange,
|
||||
});
|
||||
const [backdropOpen, setBackdropOpen] = React.useState(false);
|
||||
const treeRootDataRef = React.useRef({}); // hold all of the actual data
|
||||
// hold the simple adjacency matrix for parent/child relationships
|
||||
const [treeAdjMtx, setTreeAdjMtx] = React.useState({});
|
||||
const [selectedFolderData, setSelectedFolderData] = React.useState({
|
||||
full_path_text: '',
|
||||
host: tabInfo.host,
|
||||
@@ -215,7 +223,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
token: 0,
|
||||
});
|
||||
const autoTaskLsOnEmptyDirectoriesRef = React.useRef(false);
|
||||
const taskingData = React.useRef({"parameters": "", "ui_feature": "file_browser:list"});
|
||||
const taskingData = React.useRef({"parameters": "", "ui_feature": baseUIFeature +":list"});
|
||||
const mountedRef = React.useRef(true);
|
||||
const tableOpenedPathIdRef = React.useRef({
|
||||
host: "",
|
||||
@@ -224,149 +232,39 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
});
|
||||
const loadedCommandsRef = React.useRef({});
|
||||
const loadingCommandsRef = React.useRef(false);
|
||||
useQuery(rootFileQuery, {
|
||||
onCompleted: (data) => {
|
||||
// use an adjacency matrix but only for full_path_text -> children, not both directions
|
||||
// create the top level data in the treeRootDataRef
|
||||
let defaultGroup = "Default";
|
||||
for(let i = 0; i < data.mythictree.length; i++){
|
||||
let currentGroups = data.mythictree[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
try{
|
||||
if(data.mythictree[i]?.['callback']?.['id'] === tabInfo.callbackID){
|
||||
if(!currentGroups.includes(defaultGroup, 0)){
|
||||
defaultGroup = currentGroups[0];
|
||||
}
|
||||
}
|
||||
}catch(error){
|
||||
console.log(error);
|
||||
}
|
||||
for(let j = 0; j < currentGroups.length; j++){
|
||||
if(treeRootDataRef.current[currentGroups[j]] === undefined){
|
||||
treeRootDataRef.current[currentGroups[j]] = {};
|
||||
}
|
||||
if( treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] === undefined) {
|
||||
// new host discovered
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] = {};
|
||||
}
|
||||
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] === undefined){
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]};
|
||||
} else {
|
||||
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].success === null){
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// create the top level data in the adjacency matrix
|
||||
const newMatrix = data.mythictree.reduce( (prev, cur) => {
|
||||
let currentGroups = cur?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
for(let j = 0; j < currentGroups.length; j++) {
|
||||
if (prev[currentGroups[j]] === undefined) {
|
||||
prev[currentGroups[j]] = {};
|
||||
}
|
||||
if (prev[currentGroups[j]][cur["host"]] === undefined) {
|
||||
// the current host isn't tracked in the adjacency matrix, so add it
|
||||
prev[currentGroups[j]][cur["host"]] = {}
|
||||
}
|
||||
if (prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] === undefined) {
|
||||
// the current parent's path isn't tracked, so add it and ourselves as children
|
||||
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] = {};
|
||||
}
|
||||
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]][cur["full_path_text"]] = 1;
|
||||
}
|
||||
return prev;
|
||||
}, {...treeAdjMtx});
|
||||
//console.log(treeRootDataRef.current);
|
||||
setTreeAdjMtx(newMatrix);
|
||||
setSelectedFolderData({
|
||||
full_path_text: '',
|
||||
host: tabInfo.host,
|
||||
group: defaultGroup,
|
||||
parent_path_text: "",
|
||||
});
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
useSubscription(fileDataSubscription, {
|
||||
variables: {now: fromNow.current},
|
||||
fetchPolicy: "no-cache",
|
||||
onData: ({data}) => {
|
||||
for(let i = 0; i < data.data.mythictree_stream.length; i++){
|
||||
let currentGroups = data.data.mythictree_stream[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
for(let j = 0; j < currentGroups.length; j++) {
|
||||
if (treeRootDataRef.current[currentGroups[j]] === undefined) {
|
||||
treeRootDataRef.current[currentGroups[j]] = {};
|
||||
}
|
||||
if (treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]] === undefined) {
|
||||
// new host discovered
|
||||
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]] = {};
|
||||
}
|
||||
if(treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] === undefined){
|
||||
// first time we're seeing this file data, just add it
|
||||
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] = {...data.data.mythictree_stream[i]};
|
||||
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]].filemeta = treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]].filemeta.map(f => {
|
||||
return {...f, filename_text: b64DecodeUnicode(f.filename_text)}
|
||||
})
|
||||
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === data.data.mythictree_stream[i]["host"] &&
|
||||
selectedFolderData.full_path_text === data.data.mythictree_stream[i]["full_path_text"]){
|
||||
setSelectedFolderData({...treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]],
|
||||
group: currentGroups[j], fromHistory: selectedFolderData.fromHistory});
|
||||
}
|
||||
} else {
|
||||
// we need to merge data in because we already have some info
|
||||
let existingData = treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]];
|
||||
if( (existingData.success === null || !existingData.success) && data.data.mythictree_stream[i].success !== null){
|
||||
existingData.success = data.data.mythictree_stream[i].success;
|
||||
existingData.task_id = data.data.mythictree_stream[i].task_id;
|
||||
}
|
||||
existingData.comment += data.data.mythictree_stream[i].comment;
|
||||
existingData.tags = [...existingData.tags, ...data.data.mythictree_stream[i].tags];
|
||||
if(!existingData.metadata.has_children){
|
||||
existingData.metadata.has_children = data.data.mythictree_stream[i].metadata.has_children;
|
||||
}
|
||||
existingData.metadata.permissions = {...existingData.metadata.permissions, ...data.data.mythictree_stream[i].metadata.permissions};
|
||||
let newfileData = data.data.mythictree_stream[i].filemeta.map(f => {
|
||||
return {...f, filename_text: b64DecodeUnicode(f.filename_text)};
|
||||
})
|
||||
existingData.filemeta = [...existingData.filemeta, ...newfileData]
|
||||
existingData.filemeta.sort((a,b) => a.id > b.id ? 1 : -1);
|
||||
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] = {...existingData};
|
||||
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === data.data.mythictree_stream[i]["host"] &&
|
||||
selectedFolderData.full_path_text === data.data.mythictree_stream[i]["full_path_text"]){
|
||||
setSelectedFolderData({...existingData, group: currentGroups[j], fromHistory: selectedFolderData.fromHistory});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const newMatrix = data.data.mythictree_stream.reduce( (prev, cur) => {
|
||||
let currentGroups = cur?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
for(let j = 0; j < currentGroups.length; j++) {
|
||||
if (prev[currentGroups[j]] === undefined) {
|
||||
prev[currentGroups[j]] = {};
|
||||
}
|
||||
if (prev[currentGroups[j]][cur["host"]] === undefined) {
|
||||
// the current host isn't tracked in the adjacency matrix, so add it
|
||||
prev[currentGroups[j]][cur["host"]] = {}
|
||||
}
|
||||
if (prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] === undefined) {
|
||||
// the current parent's path isn't tracked, so add it and ourselves as children
|
||||
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] = {};
|
||||
}
|
||||
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]][cur["full_path_text"]] = 1;
|
||||
}
|
||||
return prev;
|
||||
}, {...treeAdjMtx});
|
||||
setTreeAdjMtx(newMatrix);
|
||||
|
||||
}
|
||||
})
|
||||
const initializedSelectionRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if(!treeHydrated || initializedSelectionRef.current){return;}
|
||||
const selection = getDefaultBrowserSelection(
|
||||
treeRootDataRef.current,
|
||||
treeAdjMtx,
|
||||
tabInfo.callbackID,
|
||||
tabInfo.host,
|
||||
);
|
||||
setSelectedFolderData({
|
||||
full_path_text: '',
|
||||
host: selection.host,
|
||||
group: selection.group,
|
||||
parent_path_text: "",
|
||||
});
|
||||
initializedSelectionRef.current = true;
|
||||
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeVersion]);
|
||||
React.useEffect(() => {
|
||||
if(!active || !initializedSelectionRef.current){return;}
|
||||
setSelectedFolderData((previous) => {
|
||||
const current = treeRootDataRef.current[previous.group]?.[previous.host]?.[previous.full_path_text];
|
||||
if(!current){return previous;}
|
||||
return {...current, group: previous.group, fromHistory: previous.fromHistory};
|
||||
});
|
||||
}, [active, treeRootDataRef, treeVersion]);
|
||||
useSubscription(fileBrowserTaskSub, {
|
||||
variables: {now: fromNow.current, callback_id: tabInfo.callbackID},
|
||||
fetchPolicy: "no-cache",
|
||||
ignoreResults: true,
|
||||
onData: ({data}) => {
|
||||
for(let i = 0; i < data.data.task_stream.length; i++){
|
||||
if(data.data.task_stream[i].status.toLowerCase().includes("error") && data.data.task_stream[i].completed){
|
||||
snackActions.warning(<RenderSingleTask task_id={data.data.task_stream[i].id} />,
|
||||
snackActions.error(<RenderSingleTask task_id={data.data.task_stream[i].id} />,
|
||||
{toastId: data.data.task_stream[i].id, autoClose: false, closeOnClick: false});
|
||||
}
|
||||
}
|
||||
@@ -441,20 +339,34 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
} else {
|
||||
// we need to merge data in because we already have some info
|
||||
let existingData = treeRootDataRef.current[currentGroups[j]][mythictree[i]["host"]][mythictree[i]["full_path_text"]];
|
||||
if(existingData.task_id <= mythictree[i].task_id){
|
||||
existingData.deleted = mythictree[i].deleted;
|
||||
}
|
||||
if( (existingData.success === null || !existingData.success) && mythictree[i].success !== null){
|
||||
existingData.success = mythictree[i].success;
|
||||
existingData.task_id = mythictree[i].task_id;
|
||||
}
|
||||
existingData.comment += mythictree[i].comment;
|
||||
existingData.tags = [...existingData.tags, ...mythictree[i].tags];
|
||||
if(!existingData.metadata.has_children){
|
||||
existingData.metadata.has_children = mythictree[i].metadata.has_children;
|
||||
existingData.tags = mergeUniqueById(existingData.tags, mythictree[i].tags);
|
||||
existingData.has_children = mythictree[i].has_children || existingData.has_children;
|
||||
existingData.metadata.size = mythictree[i].metadata.size;
|
||||
existingData.metadata.access_time = mythictree[i].metadata.access_time;
|
||||
existingData.metadata.modify_time = mythictree[i].metadata.modify_time;
|
||||
//console.log("updating permissions", "existing", existingData.metadata.permissions, "new", mythictree[i].metadata)
|
||||
if(mythictree[i].metadata.permissions !== undefined && mythictree[i].metadata.permissions !== null){
|
||||
if(Array.isArray(existingData.metadata.permissions) && Array.isArray(mythictree[i].metadata.permissions)){
|
||||
existingData.metadata.permissions = mergeUniqueValues(existingData.metadata.permissions, mythictree[i].metadata.permissions);
|
||||
} else if(Array.isArray(existingData.metadata.permissions) !== Array.isArray(mythictree[i].metadata.permissions)) {
|
||||
// had array/dict and got mismatched array/dict, so just take newer one
|
||||
existingData.metadata.permissions = mythictree[i].metadata.permissions;
|
||||
} else {
|
||||
existingData.metadata.permissions = {...existingData.metadata.permissions, ...mythictree[i].metadata.permissions};
|
||||
}
|
||||
}
|
||||
existingData.metadata.permissions = {...existingData.metadata.permissions, ...mythictree[i].metadata.permissions};
|
||||
let newfileData = mythictree[i].filemeta.map(f => {
|
||||
return {...f, filename_text: b64DecodeUnicode(f.filename_text)};
|
||||
})
|
||||
existingData.filemeta = [...existingData.filemeta, ...newfileData]
|
||||
existingData.filemeta = mergeUniqueById(existingData.filemeta, newfileData)
|
||||
existingData.filemeta.sort((a,b) => a.id > b.id ? 1 : -1);
|
||||
treeRootDataRef.current[currentGroups[j]][mythictree[i]["host"]][mythictree[i]["full_path_text"]] = {...existingData};
|
||||
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === mythictree[i]["host"] &&
|
||||
@@ -548,7 +460,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
taskingData.current = ({
|
||||
"token": token,
|
||||
"parameters": {path: path, full_path: path, host: selectedFolderData.host, file: ""},
|
||||
"ui_feature": "file_browser:list", callback_id, callback_display_id});
|
||||
"ui_feature": baseUIFeature + ":list", callback_id, callback_display_id});
|
||||
setOpenTaskingButton(true);
|
||||
};
|
||||
const onListFilesButtonFromTableWithNoEntries = () => {
|
||||
@@ -560,7 +472,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
host: selectedFolderData.host,
|
||||
file: ""
|
||||
},
|
||||
"ui_feature": "file_browser:list",
|
||||
"ui_feature": baseUIFeature +":list",
|
||||
callback_id: tabInfo.callbackID,
|
||||
callback_display_id: tabInfo.displayID});
|
||||
setOpenTaskingButton(true);
|
||||
@@ -569,7 +481,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
taskingData.current = ({
|
||||
"token": token,
|
||||
"parameters": {path: fullPath, full_path: fullPath, host: selectedFolderData.host},
|
||||
"ui_feature": "file_browser:upload", "openDialog": true,
|
||||
"ui_feature": baseUIFeature + ":upload", "openDialog": true,
|
||||
callback_id, callback_display_id
|
||||
});
|
||||
setOpenTaskingButton(true);
|
||||
@@ -629,7 +541,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
taskingData.current = ({
|
||||
"token": localSelectedToken.current,
|
||||
"parameters": {path: nodeData.full_path_text, full_path: nodeData.full_path_text, host: nodeData.host, file: ""},
|
||||
"ui_feature": "file_browser:list", callback_id, callback_display_id});
|
||||
"ui_feature": baseUIFeature + ":list", callback_id, callback_display_id});
|
||||
setOpenTaskingButton(true);
|
||||
}
|
||||
const openDirectoryPath = ({group, host, path}) => {
|
||||
@@ -647,8 +559,8 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
}
|
||||
return (
|
||||
<MythicTabPanel index={index} value={value}>
|
||||
<Split direction="horizontal" style={{width: "100%", height: "100%", display: "flex", overflow: "hidden"}} sizes={[30, 70]} >
|
||||
<div className="bg-gray-base" style={{display: "inline-flex"}}>
|
||||
<Split direction="horizontal" className="mythic-eventing-split flex h-full min-h-0 min-w-0 overflow-hidden w-full" sizes={[30, 70]} >
|
||||
<div className="mythic-eventing-sidebar flex flex-column min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
@@ -663,13 +575,15 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
taskListing={taskListing}
|
||||
tableOpenedPathId={tableOpenedPathIdRef.current}
|
||||
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="bg-gray-light" style={{display: "inline-flex"}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
|
||||
<div style={{ flexGrow: 0 }}>
|
||||
<div className="mythic-eventing-content flex min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
|
||||
<div className="mythic-process-browser-table-shell flex flex-column h-full min-h-0 min-w-0 overflow-hidden w-full relative">
|
||||
<div>
|
||||
<FileBrowserTableTop
|
||||
active={active}
|
||||
tabInfo={tabInfo}
|
||||
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
|
||||
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
|
||||
@@ -681,13 +595,16 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
fetchParentFolderData={fetchParentFolderData}
|
||||
fetchFolderData={fetchFolderData}
|
||||
openDirectoryPath={openDirectoryPath}
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div className="mythic-process-browser-grid-shell flex-fill min-h-0 min-w-0 overflow-hidden">
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
<CallbacksTabsFileBrowserTable
|
||||
active={active}
|
||||
dataVersion={treeVersion}
|
||||
tabInfo={tabInfo}
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
onRowDoubleClick={fetchFolderData}
|
||||
@@ -700,6 +617,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
onTaskRowActions={onTaskRowActions}
|
||||
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
|
||||
me={me}
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -721,6 +639,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me }) =>
|
||||
);
|
||||
};
|
||||
const FileBrowserTableTop = ({
|
||||
active,
|
||||
selectedFolderData,
|
||||
taskingTableTopTypedDataRef,
|
||||
autoTaskLsOnEmptyDirectoriesRef,
|
||||
@@ -731,11 +650,12 @@ const FileBrowserTableTop = ({
|
||||
fetchParentFolderData,
|
||||
fetchFolderData,
|
||||
openDirectoryPath,
|
||||
tabInfo
|
||||
tabInfo,
|
||||
baseUIFeature
|
||||
}) => {
|
||||
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
|
||||
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
|
||||
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
|
||||
});
|
||||
}));
|
||||
const [updateMythicSetting] = useSetMythicSetting();
|
||||
const [fullPath, setFullPath] = React.useState('');
|
||||
const selectedToken = React.useRef("Default Token");
|
||||
@@ -764,9 +684,14 @@ const FileBrowserTableTop = ({
|
||||
}
|
||||
useSubscription(subscriptionCallbackTokens, {
|
||||
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
|
||||
skip: !active,
|
||||
ignoreResults: true,
|
||||
shouldResubscribe: true,
|
||||
onData: ({data}) => {
|
||||
setTokenOptions(data.data.callbacktoken);
|
||||
if(data.data.callbacktoken.length === 0) {
|
||||
onChangeSelectedToken("Default Token");
|
||||
}
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
@@ -789,16 +714,15 @@ const FileBrowserTableTop = ({
|
||||
return;
|
||||
}
|
||||
if(selectedFolderData.id !== ""){
|
||||
if(history[0]?.full_path_text !== selectedFolderData.full_path_text){
|
||||
// always add newest things to the bottom of the stack
|
||||
setHistory([selectedFolderData, ...history]);
|
||||
if(history.length > 20){
|
||||
// pop off from the top (the oldest) if we get more than 50
|
||||
setHistory([selectedFolderData, ...history.splice(history.length-1, 1)])
|
||||
setHistory((currentHistory) => {
|
||||
if(currentHistory[0]?.full_path_text === selectedFolderData.full_path_text){
|
||||
return currentHistory;
|
||||
}
|
||||
}
|
||||
return [selectedFolderData, ...currentHistory].slice(0, 20);
|
||||
});
|
||||
setHistoryIndex(0);
|
||||
}
|
||||
}, [selectedFolderData, history])
|
||||
}, [selectedFolderData])
|
||||
const moveIndexToPreviousListing = () => {
|
||||
// we're getting closer to the end of the historyRef.current, the oldest listing
|
||||
if(historyIndex >= history.length -1){
|
||||
@@ -844,7 +768,14 @@ const FileBrowserTableTop = ({
|
||||
}
|
||||
};
|
||||
const onToggleAutoTaskLsOnEmptyDirectories = () => {
|
||||
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
|
||||
const nextAutoTaskSetting = !autoTaskLsOnEmptyDirectories;
|
||||
setAutoTaskLsOnEmptyDirectories(nextAutoTaskSetting);
|
||||
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskSetting;
|
||||
updateMythicSetting({
|
||||
setting_name: "autoTaskLsOnEmptyDirectories",
|
||||
value: nextAutoTaskSetting,
|
||||
broadcast: false,
|
||||
});
|
||||
if(autoTaskLsOnEmptyDirectories){
|
||||
snackActions.info("No longer auto issuing listings for empty paths");
|
||||
} else {
|
||||
@@ -875,100 +806,106 @@ const FileBrowserTableTop = ({
|
||||
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
|
||||
}, [autoTaskLsOnEmptyDirectories]);
|
||||
return (
|
||||
<Grid container spacing={0} style={{ paddingTop: '10px' }}>
|
||||
<Grid item xs={12} >
|
||||
<Grid container spacing={0} className="mythic-file-browser-tableTop">
|
||||
<Grid size={12}>
|
||||
<MythicTextField
|
||||
placeholder={selectedFolderData.host}
|
||||
value={fullPath}
|
||||
onEnter={goToDirectory}
|
||||
onChange={onChangePath}
|
||||
name={placeHolder}
|
||||
marginTop="0"
|
||||
marginBottom="0"
|
||||
InputProps={{
|
||||
className: "mythic-file-browser-pathInput",
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupEnd">
|
||||
<MythicStyledTooltip title={`Task current callback (${tabInfo["displayID"]}) to list contents`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
onClick={onLocalListFilesButton}
|
||||
size="large">
|
||||
<RefreshIcon color='info' />
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
onClick={onLocalListFilesButton}
|
||||
size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Upload file to folder via current callback (${tabInfo["displayID"]})`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
onClick={onLocalUploadFileButton}
|
||||
size="large">
|
||||
<CloudUploadIcon color="info" />
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
onClick={onLocalUploadFileButton}
|
||||
size="small">
|
||||
<CloudUploadIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
{autoTaskLsOnEmptyDirectories ? (
|
||||
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size={"large"}>
|
||||
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" active colorMode="hover" shape="square" tone="success"
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size="small">
|
||||
<PlaylistAddIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
) : (
|
||||
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size={"large"}>
|
||||
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="warning"
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size="small">
|
||||
<PlaylistRemoveIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
)}
|
||||
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Files' : 'Show Deleted Files'}>
|
||||
<IconButton
|
||||
style={{ padding: '3px' }}
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" active={showDeletedFiles} colorMode="hover" shape="square" tone="warning"
|
||||
onClick={onLocalToggleShowDeletedFiles}
|
||||
size="large">
|
||||
size="small">
|
||||
{showDeletedFiles ? (
|
||||
<VisibilityIcon color="success" />
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon color="error" />
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
),
|
||||
startAdornment: (
|
||||
<div style={{display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRight: "1px solid grey",
|
||||
marginRight: "10px",
|
||||
padding: "0 5px 0 0"}}>
|
||||
{tokenOptions.length > 0 ? (
|
||||
<CallbacksTabsTaskingInputTokenSelect options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
|
||||
) : null}
|
||||
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupStart">
|
||||
{tokenOptions.length > 0 &&
|
||||
<div className="mythic-file-browser-tokenSelect overflow-hidden">
|
||||
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
|
||||
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
|
||||
</div>
|
||||
}
|
||||
<MythicStyledTooltip title={`Move back to previous listing`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
disabled={historyIndex >= history.length -1 }
|
||||
onClick={moveIndexToPreviousListing}
|
||||
color='info'
|
||||
size="large">
|
||||
<ArrowBackIcon />
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
disabled={historyIndex >= history.length -1 }
|
||||
onClick={moveIndexToPreviousListing}
|
||||
size="small">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Move to next listing`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="large"
|
||||
color='info'>
|
||||
<ArrowForwardIcon />
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="small">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={"Move up a directory"} >
|
||||
<IconButton style={{padding: "0 0 0 0"}}
|
||||
onClick={onLocalMoveUpDirectoryButton}
|
||||
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
onClick={onLocalMoveUpDirectoryButton}
|
||||
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
|
||||
>
|
||||
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
|
||||
</IconButton>
|
||||
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
|
||||
</div>),
|
||||
style: { },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import React, { useEffect } from 'react';
|
||||
import { useMutation, useLazyQuery, gql } from '@apollo/client';
|
||||
import { snackActions } from '../../utilities/Snackbar';
|
||||
@@ -12,7 +13,6 @@ 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 { IconButton } from '@mui/material';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import { DownloadHistoryDialog } from './DownloadHistoryDialog';
|
||||
@@ -26,17 +26,28 @@ import FileCopyOutlinedIcon from '@mui/icons-material/FileCopyOutlined';
|
||||
import { copyStringToClipboard } from '../../utilities/Clipboard';
|
||||
import MythicResizableGrid from '../../MythicComponents/MythicResizableGrid';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import {TableFilterDialog} from './TableFilterDialog';
|
||||
import {MythicTransferListDialog} from '../../MythicComponents/MythicTransferList';
|
||||
import {TagsDisplay, ViewEditTags} from '../../MythicComponents/MythicTag';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import { toLocalTime } from '../../utilities/Time';
|
||||
import {b64DecodeUnicode} from "./ResponseDisplay";
|
||||
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
|
||||
import {PreviewFileMediaDialog} from "../Search/PreviewFileMedia";
|
||||
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../../MythicComponents/MythicNestedMenus";
|
||||
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
|
||||
import {
|
||||
GetComputedFontSize,
|
||||
GetMythicSetting,
|
||||
useSetMythicSetting
|
||||
} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
|
||||
import {
|
||||
GridColumnFilterDialog,
|
||||
getUpdatedGridFilterOptions,
|
||||
gridValuePassesFilter,
|
||||
isGridColumnFilterActive
|
||||
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
|
||||
import {operatorSettingDefaults} from "../../../cache";
|
||||
|
||||
const getFileDownloadHistory = gql`
|
||||
query getFileDownloadHistory($full_path_text: String!, $host: String!, $group: [String!]) {
|
||||
@@ -75,30 +86,37 @@ const updateFileComment = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
const defaultVisibleColumns = ["Info", "Name", "Size", "Last Modify"];
|
||||
const columnDefaults = [
|
||||
{ name: 'Info', width: 65, disableDoubleClick: true, disableSort: true, disableFilterMenu: true, key: "info", visible: true },
|
||||
{ name: 'Name', type: 'string', key: 'name_text', fillWidth: true, visible: true },
|
||||
{ name: "Size", type: "size", key: "size", inMetadata: true, width: 100, visible: true},
|
||||
{ name: "Last Modify", type: "date", key: "modify_time", inMetadata: true, width: 250, visible: true},
|
||||
{ name: 'Tags', type: 'tags', disableSort: true, disableFilterMenu: true, width: 220, key: "tags", visible: false },
|
||||
{ name: 'Comment', type: 'string', key: 'comment', width: 200, visible: false },
|
||||
];
|
||||
export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
const theme = useTheme();
|
||||
const [updateSetting, updateSettings] = useSetMythicSetting();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [allData, setAllData] = React.useState([]);
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const [filterOptions, setFilterOptions] = React.useState({});
|
||||
const [selectedColumn, setSelectedColumn] = React.useState({});
|
||||
const [sortData, setSortData] = React.useState({"sortKey": null, "sortDirection": null, "sortType": null})
|
||||
const [columnVisibility, setColumnVisibility] = React.useState({
|
||||
"visible": ["Info", "Name", "Size", "Last Modify"],
|
||||
"visible": defaultVisibleColumns,
|
||||
"hidden": ["Tags", "Comment"]
|
||||
});
|
||||
const [openAdjustColumnsDialog, setOpenAdjustColumnsDialog] = React.useState(false);
|
||||
const [virtualizedTablePadding, setVirtualizedTablePadding] = React.useState(operatorSettingDefaults.virtualizedTablePadding);
|
||||
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
|
||||
const [columnOrder, setColumnOrder] = React.useState(columnDefaults);
|
||||
const [selectedRows, setSelectedRows] = React.useState([]);
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
{ name: 'Info', width: 65, disableDoubleClick: true, disableSort: true, disableFilterMenu: true },
|
||||
{ name: 'Name', type: 'string', key: 'name_text', fillWidth: true },
|
||||
{ name: "Size", type: "size", key: "size", inMetadata: true, width: 100},
|
||||
{ name: "Last Modify", type: "date", key: "modify_time", inMetadata: true, width: 250},
|
||||
{ name: 'Tags', type: 'tags', disableSort: true, disableFilterMenu: true, width: 220 },
|
||||
{ name: 'Comment', type: 'string', key: 'comment', width: 200 },
|
||||
].reduce( (prev, cur) => {
|
||||
columnOrder.reduce( (prev, cur) => {
|
||||
if(columnVisibility.visible.includes(cur.name)){
|
||||
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
|
||||
if(isGridColumnFilterActive(filterOptions[cur.key])){
|
||||
return [...prev, {...cur, filtered: true}];
|
||||
}else{
|
||||
return [...prev, {...cur}];
|
||||
@@ -107,7 +125,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
return [...prev];
|
||||
}
|
||||
}, [])
|
||||
, [filterOptions, columnVisibility]
|
||||
, [filterOptions, columnVisibility, columnOrder]
|
||||
);
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (sortData.sortKey === null || sortData.sortType === null) {
|
||||
@@ -187,35 +205,46 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
}
|
||||
return tempData;
|
||||
}, [allData, sortData]);
|
||||
const onSubmitFilterOptions = (newFilterOptions) => {
|
||||
setFilterOptions(newFilterOptions);
|
||||
const onSubmitFilterOptions = (value) => {
|
||||
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
|
||||
setFilterOptions(nextFilterOptions);
|
||||
try{
|
||||
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions, broadcast: false});
|
||||
}catch(error){
|
||||
console.log("failed to save filter options", error);
|
||||
}
|
||||
}
|
||||
const filterRow = (row) => {
|
||||
if(props.treeRootData[props.selectedFolderData.group] === undefined ||
|
||||
props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host] === undefined ||
|
||||
props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row] === undefined) {
|
||||
return true;
|
||||
}
|
||||
if(!props.showDeletedFiles && props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.deleted){
|
||||
return true;
|
||||
}
|
||||
for(const [key,value] of Object.entries(filterOptions)){
|
||||
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key]).toLowerCase().includes(value)){
|
||||
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key], value)){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const gridData = React.useMemo(
|
||||
() =>
|
||||
// row is just the name
|
||||
sortedData.reduce((prev, row) => {
|
||||
if(filterRow(row)){
|
||||
return [...prev];
|
||||
}else{
|
||||
return [...prev, columns.map( c => {
|
||||
const cachedGridDataRef = React.useRef([]);
|
||||
const gridData = React.useMemo(() => {
|
||||
if(props.active === false){return cachedGridDataRef.current;}
|
||||
const nextGridData = [];
|
||||
// row is just the name
|
||||
sortedData.forEach((row) => {
|
||||
if(!filterRow(row)){
|
||||
nextGridData.push(columns.map( c => {
|
||||
switch(c.name){
|
||||
case "Info":
|
||||
return <FileBrowserTableRowActionCell
|
||||
treeRootData={props.treeRootData[props.selectedFolderData.group]}
|
||||
selectedFolderData={props.selectedFolderData}
|
||||
rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]}
|
||||
cellData={row}
|
||||
cellData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.name_text}
|
||||
me={props.me}
|
||||
tabInfo={props.tabInfo}
|
||||
onTaskRowAction={props.onTaskRowAction} />;
|
||||
@@ -223,7 +252,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
return <FileBrowserTableRowNameCell
|
||||
treeRootData={props.treeRootData[props.selectedFolderData.group]}
|
||||
selectedFolderData={props.selectedFolderData}
|
||||
cellData={row}
|
||||
cellData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.name_text}
|
||||
rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]} />;
|
||||
case "Size":
|
||||
return <TableRowSizeCell cellData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.metadata?.size}
|
||||
@@ -242,20 +271,21 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
case "Comment":
|
||||
return <FileBrowserTableRowStringCell cellData={row.comment} rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]} />
|
||||
}
|
||||
})];
|
||||
}
|
||||
|
||||
}, []),
|
||||
[sortedData, props.onTaskRowAction, filterOptions, columnVisibility, props.showDeletedFiles]
|
||||
);
|
||||
}));
|
||||
}
|
||||
});
|
||||
cachedGridDataRef.current = nextGridData;
|
||||
return nextGridData;
|
||||
}, [columns, sortedData, props.active, props.dataVersion, props.onTaskRowAction, filterOptions,
|
||||
props.selectedFolderData, props.showDeletedFiles, props.treeRootData]);
|
||||
const getDisplayFormat = () => {
|
||||
if(sortedData.length === 0 && props?.selectedFolderData?.success === false){
|
||||
return "showTask";
|
||||
}else if(sortedData.length > 0 || props?.selectedFolderData?.success){
|
||||
return "normal";
|
||||
} else if(sortedData.length === 0 && props?.selectedFolderData?.metadata?.has_children && props?.selectedFolderData?.success === null){
|
||||
} else if(sortedData.length === 0 && props?.selectedFolderData?.has_children && props?.selectedFolderData?.success === null){
|
||||
return "fetchLocal";
|
||||
}else if(sortedData.length === 0 && !props?.selectedFolderData?.metadata?.has_children && props?.selectedFolderData?.success === null) {
|
||||
}else if(sortedData.length === 0 && !props?.selectedFolderData?.has_children && props?.selectedFolderData?.success === null) {
|
||||
return "fetchRemote";
|
||||
}else if(sortedData.length === 0){
|
||||
return "fetchRemote";
|
||||
@@ -265,6 +295,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
}
|
||||
const displayFormat = getDisplayFormat();
|
||||
useEffect(() => {
|
||||
if(props.active === false){return;}
|
||||
// when the folder changes, we need to aggregate all of the entries
|
||||
//console.log(props.selectedFolderData, props.treeAdjMatrix, props.treeRootData)
|
||||
let desiredPath = props.selectedFolderData.full_path_text;
|
||||
@@ -274,7 +305,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
let newAllData = Object.keys(props.treeAdjMatrix[props.selectedFolderData.group]?.[props.selectedFolderData.host]?.[desiredPath] || {});
|
||||
setAllData(newAllData);
|
||||
//console.log("just set all data")
|
||||
}, [props.selectedFolderData, props.treeAdjMatrix]);
|
||||
}, [props.active, props.dataVersion, props.selectedFolderData, props.treeAdjMatrix]);
|
||||
const onRowDoubleClick = (e, rowIndex, rowData) => {
|
||||
if (!rowData.can_have_children) {
|
||||
return;
|
||||
@@ -303,8 +334,12 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
};
|
||||
const contextMenuOptions = [
|
||||
{
|
||||
name: 'Filter Column',
|
||||
name: 'Filter Column', type: "item", icon: null,
|
||||
click: ({event, columnIndex}) => {
|
||||
if(event){
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
if(columns[columnIndex].disableFilterMenu){
|
||||
snackActions.warning("Can't filter that column");
|
||||
return;
|
||||
@@ -314,14 +349,96 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Show/Hide Columns",
|
||||
name: "Reorder Columns and Adjust Visibility",type: "item", icon: null,
|
||||
click: ({event, columnIndex}) => {
|
||||
setOpenAdjustColumnsDialog(true);
|
||||
if(event){
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
setOpenReorderDialog(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
const onSubmitAdjustColumns = ({left, right}) => {
|
||||
setColumnVisibility({visible: right, hidden: left});
|
||||
React.useEffect( () => {
|
||||
try {
|
||||
const storageItem = GetMythicSetting({setting_name: `file_browser_table_columns`, default_value: defaultVisibleColumns});
|
||||
if(storageItem !== null){
|
||||
let allColumns = [...columnVisibility["visible"].map(c => c), ...columnVisibility["hidden"].map(c => c)];
|
||||
let newHidden = [];
|
||||
allColumns.forEach((v,i,a) => {
|
||||
if(!storageItem.includes(v)){
|
||||
newHidden.push(v);
|
||||
}
|
||||
});
|
||||
if(storageItem.length !== 0){
|
||||
setColumnVisibility({visible: storageItem, hidden: newHidden});
|
||||
}
|
||||
}
|
||||
}catch(error){
|
||||
console.log("Failed to load custom browser_table_columns", error);
|
||||
}
|
||||
try {
|
||||
const storageItemOptions = GetMythicSetting({setting_name: `file_browser_filter_options`, default_value: {}});
|
||||
if(storageItemOptions !== null){
|
||||
setFilterOptions(storageItemOptions);
|
||||
}
|
||||
}catch(error){
|
||||
console.log("Failed to load custom browser_table_filter_options", error);
|
||||
}
|
||||
try {
|
||||
const storageColumnOrder = GetMythicSetting({setting_name: `file_browser_column_order`, default_value: columns.map(c => c.name)});
|
||||
if(storageColumnOrder !== null){
|
||||
let newOrder = [];
|
||||
for(let i = 0; i < storageColumnOrder.length; i++){
|
||||
for(let j = 0; j < columnOrder.length; j++){
|
||||
if(columnOrder[j].name === storageColumnOrder[i]){
|
||||
newOrder.push(columnOrder[j]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setColumnOrder(newOrder);
|
||||
}
|
||||
}catch(error){
|
||||
console.log("Failed to load file_browser_table_filter_options", error);
|
||||
}
|
||||
try {
|
||||
const storageItem = GetMythicSetting({setting_name: "virtualizedTablePadding", default_value: operatorSettingDefaults.virtualizedTablePadding});
|
||||
if(storageItem !== null){
|
||||
setVirtualizedTablePadding(parseInt(storageItem));
|
||||
}
|
||||
}catch(error){
|
||||
console.log("Failed to load virtualizedTablePadding", error);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
const onSubmitColumnReorder = (newOrder) => {
|
||||
let newVisible = [];
|
||||
let newHidden = [];
|
||||
for(let i = 0; i < newOrder.length; i++){
|
||||
if(newOrder[i].visible){
|
||||
newVisible.push(newOrder[i].name);
|
||||
} else {
|
||||
newHidden.push(newOrder[i].name);
|
||||
}
|
||||
}
|
||||
if(newVisible.length === 0){
|
||||
snackActions.error("Can't update to show no fields");
|
||||
return;
|
||||
}
|
||||
setColumnOrder(newOrder);
|
||||
setColumnVisibility({visible: newVisible, hidden: newHidden});
|
||||
updateSettings({
|
||||
settings: {
|
||||
file_browser_column_order: newOrder.map(c => c.name),
|
||||
file_browser_table_columns: newVisible,
|
||||
},
|
||||
broadcast: false,
|
||||
});
|
||||
setOpenReorderDialog(false);
|
||||
}
|
||||
const onResetColumnReorder = () => {
|
||||
onSubmitColumnReorder(columnDefaults);
|
||||
}
|
||||
const sortColumn = columns.findIndex((column) => column.key === sortData.sortKey);
|
||||
const onRowClick = ({event, rowDataStatic}) => {
|
||||
@@ -356,7 +473,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
copyStringToClipboard(element.name_text);
|
||||
if(copyStringToClipboard(element.name_text)){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -364,22 +483,26 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
copyStringToClipboard(element.full_path_text);
|
||||
if(copyStringToClipboard(element.full_path_text)){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Attributes', type: "item",
|
||||
name: 'Metadata', type: "item",
|
||||
icon: <FileCopyOutlinedIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
copyStringToClipboard(JSON.stringify(element?.metadata?.permissions, null, 2));
|
||||
if(copyStringToClipboard(JSON.stringify(element?.metadata?.permissions, null, 2))){
|
||||
snackActions.success("Copied to clipboard");
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
async function optionsB (callback_id, callback_display_id, element) {
|
||||
let downloadCommand = await props.getLoadedCommandForUIFeature(callback_id, element.can_have_children ? "file_browser:download_folder" : "file_browser:download");
|
||||
let downloadCommand = await props.getLoadedCommandForUIFeature(callback_id, element.can_have_children ? props.baseUIFeature + ":download_folder" : props.baseUIFeature + ":download");
|
||||
let downloadDisplay = "Download (Unsupported)";
|
||||
if(element.can_have_children){
|
||||
downloadDisplay = "Download Folder (Unsupported)";
|
||||
@@ -390,15 +513,21 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
downloadDisplay = `Download Folder (${downloadCommand.command.cmd})`;
|
||||
}
|
||||
}
|
||||
let listCommand = await props.getLoadedCommandForUIFeature(callback_id, "file_browser:list");
|
||||
let listCommand = await props.getLoadedCommandForUIFeature(callback_id, props.baseUIFeature + ":list");
|
||||
let listDisplay = "List (Unsupported)";
|
||||
if(listCommand !== undefined){
|
||||
listDisplay = `List (${listCommand.command.cmd})`;
|
||||
}
|
||||
let removeCommand = await props.getLoadedCommandForUIFeature(callback_id, "file_browser:remove");
|
||||
let removeCommand = await props.getLoadedCommandForUIFeature(callback_id, element.can_have_children ? props.baseUIFeature + ":remove_folder" : props.baseUIFeature + ":remove");
|
||||
let removeDisplay = "Remove (Unsupported)";
|
||||
if( element.can_have_children) {
|
||||
removeDisplay = "Remove Folder (Unsupported)"
|
||||
}
|
||||
if(removeCommand !== undefined){
|
||||
removeDisplay = `Remove (${removeCommand.command.cmd})`;
|
||||
if(element.can_have_children){
|
||||
removeDisplay = `Remove Folder (${removeCommand.command.cmd})`;
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
@@ -412,7 +541,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: element.full_path_text,
|
||||
host: element.host,
|
||||
filename: element.name_text,
|
||||
uifeature: element.can_have_children ? 'file_browser:download_folder' : 'file_browser:download',
|
||||
uifeature: element.can_have_children ? props.baseUIFeature + ':download_folder' : props.baseUIFeature + ':download',
|
||||
callback_id, callback_display_id
|
||||
});
|
||||
},
|
||||
@@ -427,14 +556,15 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: element.full_path_text,
|
||||
host: element.host,
|
||||
filename: element.name_text,
|
||||
uifeature: 'file_browser:list',
|
||||
uifeature: props.baseUIFeature + ':list',
|
||||
callback_id, callback_display_id
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: removeDisplay, type: "item", disabled: removeCommand === undefined,
|
||||
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
|
||||
danger: true,
|
||||
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
props.onTaskRowAction({
|
||||
@@ -442,7 +572,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: element.full_path_text,
|
||||
host: element.host,
|
||||
filename: element.name_text,
|
||||
uifeature: 'file_browser:remove',
|
||||
uifeature: element.can_have_children ? props.baseUIFeature + ':remove_folder' : props.baseUIFeature + ':remove',
|
||||
getConfirmation: true,
|
||||
callback_id, callback_display_id
|
||||
});
|
||||
@@ -467,7 +597,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: selectedRows[i].full_path_text,
|
||||
host: selectedRows[i].host,
|
||||
filename: selectedRows[i].name_text,
|
||||
uifeature: selectedRows[i].can_have_children ? 'file_browser:download_folder': 'file_browser:download',
|
||||
uifeature: selectedRows[i].can_have_children ? props.baseUIFeature + ':download_folder': props.baseUIFeature + ':download',
|
||||
callback_id: props.tabInfo.callbackID,
|
||||
callback_display_id: props.tabInfo.displayID
|
||||
});
|
||||
@@ -477,7 +607,8 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
},
|
||||
{
|
||||
name: `Remove All Selected`, type: "item",
|
||||
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
|
||||
danger: true,
|
||||
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
let newTasks = [];
|
||||
@@ -487,7 +618,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: selectedRows[i].full_path_text,
|
||||
host: selectedRows[i].host,
|
||||
filename: selectedRows[i].name_text,
|
||||
uifeature: 'file_browser:remove',
|
||||
uifeature: props.baseUIFeature + ':remove',
|
||||
getConfirmation: true,
|
||||
callback_id: props.tabInfo.callbackID,
|
||||
callback_display_id: props.tabInfo.displayID
|
||||
@@ -508,7 +639,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
full_path: selectedRows[i].full_path_text,
|
||||
host: selectedRows[i].host,
|
||||
filename: selectedRows[i].name_text,
|
||||
uifeature: 'file_browser:list',
|
||||
uifeature: props.baseUIFeature + ':list',
|
||||
callback_id: props.tabInfo.callbackID,
|
||||
callback_display_id: props.tabInfo.displayID
|
||||
});
|
||||
@@ -519,17 +650,32 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
]
|
||||
}
|
||||
let options = [...optionsA(rowDataStatic)];
|
||||
options.push(...await optionsB(props.tabInfo.callbackID, props.tabInfo.displayID, rowDataStatic));
|
||||
options.push(...(await optionsB(props.tabInfo.callbackID, props.tabInfo.displayID, rowDataStatic)));
|
||||
if(rowDataStatic.callback.display_id !== props.tabInfo.displayID){
|
||||
options.push({
|
||||
name: `Original Callback: ${rowDataStatic.callback.display_id}`, icon: null, click: () => {}, type: "menu",
|
||||
menuItems: [
|
||||
...await optionsB(rowDataStatic.callback.id, rowDataStatic.callback.display_id, rowDataStatic)
|
||||
...(await optionsB(rowDataStatic.callback.id, rowDataStatic.callback.display_id, rowDataStatic))
|
||||
]
|
||||
})
|
||||
}
|
||||
return options;
|
||||
}
|
||||
if(loading){
|
||||
return (
|
||||
<div style={{width: '100%', height: '100%', position: "relative",}}>
|
||||
<div style={{overflowY: "hidden", flexGrow: 1}}>
|
||||
<div style={{
|
||||
position: "absolute",
|
||||
left: "35%",
|
||||
top: "40%"
|
||||
}}>
|
||||
{"Loading Saved Browser Customizations..."}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{width: '100%', height: '100%', overflow: "hidden", position: "relative"}}>
|
||||
{displayFormat === "normal" &&
|
||||
@@ -538,7 +684,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
sortIndicatorIndex={sortColumn}
|
||||
sortDirection={sortData.sortDirection}
|
||||
items={gridData}
|
||||
rowHeight={20}
|
||||
rowHeight={GetComputedFontSize() + virtualizedTablePadding}
|
||||
onClickHeader={onClickHeader}
|
||||
onDoubleClickRow={onRowDoubleClick}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
@@ -553,17 +699,17 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
left: "35%",
|
||||
top: "40%",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid black",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: "5px",
|
||||
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
|
||||
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
|
||||
}}>
|
||||
{"Only PARTIAL data has been collected for this path. "}<br/>
|
||||
{"Task this callback to list the contents"}
|
||||
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}}
|
||||
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}}
|
||||
onClick={props.onListFilesButtonFromTableWithNoEntries}>
|
||||
<RefreshIcon color={"info"} fontSize={"large"}
|
||||
style={{display: "inline-block",}}/>
|
||||
</IconButton>
|
||||
</MythicActionButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -574,9 +720,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
left: "35%",
|
||||
top: "40%",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid black",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: "5px",
|
||||
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
|
||||
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
|
||||
}}>
|
||||
{"Some data exists for this path, but isn't loaded into the UI. "}
|
||||
<br/>
|
||||
@@ -591,17 +737,17 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
left: "35%",
|
||||
top: "40%",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid black",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: "5px",
|
||||
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
|
||||
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
|
||||
}}>
|
||||
{"No data has been collected for this path. "}
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
{"Task this callback to list the contents"}
|
||||
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
|
||||
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
|
||||
<RefreshIcon color={"info"} fontSize={"large"}
|
||||
style={{ display: "inline-block",}} />
|
||||
</IconButton>
|
||||
</MythicActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -610,26 +756,34 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
<RenderSingleTask task_id={props.selectedFolderData?.task_id} />
|
||||
}
|
||||
{openContextMenu &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xs" open={openContextMenu}
|
||||
onClose={() => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
innerDialog={<TableFilterDialog
|
||||
selectedColumn={selectedColumn}
|
||||
filterOptions={filterOptions}
|
||||
onSubmit={onSubmitFilterOptions}
|
||||
onClose={()=>{setOpenContextMenu(false);}} />}
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
|
||||
onClose={()=>{setOpenContextMenu(false);}}
|
||||
innerDialog={
|
||||
<GridColumnFilterDialog
|
||||
onSubmit={onSubmitFilterOptions}
|
||||
filterValue={filterOptions[selectedColumn.key]}
|
||||
selectedColumn={selectedColumn}
|
||||
onClose={() => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
{openAdjustColumnsDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={openAdjustColumnsDialog}
|
||||
onClose={()=>{setOpenAdjustColumnsDialog(false);}}
|
||||
innerDialog={
|
||||
<MythicTransferListDialog onClose={()=>{setOpenAdjustColumnsDialog(false);}}
|
||||
onSubmit={onSubmitAdjustColumns} right={columnVisibility.visible} rightTitle="Show these columns"
|
||||
leftTitle={"Hidden Columns"} left={columnVisibility.hidden} dialogTitle={"Edit which columns are shown"}/>}
|
||||
{openReorderDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openReorderDialog}
|
||||
onClose={()=>{setOpenReorderDialog(false);}}
|
||||
innerDialog={
|
||||
<CallbacksTableColumnsReorderDialog
|
||||
onClose={()=>{setOpenReorderDialog(false);}}
|
||||
visible={columnVisibility.visible}
|
||||
hidden={columnVisibility.hidden}
|
||||
onReset={onResetColumnReorder}
|
||||
onSubmit={onSubmitColumnReorder}
|
||||
initialItems={columnOrder}
|
||||
/>}
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -637,22 +791,19 @@ const FileBrowserTableRowNameCell = ({cellData, rowData, treeRootData, selected
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<div style={{ alignItems: 'center', display: 'flex', maxHeight: "100%", textDecoration: treeRootData[selectedFolderData.host][cellData]?.deleted ? 'line-through' : '' }}>
|
||||
{!treeRootData[selectedFolderData.host][cellData]?.can_have_children ? (
|
||||
<DescriptionIcon style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
<div style={{ alignItems: 'center', display: 'flex', maxHeight: "100%", textDecoration: treeRootData[selectedFolderData.host][rowData.full_path_text]?.deleted ? 'line-through' : '' }}>
|
||||
{!treeRootData[selectedFolderData.host][rowData.full_path_text]?.can_have_children ? (
|
||||
<DescriptionIcon size={"small"} style={{
|
||||
marginRight: '5px' }} />
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={faFolder}
|
||||
size={"lg"}
|
||||
size={"1x"}
|
||||
style={{
|
||||
width: "15px",
|
||||
height: "15px",
|
||||
marginRight: '5px',
|
||||
marginRight: '8px',
|
||||
marginLeft: "4px",
|
||||
color:
|
||||
treeRootData[selectedFolderData.host][cellData]?.success || treeRootData[selectedFolderData.host][cellData]?.metadata?.has_children
|
||||
treeRootData[selectedFolderData.host][rowData.full_path_text]?.success || treeRootData[selectedFolderData.host][rowData.full_path_text]?.has_children
|
||||
? theme.folderColor
|
||||
: 'grey',
|
||||
}}
|
||||
@@ -661,18 +812,18 @@ const FileBrowserTableRowNameCell = ({cellData, rowData, treeRootData, selected
|
||||
<pre
|
||||
style={{
|
||||
color:
|
||||
treeRootData[selectedFolderData.host][cellData]?.success !== null
|
||||
treeRootData[selectedFolderData.host][rowData.full_path_text]?.success !== null
|
||||
? theme.palette.text.primary
|
||||
: theme.palette.text.secondary,
|
||||
}}>
|
||||
{treeRootData[selectedFolderData.host][cellData]?.name_text}
|
||||
{cellData}
|
||||
</pre>
|
||||
{treeRootData[selectedFolderData.host][cellData]?.success === true ? (
|
||||
<MythicStyledTooltip title='Successfully listed contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
{treeRootData[selectedFolderData.host][rowData.full_path_text]?.success === true ? (
|
||||
<MythicStyledTooltip title='Successfully listed contents of folder' tooltipStyle={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
<CheckCircleOutlineIcon color="success" fontSize='small' />
|
||||
</MythicStyledTooltip>
|
||||
) : treeRootData[selectedFolderData.host][cellData]?.success === false ? (
|
||||
<MythicStyledTooltip title='Failed to list contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
) : treeRootData[selectedFolderData.host][rowData.full_path_text]?.success === false ? (
|
||||
<MythicStyledTooltip title='Failed to list contents of folder' tooltipStyle={{display: "inline-flex", marginLeft: "5px"}}>
|
||||
<ErrorIcon fontSize='small' color="error" />
|
||||
</MythicStyledTooltip>
|
||||
) : null}
|
||||
@@ -804,7 +955,7 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
};
|
||||
const optionsA = [
|
||||
{
|
||||
name: treeRootData[selectedFolderData.host][cellData]?.name_text, icon: null, click: ({event}) => {},
|
||||
name: treeRootData[selectedFolderData.host][rowData.full_path_text]?.name_text, icon: null, click: ({event}) => {},
|
||||
type: "item", disabled: true
|
||||
},
|
||||
{
|
||||
@@ -812,7 +963,7 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
icon: <VisibilityIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
setPermissionData(treeRootData[selectedFolderData.host][cellData].metadata);
|
||||
setPermissionData(treeRootData[selectedFolderData.host][rowData.full_path_text].metadata);
|
||||
setViewPermissionsDialogOpen(true);
|
||||
},
|
||||
},
|
||||
@@ -822,7 +973,7 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
getHistory({ variables: {
|
||||
full_path_text: treeRootData[selectedFolderData.host][cellData].full_path_text,
|
||||
full_path_text: treeRootData[selectedFolderData.host][rowData.full_path_text].full_path_text,
|
||||
host: selectedFolderData.host,
|
||||
group: [selectedFolderData.group],
|
||||
} });
|
||||
@@ -849,22 +1000,21 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<IconButton
|
||||
<MythicActionButton colorMode="always" tone="info" iconOnly
|
||||
style={{height: "100%" }}
|
||||
size='small'
|
||||
aria-controls={dropdownOpen ? 'split-button-menu' : undefined}
|
||||
aria-expanded={dropdownOpen ? 'true' : undefined}
|
||||
aria-haspopup='menu'
|
||||
onClick={handleDropdownToggle}
|
||||
color='info'
|
||||
variant='contained'
|
||||
ref={dropdownAnchorRef}>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
{treeRootData[selectedFolderData.host][cellData]?.filemeta.length > 0 ?
|
||||
<MythicStyledTooltip title={treeRootData[selectedFolderData.host][cellData]?.filemeta[0]?.complete ?
|
||||
</MythicActionButton>
|
||||
{treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta.length > 0 ?
|
||||
<MythicStyledTooltip title={treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta[0]?.complete ?
|
||||
"Preview Media" : "Preview Partial Media"}>
|
||||
<FontAwesomeIcon icon={faPhotoVideo} style={{height: "15px",
|
||||
<FontAwesomeIcon icon={faPhotoVideo} size={"1x"} style={{
|
||||
position: "relative", cursor: "pointer", display: "inline-block"}}
|
||||
onClick={openFilePreview}/>
|
||||
</MythicStyledTooltip>
|
||||
@@ -874,8 +1024,8 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
|
||||
onClose={(e)=>{setOpenPreviewMediaDialog(false);}}
|
||||
innerDialog={<PreviewFileMediaDialog
|
||||
agent_file_id={treeRootData[selectedFolderData.host][cellData]?.filemeta[0]?.agent_file_id}
|
||||
filename={b64DecodeUnicode(treeRootData[selectedFolderData.host][cellData]?.filemeta[0]?.filename_text)}
|
||||
agent_file_id={treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta[0]?.agent_file_id}
|
||||
filename={b64DecodeUnicode(treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta[0]?.filename_text)}
|
||||
onClose={(e)=>{setOpenPreviewMediaDialog(false);}} />}
|
||||
/>
|
||||
}
|
||||
@@ -891,6 +1041,7 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
disabled={option.disabled}
|
||||
className={option.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined}
|
||||
onClick={(event) => handleMenuItemClick(event, option.click)}
|
||||
>
|
||||
{option.icon} {option.name}
|
||||
@@ -904,6 +1055,7 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
|
||||
<DropdownMenuItem
|
||||
key={menuOption.name}
|
||||
disabled={menuOption.disabled}
|
||||
className={menuOption.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined}
|
||||
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
|
||||
>
|
||||
{menuOption.icon}{menuOption.name}
|
||||
|
||||
@@ -6,11 +6,14 @@ 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 getOpenIDFromNode = (node) => {
|
||||
return `${node.group};${node.host};${node.full_path_text}`;
|
||||
}
|
||||
export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatrix, fetchFolderData, setTableData, taskListing, tableOpenedPathId, showDeletedFiles, tabInfo, selectedFolderData}) => {
|
||||
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);
|
||||
@@ -19,7 +22,7 @@ export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatri
|
||||
//console.log("toggleNodeExpanded", nodeId, nodeData);
|
||||
let lastOpenedNodeNewState = true;
|
||||
let lastOpenedNodeID = getOpenIDFromNode(lastOpenedNodeRef.current);
|
||||
if(!lastOpenedNodeRef.current?.metadata?.has_children){
|
||||
if(!lastOpenedNodeRef.current?.has_children){
|
||||
lastOpenedNodeNewState = false;
|
||||
}
|
||||
lastOpenedNodeRef.current = {...nodeData};
|
||||
@@ -28,7 +31,7 @@ export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatri
|
||||
setOpenNodes({
|
||||
...openNodes,
|
||||
[getOpenIDFromNode(nodeData)]: true,
|
||||
[lastOpenedNodeID]: lastOpenedNodeNewState
|
||||
//[lastOpenedNodeID]: lastOpenedNodeNewState
|
||||
});
|
||||
};
|
||||
const toggleNodeCollapsed = (nodeId, nodeData) => {
|
||||
@@ -77,6 +80,41 @@ export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatri
|
||||
});
|
||||
}, [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: 'List', type: "item", icon: <ListIcon color="warning" style={{ paddingRight: '5px'}} />,
|
||||
click: ({event}) => {
|
||||
@@ -146,6 +184,7 @@ export const CallbacksTabsFileBrowserTreePreMemo = ({ treeRootData, treeAdjMatri
|
||||
onCollapseNode={toggleNodeCollapsed}
|
||||
onContextMenu={onContextMenu}
|
||||
tabInfo={tabInfo}
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
{openViewGroupsDialog &&
|
||||
<MythicDialog
|
||||
|
||||
@@ -1,13 +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 DialogTitle from '@mui/material/DialogTitle';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import {useMutation} from '@apollo/client';
|
||||
import {hideCallbacksMutation} from './CallbackMutations';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import {CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell, CallbacksTableIPCell} from "./CallbacksTableRow";
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
|
||||
|
||||
|
||||
const callbacksAndFeaturesQuery = gql`
|
||||
@@ -34,104 +34,6 @@ query callbacksAndFeatures{
|
||||
}
|
||||
}`;
|
||||
|
||||
const columns = [
|
||||
{ field: 'display_id', headerName: 'ID', width: 70, type: 'number', },
|
||||
{
|
||||
field: 'host',
|
||||
headerName: 'Host',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'user',
|
||||
headerName: 'User',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'pid',
|
||||
headerName: 'PID',
|
||||
type: 'number',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Description',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'ip',
|
||||
headerName: 'IP',
|
||||
width: 100,
|
||||
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
|
||||
sortable: false,
|
||||
valueGetter: (params) => {
|
||||
try{
|
||||
return JSON.parse(params.row.ip)[0];
|
||||
}catch(error){
|
||||
return params.row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (params) => new Date(params.row.last_checkin),
|
||||
renderCell: (params) =>
|
||||
<CallbacksTableLastCheckinCell rowData={params.row} />,
|
||||
},
|
||||
{
|
||||
field: "payload.payloadtype.name",
|
||||
headerName: "Agent",
|
||||
flex: 0.5,
|
||||
valueGetter: (params) => params.row.payload.payloadtype.name,
|
||||
renderCell: (params) => <CallbacksTablePayloadTypeCell rowData={params.row} />
|
||||
},
|
||||
{
|
||||
field: "mythictree_groups_string",
|
||||
headerName: "Groups",
|
||||
flex: 0.5,
|
||||
}
|
||||
];
|
||||
const CustomSelectTable = ({initialData, selectedData}) => {
|
||||
const [data, setData] = React.useState([]);
|
||||
const [rowSelectionModel, setRowSelectionModel] = React.useState([]);
|
||||
React.useEffect( () => {
|
||||
selectedData.current = data.reduce( (prev, cur) => {
|
||||
if(rowSelectionModel.includes(cur.id)){return [...prev, cur]}
|
||||
return [...prev];
|
||||
}, []);
|
||||
}, [data, rowSelectionModel]);
|
||||
React.useEffect( () => {
|
||||
setData(initialData.map(c => {
|
||||
return {...c};
|
||||
}));
|
||||
}, [initialData]);
|
||||
return (
|
||||
<div style={{height: "calc(80vh)"}}>
|
||||
<DataGrid
|
||||
rows={data}
|
||||
columns={columns}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
},
|
||||
},
|
||||
sorting: {
|
||||
sortModel: [{ field: 'last_checkin', sort: 'asc' }],
|
||||
},
|
||||
}}
|
||||
autoPageSize
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newRowSelectionModel) => {
|
||||
setRowSelectionModel(newRowSelectionModel);
|
||||
}}
|
||||
rowSelectionModel={rowSelectionModel}
|
||||
density={"compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
|
||||
const selectedData = React.useRef([]);
|
||||
@@ -147,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 => {
|
||||
@@ -172,17 +74,72 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Hide Multiple Callbacks at Once</DialogTitle>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, {useContext} from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import React from 'react';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import {CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell, CallbacksTableIPCell} from "./CallbacksTableRow";
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
|
||||
|
||||
|
||||
const callbacksAndFeaturesQuery = gql`
|
||||
@@ -31,97 +31,9 @@ query callbacksAndFeatures{
|
||||
}
|
||||
}`;
|
||||
|
||||
const columns = [
|
||||
{ field: 'display_id', headerName: 'ID', width: 70, type: 'number', },
|
||||
{
|
||||
field: 'host',
|
||||
headerName: 'Host',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'user',
|
||||
headerName: 'User',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'pid',
|
||||
headerName: 'PID',
|
||||
type: 'number',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Description',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'ip',
|
||||
headerName: 'IP',
|
||||
width: 100,
|
||||
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
|
||||
sortable: false,
|
||||
valueGetter: (params) => {
|
||||
try{
|
||||
return JSON.parse(params.row.ip)[0];
|
||||
}catch(error){
|
||||
return params.row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (params) => new Date(params.row.last_checkin),
|
||||
renderCell: (params) =>
|
||||
<CallbacksTableLastCheckinCell rowData={params.row} />,
|
||||
},
|
||||
{
|
||||
field: "payload.payloadtype.name",
|
||||
headerName: "Agent",
|
||||
flex: 0.5,
|
||||
valueGetter: (params) => params.row.payload.payloadtype.name,
|
||||
renderCell: (params) => <CallbacksTablePayloadTypeCell rowData={params.row} />
|
||||
},
|
||||
{
|
||||
field: "mythictree_groups_string",
|
||||
headerName: "Groups",
|
||||
flex: 0.5,
|
||||
}
|
||||
];
|
||||
const CustomSelectTable = ({initialData, selectedData, onRowClick}) => {
|
||||
const [data, setData] = React.useState([]);
|
||||
React.useEffect( () => {
|
||||
setData(initialData.map(c => {
|
||||
return {...c};
|
||||
}));
|
||||
}, [initialData]);
|
||||
return (
|
||||
<div style={{height: "calc(80vh)"}}>
|
||||
<DataGrid
|
||||
rows={data}
|
||||
columns={columns}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
},
|
||||
},
|
||||
sorting: {
|
||||
sortModel: [{ field: 'display_id', sort: 'desc' }],
|
||||
},
|
||||
}}
|
||||
autoPageSize
|
||||
onRowClick={onRowClick}
|
||||
density={"compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs}) {
|
||||
const selectedData = React.useRef([]);
|
||||
const [initialData, setInitialData] = React.useState([]);
|
||||
useQuery(callbacksAndFeaturesQuery,{
|
||||
const {loading} = useQuery(callbacksAndFeaturesQuery,{
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
const callbackData = data.callback.map( c => {
|
||||
@@ -133,23 +45,29 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
|
||||
}
|
||||
});
|
||||
const onRowClick = (rowData) => {
|
||||
onOpenTabs([rowData.row]);
|
||||
onOpenTabs([rowData]);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Select Callback to open {tabType} tab</DialogTitle>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData}
|
||||
onRowClick={onRowClick}
|
||||
<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}
|
||||
onRowClick={onRowClick}
|
||||
selectable={false}
|
||||
sortModel={{ field: 'display_id', sort: 'desc' }}
|
||||
tableId={`open-multiple-${tabType}-callbacks-table`}
|
||||
tableLabel={`Open ${tabType} callback`}
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
|
||||
Close
|
||||
</Button>
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import {gql, useQuery, useSubscription, useLazyQuery } from '@apollo/client';
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {gql, useQuery, useSubscription } from '@apollo/client';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import {CallbacksTabsProcessBrowserTable} from './CallbacksTabsProcessBrowserTable';
|
||||
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
import {TaskFromUIButton} from './TaskFromUIButton';
|
||||
@@ -15,14 +12,20 @@ import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip'
|
||||
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 TextField from '@mui/material/TextField';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import {ViewCallbackMythicTreeGroupsDialog} from "./ViewCallbackMythicTreeGroupsDialog";
|
||||
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import { Backdrop, Typography } from '@mui/material';
|
||||
import {Backdrop, Typography} from '@mui/material';
|
||||
import {CircularProgress} from '@mui/material';
|
||||
import ExpandIcon from '@mui/icons-material/Expand';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
|
||||
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
|
||||
|
||||
const treeFragment = gql`
|
||||
fragment treeObjData on mythictree {
|
||||
@@ -83,6 +86,9 @@ const treeHostQuery = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
const mergeUniqueById = (existing = [], incoming = []) => [
|
||||
...new Map([...existing, ...incoming].filter(Boolean).map((item) => [item.id, item])).values(),
|
||||
];
|
||||
export const loadedCommandsQuery = gql`
|
||||
query loadedCommandUIFeatures($callback_id: Int!){
|
||||
loadedcommands(where: {callback_id: {_eq: $callback_id}}){
|
||||
@@ -131,7 +137,7 @@ export function CallbacksTabsProcessBrowserLabel(props){
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MythicTabLabel label={description} onDragTab={props.onDragTab} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
<MythicTabLabel label={description} highlight={props.newDataForTab[props.tabInfo.tabID]} onDragTab={props.onDragTab} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
{openEditDescriptionDialog &&
|
||||
<MythicDialog fullWidth={true} open={openEditDescriptionDialog} onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
innerDialog={
|
||||
@@ -142,18 +148,39 @@ export function CallbacksTabsProcessBrowserLabel(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>{
|
||||
export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, setNewDataForTab}) =>{
|
||||
const theme = useTheme();
|
||||
const fromNow = React.useRef((new Date()));
|
||||
const active = index === value;
|
||||
const markInactiveChange = useCallback(() => {
|
||||
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {
|
||||
...previous,
|
||||
[tabInfo.tabID]: true,
|
||||
});
|
||||
}, [setNewDataForTab, tabInfo.tabID]);
|
||||
const {
|
||||
treeRootDataRef,
|
||||
treeAdjMtx,
|
||||
version: treeVersion,
|
||||
hydrated: treeHydrated,
|
||||
setTreeAdjMtx,
|
||||
isScopeLoaded,
|
||||
markScopeLoaded,
|
||||
} = useCallbackBrowserTree({
|
||||
treeType: "process",
|
||||
mode: "process",
|
||||
active,
|
||||
onInactiveChange: markInactiveChange,
|
||||
});
|
||||
const [backdropOpen, setBackdropOpen] = React.useState(false);
|
||||
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(false);
|
||||
const treeRootDataRef = React.useRef({}); // hold all the actual data
|
||||
const [treeAdjMtx, setTreeAdjMtx] = React.useState({}); // hold the simple adjacency matrix for parent/child relationships
|
||||
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(true);
|
||||
const [openTaskingButton, setOpenTaskingButton] = React.useState(false);
|
||||
const taskingData = React.useRef({"parameters": "", "ui_feature": "process_browser:list"});
|
||||
const mountedRef = React.useRef(true);
|
||||
const [showDeletedFiles, setShowDeletedFiles] = React.useState(false);
|
||||
const [selectedHost, setSelectedHost] = React.useState("");
|
||||
const [selectedGroup, setSelectedGroup] = React.useState("");
|
||||
const [quickFilter, setQuickFilter] = React.useState("");
|
||||
const loadedCommandsRef = React.useRef({});
|
||||
const loadingCommandsRef = React.useRef(false);
|
||||
const getNewMatrix = () => {
|
||||
@@ -161,7 +188,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
for(const[group, groupMatrix] of Object.entries(treeRootDataRef.current)){
|
||||
if(newMatrix[group] === undefined){newMatrix[group] = {}}
|
||||
for(const[host, hostMatrix] of Object.entries(treeRootDataRef.current[group])){
|
||||
if(newMatrix[group][host] === undefined){newMatrix[group][host] = {"":[]}}
|
||||
if(newMatrix[group][host] === undefined){newMatrix[group][host] = {"":{}}}
|
||||
for(const[full_path_text, node] of Object.entries(treeRootDataRef.current[group][host])){
|
||||
if(newMatrix[group][host][ node["parent_path_text"] ] === undefined){
|
||||
newMatrix[group][host][ node["parent_path_text"] ] = {}
|
||||
@@ -173,6 +200,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
return newMatrix;
|
||||
}
|
||||
useQuery(rootQuery, {
|
||||
skip: true,
|
||||
onCompleted: (data) => {
|
||||
// use an adjacency matrix but only for full_path_text -> children, not both directions
|
||||
let defaultGroup = "Default";
|
||||
@@ -236,21 +264,22 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
const [getLoadedCommandsQuery] = useLazyQuery(loadedCommandsQuery, {
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: (data) => {
|
||||
if(data.loadedcommands.length > 0){
|
||||
loadedCommandsRef.current[data.loadedcommands[0].callback_id] = [...data.loadedcommands];
|
||||
}
|
||||
loadingCommandsRef.current = false;
|
||||
},
|
||||
onError: (data) => {
|
||||
console.log(data);
|
||||
loadingCommandsRef.current = false;
|
||||
const getLoadedCommandsQuerySuccess = (data) => {
|
||||
if(data.loadedcommands.length > 0){
|
||||
loadedCommandsRef.current[data.loadedcommands[0].callback_id] = [...data.loadedcommands];
|
||||
}
|
||||
loadingCommandsRef.current = false;
|
||||
}
|
||||
const getLoadedCommandsQueryError = (data) => {
|
||||
console.log(data);
|
||||
loadingCommandsRef.current = false;
|
||||
}
|
||||
const getLoadedCommandsQuery = useMythicLazyQuery(loadedCommandsQuery, {
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
useSubscription(treeSubscription, {
|
||||
variables: {now: fromNow.current},
|
||||
skip: true,
|
||||
fetchPolicy: "no-cache",
|
||||
onData: ({data}) => {
|
||||
for(let i = 0; i < data.data.mythictree_stream.length; i++){
|
||||
@@ -270,15 +299,18 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]].callbacks = [data.data.mythictree_stream[i].callback]
|
||||
} else {
|
||||
// we need to merge data in because we already have some info
|
||||
if(data.data.mythictree_stream[i].deleted){
|
||||
delete treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]];
|
||||
continue;
|
||||
}
|
||||
let existingData = treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]];
|
||||
existingData.callbacks.push(data.data.mythictree_stream[i].callback)
|
||||
if(existingData.task_id <= data.data.mythictree_stream[i].task_id){
|
||||
if(data.data.mythictree_stream[i].deleted){
|
||||
delete treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
|
||||
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.data.mythictree_stream[i].callback]);
|
||||
existingData.comment += data.data.mythictree_stream[i].comment;
|
||||
existingData.tags = [...existingData.tags, ...data.data.mythictree_stream[i].tags];
|
||||
if(existingData.id > data.data.mythictree_stream[i].id){
|
||||
existingData.tags = mergeUniqueById(existingData.tags, data.data.mythictree_stream[i].tags);
|
||||
if(existingData.task_id > data.data.mythictree_stream[i].task_id){
|
||||
existingData.metadata = {...data.data.mythictree_stream[i].metadata, ...existingData.metadata};
|
||||
} else {
|
||||
existingData.metadata = {...existingData.metadata, ...data.data.mythictree_stream[i].metadata};
|
||||
@@ -289,56 +321,70 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
}
|
||||
const newMatrix = getNewMatrix();
|
||||
setTreeAdjMtx(newMatrix);
|
||||
if(index !== value){
|
||||
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
|
||||
}
|
||||
}
|
||||
})
|
||||
const [getHostProcessesQuery] = useLazyQuery(treeHostQuery, {
|
||||
onError: (data) => {
|
||||
console.error(data);
|
||||
},
|
||||
fetchPolicy: 'no-cache',
|
||||
onCompleted: (data) => {
|
||||
snackActions.dismiss();
|
||||
// add in all of the raw data
|
||||
for(let i = 0; i < data.mythictree.length; i++){
|
||||
let currentGroups = data.mythictree[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
for(let j = 0; j < currentGroups.length; j++){
|
||||
if(treeRootDataRef.current[currentGroups[j]] === undefined){
|
||||
treeRootDataRef.current[currentGroups[j]] = {};
|
||||
});
|
||||
const initializedSelectionRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if(!treeHydrated || initializedSelectionRef.current){return;}
|
||||
const selection = getDefaultBrowserSelection(
|
||||
treeRootDataRef.current,
|
||||
treeAdjMtx,
|
||||
tabInfo.callbackID,
|
||||
tabInfo.host,
|
||||
);
|
||||
setSelectedGroup(selection.group);
|
||||
setSelectedHost(selection.host);
|
||||
initializedSelectionRef.current = true;
|
||||
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeVersion]);
|
||||
const getHostProcessesQuerySuccess = (data) => {
|
||||
snackActions.dismiss();
|
||||
// add in all of the raw data
|
||||
for(let i = 0; i < data.mythictree.length; i++){
|
||||
let currentGroups = data.mythictree[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
|
||||
for(let j = 0; j < currentGroups.length; j++){
|
||||
if(treeRootDataRef.current[currentGroups[j]] === undefined){
|
||||
treeRootDataRef.current[currentGroups[j]] = {};
|
||||
}
|
||||
if( treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] === undefined) {
|
||||
// new host discovered
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] = {};
|
||||
}
|
||||
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] === undefined){
|
||||
// first time we're seeing this file data, just add it
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]};
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].callbacks = [data.mythictree[i].callback];
|
||||
} else {
|
||||
// we need to merge data in because we already have some info
|
||||
if(data.mythictree[i].deleted){
|
||||
delete treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]];
|
||||
continue;
|
||||
}
|
||||
if( treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] === undefined) {
|
||||
// new host discovered
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] = {};
|
||||
}
|
||||
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] === undefined){
|
||||
// first time we're seeing this file data, just add it
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]};
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].callbacks = [data.mythictree[i].callback];
|
||||
let existingData = treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]];
|
||||
existingData.comment += data.mythictree[i].comment;
|
||||
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.mythictree[i].callback]);
|
||||
existingData.tags = mergeUniqueById(existingData.tags, data.mythictree[i].tags);
|
||||
if(existingData.task_id > data.mythictree[i].task_id){
|
||||
existingData.metadata = {...data.mythictree[i].metadata, ...existingData.metadata};
|
||||
} else {
|
||||
// we need to merge data in because we already have some info
|
||||
if(data.mythictree[i].deleted){
|
||||
delete treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]];
|
||||
continue;
|
||||
}
|
||||
let existingData = treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]];
|
||||
existingData.comment += data.mythictree[i].comment;
|
||||
existingData.callbacks.push(data.mythictree[i].callback)
|
||||
existingData.tags = [...existingData.tags, ...data.mythictree[i].tags];
|
||||
if(existingData.id > data.mythictree[i].id){
|
||||
existingData.metadata = {...data.mythictree[i].metadata, ...existingData.metadata};
|
||||
} else {
|
||||
existingData.metadata = {...existingData.metadata, ...data.mythictree[i].metadata};
|
||||
}
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...existingData};
|
||||
existingData.metadata = {...existingData.metadata, ...data.mythictree[i].metadata};
|
||||
}
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...existingData};
|
||||
}
|
||||
}
|
||||
// create the top level data in the adjacency matrix
|
||||
const newMatrix = getNewMatrix();
|
||||
//console.log(treeRootDataRef.current);
|
||||
setTreeAdjMtx(newMatrix);
|
||||
//console.log("just set treeAdjMtx, about to close backdrop")
|
||||
setBackdropOpen(false);
|
||||
}
|
||||
// create the top level data in the adjacency matrix
|
||||
const newMatrix = getNewMatrix();
|
||||
//console.log(treeRootDataRef.current);
|
||||
setTreeAdjMtx(newMatrix);
|
||||
markScopeLoaded(`host:${selectedHost}`);
|
||||
//console.log("just set treeAdjMtx, about to close backdrop")
|
||||
setBackdropOpen(false);
|
||||
}
|
||||
const getHostProcessesQuery = useMythicLazyQuery(treeHostQuery, {
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
const onListFilesButton = () => {
|
||||
taskingData.current = ({parameters: "",
|
||||
@@ -347,9 +393,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
callback_display_id: tabInfo["displayID"]});
|
||||
setOpenTaskingButton(true);
|
||||
}
|
||||
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id}) => {
|
||||
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id, display_id}) => {
|
||||
taskingData.current = {"parameters": {host: selectedHost, process_id, architecture},
|
||||
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id};
|
||||
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id: callback_display_id || display_id};
|
||||
setOpenTaskingButton(true);
|
||||
}
|
||||
const toggleShowDeletedFiles = (showStatus) => {
|
||||
@@ -373,7 +419,8 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
}
|
||||
if(loadedCommandsRef.current[callback_id] === undefined){
|
||||
loadingCommandsRef.current = true;
|
||||
getLoadedCommandsQuery({variables: {callback_id: callback_id}});
|
||||
getLoadedCommandsQuery({variables: {callback_id: callback_id}})
|
||||
.then(({data}) => getLoadedCommandsQuerySuccess(data)).catch(({data}) => getLoadedCommandsQueryError(data));
|
||||
while(loadingCommandsRef.current){
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
@@ -389,9 +436,11 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
return undefined;
|
||||
}
|
||||
React.useEffect( () => {
|
||||
getHostProcessesQuery({variables: {host: selectedHost}});
|
||||
if(!active || selectedHost === "" || isScopeLoaded(`host:${selectedHost}`)){return;}
|
||||
getHostProcessesQuery({variables: {host: selectedHost}})
|
||||
.then(({data}) => getHostProcessesQuerySuccess(data)).catch(({data}) => console.log(data));
|
||||
setBackdropOpen(true);
|
||||
}, [selectedHost]);
|
||||
}, [active, isScopeLoaded, selectedHost]);
|
||||
React.useEffect( () => {
|
||||
return() => {
|
||||
mountedRef.current = false;
|
||||
@@ -406,9 +455,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute",}} invisible={false}>
|
||||
<div style={{
|
||||
borderRadius: "4px",
|
||||
border: "1px solid black",
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
padding: "5px",
|
||||
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
|
||||
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
|
||||
alignItems: "center",
|
||||
display: "flex", flexDirection: "column"}}>
|
||||
<CircularProgress color="inherit" />
|
||||
@@ -429,8 +478,12 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
hostOptions={treeRootDataRef.current[selectedGroup] || {}}
|
||||
expandOrCollapseAll={expandOrCollapseAll}
|
||||
setExpandOrCollapseAll={setExpandOrCollapseAll}
|
||||
quickFilter={quickFilter}
|
||||
setQuickFilter={setQuickFilter}
|
||||
/>
|
||||
<CallbacksTabsProcessBrowserTable
|
||||
active={active}
|
||||
dataVersion={treeVersion}
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
tabInfo={tabInfo}
|
||||
onRowDoubleClick={() => {}}
|
||||
@@ -438,6 +491,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me}) =>
|
||||
treeAdjMatrix={treeAdjMtx}
|
||||
host={selectedHost}
|
||||
group={selectedGroup}
|
||||
quickFilter={quickFilter}
|
||||
expandOrCollapseAll={expandOrCollapseAll}
|
||||
onTaskRowAction={onTaskRowAction}
|
||||
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
|
||||
@@ -468,13 +522,12 @@ const ProcessBrowserTableTop = ({
|
||||
hostOptions,
|
||||
groupOptions,
|
||||
expandOrCollapseAll,
|
||||
setExpandOrCollapseAll
|
||||
setExpandOrCollapseAll,
|
||||
quickFilter,
|
||||
setQuickFilter
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [showDeletedFiles, setLocalShowDeletedFiles] = React.useState(false);
|
||||
const [openViewGroupsDialog, setOpenViewGroupDialog] = React.useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const inputGroupRef = useRef(null);
|
||||
const onLocalListFilesButton = () => {
|
||||
onListFilesButton()
|
||||
}
|
||||
@@ -491,58 +544,104 @@ const ProcessBrowserTableTop = ({
|
||||
const onLocalExpandOrCollapseAllButton = () => {
|
||||
setExpandOrCollapseAll(!expandOrCollapseAll);
|
||||
}
|
||||
const onClearQuickFilter = () => {
|
||||
setQuickFilter("");
|
||||
}
|
||||
return (
|
||||
<Grid container spacing={0} style={{paddingTop: "10px"}}>
|
||||
<Grid item xs={12}>
|
||||
<FormControl style={{width: "30%"}}>
|
||||
<InputLabel ref={inputGroupRef}>Available Groups</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={group}
|
||||
onChange={handleGroupChange}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
endAdornment={
|
||||
<React.Fragment>
|
||||
<MythicStyledTooltip title="View Callbacks associated with this group">
|
||||
<IconButton style={{padding: "3px", paddingRight: "25px"}} onClick={() => {setOpenViewGroupDialog(true);}} size="large"><WidgetsIcon style={{color: theme.palette.info.main}}/></IconButton>
|
||||
</MythicStyledTooltip>
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
{Object.keys(groupOptions).sort().map( (opt) => (
|
||||
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl style={{width: "70%"}}>
|
||||
<InputLabel ref={inputRef}>Available Hosts</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={host}
|
||||
onChange={handleChange}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
endAdornment={
|
||||
<React.Fragment>
|
||||
<MythicStyledTooltip title="Task Callback to List Processes">
|
||||
<IconButton style={{padding: "3px"}} onClick={onLocalListFilesButton} size="large"><RefreshIcon style={{color: theme.palette.info.main}}/></IconButton>
|
||||
<div className="mythic-file-browser-tableTop mythic-process-browser-tableTop py-4 px-5">
|
||||
<div className="mythic-process-browser-toolbar items-center flex gap-4">
|
||||
<div className="mythic-process-browser-control items-center inline-flex gap-3 mythic-process-browser-controlGroup min-w-0 rounded bg-neutral-1 border-subtle">
|
||||
<span className="mythic-process-browser-controlLabel font-750 text-muted">Group</span>
|
||||
<FormControl size="small" className="mythic-process-browser-selectControl">
|
||||
<Select
|
||||
value={group}
|
||||
onChange={handleGroupChange}
|
||||
displayEmpty
|
||||
className="mythic-process-browser-select"
|
||||
>
|
||||
{Object.keys(groupOptions).sort().map( (opt) => (
|
||||
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<MythicStyledTooltip title="View callbacks associated with this group">
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
onClick={() => {setOpenViewGroupDialog(true);}}
|
||||
size="small">
|
||||
<WidgetsIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
</div>
|
||||
<div className="mythic-process-browser-control items-center inline-flex gap-3 mythic-process-browser-controlHost min-w-0 rounded bg-neutral-1 border-subtle">
|
||||
<span className="mythic-process-browser-controlLabel font-750 text-muted">Host</span>
|
||||
<FormControl size="small" className="mythic-process-browser-selectControl">
|
||||
<Select
|
||||
value={host}
|
||||
onChange={handleChange}
|
||||
displayEmpty
|
||||
className="mythic-process-browser-select"
|
||||
>
|
||||
{Object.keys(hostOptions).sort().map( (opt) => (
|
||||
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<TextField
|
||||
className="mythic-process-browser-searchInput"
|
||||
size="small"
|
||||
value={quickFilter}
|
||||
onChange={(event) => setQuickFilter(event.target.value)}
|
||||
placeholder="Filter name, PID, user, arch, session, command..."
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon fontSize="small" />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: quickFilter ? (
|
||||
<InputAdornment position="end">
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="error"
|
||||
onClick={onClearQuickFilter}
|
||||
size="small">
|
||||
<ClearIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</InputAdornment>
|
||||
) : null
|
||||
}}
|
||||
/>
|
||||
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-process-browser-actions">
|
||||
<MythicStyledTooltip title="Task current callback to list processes">
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" colorMode="hover" shape="square" tone="info"
|
||||
onClick={onLocalListFilesButton}
|
||||
size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
onClick={onLocalExpandOrCollapseAllButton} size={"large"}>
|
||||
<ExpandIcon color={expandOrCollapseAll ? "info" : "success"} />
|
||||
</IconButton>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" active={expandOrCollapseAll} colorMode="hover" shape="square" tone={expandOrCollapseAll ? "success" : "info"}
|
||||
onClick={onLocalExpandOrCollapseAllButton}
|
||||
size="small">
|
||||
<ExpandIcon fontSize="small" />
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
<div style={{paddingRight: "20px"}} />
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
{Object.keys(hostOptions).sort().map( (opt) => (
|
||||
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<MythicStyledTooltip title={showDeletedFiles ? 'Hide deleted processes' : 'Show deleted processes'}>
|
||||
<MythicActionButton iconOnly
|
||||
appearance="plain" active={showDeletedFiles} colorMode="hover" shape="square" tone="warning"
|
||||
onClick={onLocalToggleShowDeletedFiles}
|
||||
size="small">
|
||||
{showDeletedFiles ? (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</MythicActionButton>
|
||||
</MythicStyledTooltip>
|
||||
</div>
|
||||
{openViewGroupsDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
@@ -555,7 +654,7 @@ const ProcessBrowserTableTop = ({
|
||||
}
|
||||
/>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1052
-503
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,284 @@
|
||||
import React from 'react';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import TableSortLabel from '@mui/material/TableSortLabel';
|
||||
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
|
||||
import {MythicTableEmptyState, MythicTableLoadingState} from "../../MythicComponents/MythicStateDisplay";
|
||||
import {CallbacksTableIPCell, CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell} from "./CallbacksTableRow";
|
||||
import {MythicClientSideTablePagination, useMythicClientPagination} from "../../MythicComponents/MythicTablePagination";
|
||||
|
||||
const getPrimaryIP = (row) => {
|
||||
try{
|
||||
return JSON.parse(row.ip)[0] || "";
|
||||
}catch(error){
|
||||
return row.ip || "";
|
||||
}
|
||||
}
|
||||
|
||||
const compareValues = (left, right) => {
|
||||
if(left === right){
|
||||
return 0;
|
||||
}
|
||||
if(left === undefined || left === null){
|
||||
return -1;
|
||||
}
|
||||
if(right === undefined || right === null){
|
||||
return 1;
|
||||
}
|
||||
if(left instanceof Date && right instanceof Date){
|
||||
return left.getTime() - right.getTime();
|
||||
}
|
||||
if(typeof left === "number" && typeof right === "number"){
|
||||
return left - right;
|
||||
}
|
||||
return String(left).localeCompare(String(right), undefined, {numeric: true, sensitivity: "base"});
|
||||
}
|
||||
|
||||
const getColumns = ({includeAgent}) => {
|
||||
const columns = [
|
||||
{field: "display_id", headerName: "ID", width: "4.5rem", sortValue: (row) => row.display_id},
|
||||
{field: "host", headerName: "Host", sortValue: (row) => row.host},
|
||||
{field: "user", headerName: "User", sortValue: (row) => row.user},
|
||||
{field: "pid", headerName: "PID", width: "4.5rem", sortValue: (row) => row.pid},
|
||||
{field: "description", headerName: "Description", sortValue: (row) => row.description},
|
||||
{
|
||||
field: "ip",
|
||||
headerName: "IP",
|
||||
width: "8rem",
|
||||
sortValue: getPrimaryIP,
|
||||
render: (row) => <CallbacksTableIPCell rowData={row} cellData={row.ip} />,
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: "8rem",
|
||||
sortValue: (row) => new Date(row.last_checkin),
|
||||
render: (row) => <CallbacksTableLastCheckinCell rowData={row} />,
|
||||
},
|
||||
];
|
||||
if(includeAgent){
|
||||
columns.push({
|
||||
field: "payload.payloadtype.name",
|
||||
headerName: "Agent",
|
||||
sortValue: (row) => row.payload?.payloadtype?.name || "",
|
||||
render: (row) => <CallbacksTablePayloadTypeCell rowData={row} />,
|
||||
});
|
||||
}
|
||||
columns.push({field: "mythictree_groups_string", headerName: "Groups", sortValue: (row) => row.mythictree_groups_string});
|
||||
return columns;
|
||||
}
|
||||
|
||||
export const CallbacksTabsSelectTable = ({
|
||||
includeAgent = true,
|
||||
initialData,
|
||||
loading = false,
|
||||
onRowClick,
|
||||
selectable = true,
|
||||
selectedData,
|
||||
sortModel = {field: "display_id", sort: "desc"},
|
||||
tableId = "callbacks-select-table",
|
||||
tableLabel = "Callback selection",
|
||||
rowsPerPage = 25,
|
||||
}) => {
|
||||
const [data, setData] = React.useState([]);
|
||||
const [orderBy, setOrderBy] = React.useState(sortModel.field);
|
||||
const [order, setOrder] = React.useState(sortModel.sort || "asc");
|
||||
const [selectedIds, setSelectedIds] = React.useState(new Set());
|
||||
const columns = React.useMemo(() => getColumns({includeAgent}), [includeAgent]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setOrderBy(sortModel.field);
|
||||
setOrder(sortModel.sort || "asc");
|
||||
}, [sortModel.field, sortModel.sort]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(initialData.map((c) => ({...c})));
|
||||
setSelectedIds(new Set());
|
||||
}, [initialData]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if(selectable && selectedData){
|
||||
selectedData.current = data.filter((row) => selectedIds.has(row.id));
|
||||
}
|
||||
}, [data, selectable, selectedData, selectedIds]);
|
||||
|
||||
const sortedData = React.useMemo(() => {
|
||||
const sortColumn = columns.find((column) => column.field === orderBy);
|
||||
if(!sortColumn){
|
||||
return data;
|
||||
}
|
||||
const direction = order === "desc" ? -1 : 1;
|
||||
return [...data].sort((left, right) => direction * compareValues(sortColumn.sortValue(left), sortColumn.sortValue(right)));
|
||||
}, [columns, data, order, orderBy]);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
const allSelected = selectable && sortedData.length > 0 && selectedCount === sortedData.length;
|
||||
const partiallySelected = selectable && selectedCount > 0 && selectedCount < sortedData.length;
|
||||
const pagination = useMythicClientPagination({
|
||||
items: sortedData,
|
||||
resetKey: `${orderBy}:${order}`,
|
||||
rowsPerPage,
|
||||
});
|
||||
|
||||
const handleSort = (field) => {
|
||||
if(orderBy === field){
|
||||
setOrder((current) => current === "asc" ? "desc" : "asc");
|
||||
}else{
|
||||
setOrderBy(field);
|
||||
setOrder("asc");
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectAll = (event) => {
|
||||
if(event.target.checked){
|
||||
setSelectedIds(new Set(sortedData.map((row) => row.id)));
|
||||
}else{
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectedRow = (row) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if(next.has(row.id)){
|
||||
next.delete(row.id);
|
||||
}else{
|
||||
next.add(row.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const handleSelectRow = (event, row) => {
|
||||
event.stopPropagation();
|
||||
toggleSelectedRow(row);
|
||||
}
|
||||
|
||||
const handleRowClick = (row) => {
|
||||
if(selectable){
|
||||
toggleSelectedRow(row);
|
||||
}else if(onRowClick){
|
||||
onRowClick(row);
|
||||
}
|
||||
}
|
||||
|
||||
const handleRowKeyDown = (event, row) => {
|
||||
if(!selectable && !onRowClick){
|
||||
return;
|
||||
}
|
||||
if(event.key === "Enter" || event.key === " "){
|
||||
event.preventDefault();
|
||||
handleRowClick(row);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableContainer
|
||||
aria-label={`${tableLabel} scroll area`}
|
||||
className="mythic-dialog-table-wrap mythic-fixed-row-table-wrap mythic-callback-select-table-wrap"
|
||||
data-testid={`${tableId}-scroll-region`}
|
||||
id={`${tableId}-scroll-region`}
|
||||
role="region"
|
||||
style={{height: "min(60vh, 42rem)", minHeight: "20rem", overflowY: "auto"}}
|
||||
>
|
||||
<Table aria-label={tableLabel} data-testid={tableId} id={tableId} stickyHeader size="small" style={{height: "auto"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{selectable &&
|
||||
<MythicStyledTableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
color="primary"
|
||||
indeterminate={partiallySelected}
|
||||
inputProps={{"aria-label": `Select all callbacks in ${tableLabel}`}}
|
||||
onChange={handleSelectAll}
|
||||
size="small"
|
||||
/>
|
||||
</MythicStyledTableCell>
|
||||
}
|
||||
{columns.map((column) => (
|
||||
<MythicStyledTableCell key={column.field} sortDirection={orderBy === column.field ? order : false} style={{width: column.width}}>
|
||||
<TableSortLabel
|
||||
active={orderBy === column.field}
|
||||
direction={orderBy === column.field ? order : "asc"}
|
||||
onClick={() => handleSort(column.field)}
|
||||
>
|
||||
{column.headerName}
|
||||
</TableSortLabel>
|
||||
</MythicStyledTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody id={`${tableId}-body`}>
|
||||
{loading ? (
|
||||
<MythicTableLoadingState
|
||||
colSpan={columns.length + (selectable ? 1 : 0)}
|
||||
columns={columns.length + (selectable ? 1 : 0)}
|
||||
compact
|
||||
rows={4}
|
||||
title="Loading callbacks"
|
||||
description="Fetching callback data."
|
||||
minHeight={100}
|
||||
/>
|
||||
) : sortedData.length === 0 ? (
|
||||
<MythicTableEmptyState
|
||||
colSpan={columns.length + (selectable ? 1 : 0)}
|
||||
compact
|
||||
title="No callbacks"
|
||||
description="No active callbacks match this selection."
|
||||
minHeight={180}
|
||||
/>
|
||||
) : (
|
||||
pagination.pageData.map((row) => {
|
||||
const selected = selectedIds.has(row.id);
|
||||
return (
|
||||
<TableRow
|
||||
aria-label={`Callback ${row.display_id} ${row.user || ""}@${row.host || ""}`}
|
||||
className={selected ? "selectedCallback" : ""}
|
||||
hover
|
||||
id={`${tableId}-row-${row.id}`}
|
||||
key={row.id}
|
||||
onClick={() => handleRowClick(row)}
|
||||
onKeyDown={(event) => handleRowKeyDown(event, row)}
|
||||
tabIndex={selectable || onRowClick ? 0 : undefined}
|
||||
selected={selected}
|
||||
style={{cursor: selectable || onRowClick ? "pointer" : undefined}}
|
||||
>
|
||||
{selectable &&
|
||||
<MythicStyledTableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
color="primary"
|
||||
inputProps={{"aria-label": `Select callback ${row.display_id}`}}
|
||||
onClick={(event) => handleSelectRow(event, row)}
|
||||
size="small"
|
||||
/>
|
||||
</MythicStyledTableCell>
|
||||
}
|
||||
{columns.map((column) => (
|
||||
<MythicStyledTableCell key={column.field}>
|
||||
{column.render ? column.render(row) : row[column.field]}
|
||||
</MythicStyledTableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{!loading &&
|
||||
<MythicClientSideTablePagination
|
||||
id={`${tableId}-pagination`}
|
||||
pagination={pagination}
|
||||
selectedCount={selectable ? selectedCount : undefined}
|
||||
/>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
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 DialogTitle from '@mui/material/DialogTitle';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import {TaskFromUIButton} from './TaskFromUIButton';
|
||||
import {CallbacksTabsTaskingInput} from "./CallbacksTabsTaskingInput";
|
||||
import {CallbacksTableIPCell, CallbacksTableLastCheckinCell} from "./CallbacksTableRow";
|
||||
import { DataGrid } from '@mui/x-data-grid';
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
|
||||
|
||||
|
||||
const callbacksAndFeaturesQuery = gql`
|
||||
@@ -23,6 +23,7 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
|
||||
integrity_level
|
||||
pid
|
||||
display_id
|
||||
operation_id
|
||||
last_checkin
|
||||
ip
|
||||
dead
|
||||
@@ -35,97 +36,6 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
|
||||
}
|
||||
}`;
|
||||
|
||||
const columns = [
|
||||
{ field: 'display_id', headerName: 'ID', width: 80, type: 'number', },
|
||||
{
|
||||
field: 'host',
|
||||
headerName: 'Host',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'user',
|
||||
headerName: 'User',
|
||||
flex: 0.5,
|
||||
},
|
||||
{
|
||||
field: 'pid',
|
||||
headerName: 'PID',
|
||||
type: 'number',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Description',
|
||||
flex: 1,
|
||||
},
|
||||
{
|
||||
field: 'ip',
|
||||
headerName: 'IP',
|
||||
width: 100,
|
||||
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
|
||||
sortable: false,
|
||||
valueGetter: (params) => {
|
||||
try{
|
||||
return JSON.parse(params.row.ip)[0];
|
||||
}catch(error){
|
||||
return params.row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (params) => new Date(params.row.last_checkin),
|
||||
renderCell: (params) =>
|
||||
<CallbacksTableLastCheckinCell rowData={params.row} />,
|
||||
},
|
||||
{
|
||||
field: "mythictree_groups_string",
|
||||
headerName: "Groups",
|
||||
flex: 0.5,
|
||||
}
|
||||
];
|
||||
const CustomSelectTable = ({initialData, selectedData}) => {
|
||||
const [data, setData] = React.useState([]);
|
||||
const [rowSelectionModel, setRowSelectionModel] = React.useState([]);
|
||||
React.useEffect( () => {
|
||||
selectedData.current = data.reduce( (prev, cur) => {
|
||||
if(rowSelectionModel.includes(cur.id)){return [...prev, cur]}
|
||||
return [...prev];
|
||||
}, []);
|
||||
}, [data, rowSelectionModel]);
|
||||
React.useEffect( () => {
|
||||
setData(initialData.map(c => {
|
||||
return {...c};
|
||||
}));
|
||||
}, [initialData]);
|
||||
return (
|
||||
<div style={{height: "calc(80vh)"}}>
|
||||
<DataGrid
|
||||
rows={data}
|
||||
columns={columns}
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: {
|
||||
},
|
||||
},
|
||||
sorting: {
|
||||
sortModel: [{ field: 'display_id', sort: 'desc' }],
|
||||
},
|
||||
}}
|
||||
autoPageSize
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newRowSelectionModel) => {
|
||||
setRowSelectionModel(newRowSelectionModel);
|
||||
}}
|
||||
rowSelectionModel={rowSelectionModel}
|
||||
density={"compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
const mountedRef = React.useRef(true);
|
||||
const [selectedToken, setSelectedToken] = React.useState({});
|
||||
@@ -134,7 +44,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
const finalTaskedParameters = React.useRef(null);
|
||||
const [initialData, setInitialData] = React.useState([]);
|
||||
const selectedData = React.useRef([]);
|
||||
useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
|
||||
const {loading} = useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
setInitialData(data.callback.map( c => {
|
||||
@@ -153,9 +63,9 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
const callbacks = selectedData.current.map( c => c.display_id)
|
||||
if(callbacks.length > 0){
|
||||
if(finalTaskedParameters.current){
|
||||
taskingData.current = {...taskingData.current, callback_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
|
||||
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
|
||||
}else{
|
||||
taskingData.current = {...taskingData.current, callback_ids: callbacks, openDialog: true};
|
||||
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: true};
|
||||
}
|
||||
setOpenTaskingButton(true);
|
||||
}else{
|
||||
@@ -168,7 +78,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
//onClose();
|
||||
setOpenTaskingButton(false);
|
||||
}
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
|
||||
//console.log(message, cmd, parsed);
|
||||
if(selectedData.current.length === 0){
|
||||
//onClose();
|
||||
@@ -195,6 +105,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
tasking_location: newTaskingLocation,
|
||||
dontShowSuccessDialog: false,
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
};
|
||||
submitTasking();
|
||||
return;
|
||||
@@ -241,6 +152,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
tasking_location: newTaskingLocation,
|
||||
dontShowSuccessDialog: false,
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
};
|
||||
}else{
|
||||
finalTaskedParameters.current = undefined;
|
||||
@@ -253,6 +165,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
tasking_location: newTaskingLocation,
|
||||
dontShowSuccessDialog: false,
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
};
|
||||
}
|
||||
submitTasking();
|
||||
@@ -271,11 +184,31 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
dontShowSuccessDialog: false,
|
||||
parameter_group_name: cmdGroupNames[0],
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
};
|
||||
submitTasking();
|
||||
}
|
||||
}
|
||||
}
|
||||
const onSubmitAliasCommandLine = (message) => {
|
||||
if(selectedData.current.length === 0){
|
||||
snackActions.warning("No callbacks selected");
|
||||
return;
|
||||
}
|
||||
const trimmed = message.trim();
|
||||
const command = trimmed.split(/\s+/)[0];
|
||||
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
|
||||
finalTaskedParameters.current = params;
|
||||
taskingData.current = {
|
||||
cmd: command,
|
||||
callback_id: callback.id,
|
||||
openDialog: false,
|
||||
parameters: params,
|
||||
tasking_location: "alias",
|
||||
dontShowSuccessDialog: false,
|
||||
};
|
||||
submitTasking();
|
||||
};
|
||||
const changeSelectedToken = (token) => {
|
||||
if(token === "Default Token"){
|
||||
setSelectedToken("Default Token");
|
||||
@@ -286,33 +219,46 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Task Multiple {callback.payload.payloadtype.name} Callbacks at Once</DialogTitle>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData} />
|
||||
<Grid item xs={12} >
|
||||
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine}
|
||||
changeSelectedToken={changeSelectedToken}
|
||||
payloadtype_name={callback.payload.payloadtype.name}
|
||||
filterOptions={{}} callback_id={callback.id} callback_os={callback.payload.os} parentMountedRef={mountedRef} />
|
||||
</Grid>
|
||||
{openTaskingButton &&
|
||||
<TaskFromUIButton cmd={taskingData.current?.cmd}
|
||||
callback_id={taskingData?.current?.callback_id || 0}
|
||||
callback_ids={taskingData?.current?.callback_ids || undefined}
|
||||
parameters={taskingData.current?.parameters || ""}
|
||||
openDialog={taskingData.current?.openDialog || false}
|
||||
tasking_location={taskingData.current?.tasking_location || "command_line"}
|
||||
dontShowSuccessDialog={taskingData.current?.dontShowSuccessDialog || false}
|
||||
selectCallback={taskingData.current?.selectCallback || false}
|
||||
onTasked={onTasked}/>
|
||||
}
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Task Multiple {callback.payload.payloadtype.name} Callbacks at Once</DialogTitle>
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CallbacksTabsSelectTable
|
||||
includeAgent={false}
|
||||
initialData={initialData}
|
||||
loading={loading}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'display_id', sort: 'desc' }}
|
||||
tableId="task-multiple-callbacks-table"
|
||||
tableLabel={`Task multiple ${callback.payload.payloadtype.name} callbacks`}
|
||||
/>
|
||||
<Grid size={12}>
|
||||
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine}
|
||||
changeSelectedToken={changeSelectedToken}
|
||||
payloadtype_name={callback.payload.payloadtype.name}
|
||||
hide_context={true}
|
||||
filterOptions={{}} callback_id={callback.id} callback_display_id={callback.display_id}
|
||||
operation_id={callback.operation_id || initialData[0]?.operation_id}
|
||||
callback_os={callback.payload.os} parentMountedRef={mountedRef} />
|
||||
</Grid>
|
||||
{openTaskingButton &&
|
||||
<TaskFromUIButton cmd={taskingData.current?.cmd}
|
||||
callback_id={taskingData?.current?.callback_id || 0}
|
||||
callback_display_ids={taskingData?.current?.callback_display_ids || undefined}
|
||||
parameters={taskingData.current?.parameters || ""}
|
||||
openDialog={taskingData.current?.openDialog || false}
|
||||
tasking_location={taskingData.current?.tasking_location || "command_line"}
|
||||
selected_task_references={taskingData.current?.selected_task_references || []}
|
||||
dontShowSuccessDialog={taskingData.current?.dontShowSuccessDialog || false}
|
||||
selectCallback={taskingData.current?.selectCallback || false}
|
||||
onTasked={onTasked}/>
|
||||
}
|
||||
<DialogActions>
|
||||
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
|
||||
Cancel
|
||||
</MythicActionButton>
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
|
||||
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
|
||||
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { TaskDisplay } from './TaskDisplay';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {TaskParametersDialog} from './TaskParametersDialog';
|
||||
import {CallbacksTabsTaskingInput} from './CallbacksTabsTaskingInput';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import { IconButton} from '@mui/material';
|
||||
import AutorenewIcon from '@mui/icons-material/Autorenew';
|
||||
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
|
||||
import {createTaskingMutation} from "./CallbackMutations";
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
import {useTaskReferenceSubmitter} from "./taskingReferences";
|
||||
import {useCallbackTaskingData} from "./useCallbackTaskingData";
|
||||
|
||||
|
||||
export function CallbacksTabsTaskingLabel(props){
|
||||
@@ -48,7 +49,7 @@ export function CallbacksTabsTaskingLabel(props){
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<MythicTabLabel label={description} onDragTab={props.onDragTab} me={props.me} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
<MythicTabLabel highlight={props.newDataForTab[props.tabInfo.tabID]} label={description} onDragTab={props.onDragTab} me={props.me} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
{openEditDescriptionDialog &&
|
||||
<MythicDialog fullWidth={true} open={openEditDescriptionDialog} onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
innerDialog={
|
||||
@@ -60,36 +61,11 @@ export function CallbacksTabsTaskingLabel(props){
|
||||
)
|
||||
}
|
||||
|
||||
// this is to listen for the latest tasking
|
||||
const fetchLimit = 30;
|
||||
const getTaskingQuery = gql`
|
||||
${taskingDataFragment}
|
||||
subscription getTasking($callback_id: Int!, $fromNow: timestamp!, $limit: Int){
|
||||
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}, timestamp: {_gt: $fromNow}}, order_by: {id: desc}, limit: $limit) {
|
||||
...taskData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const getNextBatchTaskingQuery = gql`
|
||||
${taskingDataFragment}
|
||||
query getBatchTasking($callback_id: Int!, $offset: Int!, $fetchLimit: Int!){
|
||||
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}}, order_by: {id: desc}, limit: $fetchLimit, offset: $offset) {
|
||||
...taskData
|
||||
}
|
||||
callback(where: {id: {_eq: $callback_id}}){
|
||||
id
|
||||
display_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me, collapseTaskRequest}) =>{
|
||||
const [taskLimit, setTaskLimit] = React.useState(20);
|
||||
export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me, collapseTaskRequest, setNewDataForTab}) =>{
|
||||
const active = index === value;
|
||||
const [scrollToBottom, setScrollToBottom] = React.useState(false);
|
||||
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
|
||||
const [commandInfo, setCommandInfo] = React.useState({});
|
||||
const [taskingData, setTaskingData] = React.useState({task: []});
|
||||
const taskingDataRef = React.useRef({task: []});
|
||||
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
|
||||
const [selectedToken, setSelectedToken] = React.useState({});
|
||||
const [filterOptions, setFilterOptions] = React.useState({
|
||||
"operatorsList": [],
|
||||
@@ -99,13 +75,7 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
"everythingButList": [],
|
||||
"hideErrors": false
|
||||
});
|
||||
const [canScroll, setCanScroll] = React.useState(true);
|
||||
const mountedRef = React.useRef(true);
|
||||
useEffect( () => {
|
||||
taskingDataRef.current = taskingData;
|
||||
}, [taskingData]);
|
||||
const [fetched, setFetched] = React.useState(false);
|
||||
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const newlyIssuedTasks = useRef([]);
|
||||
const [createTask] = useMutation(createTaskingMutation, {
|
||||
@@ -121,156 +91,40 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
console.error(data);
|
||||
}
|
||||
});
|
||||
const equalTaskTrees = (oldArray, newArray) => {
|
||||
if(oldArray.length !== newArray.length){
|
||||
return false;
|
||||
}
|
||||
for(let i = 0; i < oldArray.length; i++){
|
||||
if(oldArray[i].comment !== newArray[i].comment){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].commentOperator !== newArray[i].commentOperator){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].completed !== newArray[i].completed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].display_params !== newArray[i].display_params){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].original_params !== newArray[i].original_params){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].status !== newArray[i].status){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].timestamp !== newArray[i].timestamp){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_pre_bypassed !== newArray[i].opsec_pre_bypassed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_post_blocked !== newArray[i].opsec_post_blocked){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_post_bypassed !== newArray[i].opsec_post_bypassed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].tasks.length !== newArray[i].tasks.length){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].tags.length !== newArray[i].tags.length){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const subscriptionDataCallback = ({data}) => {
|
||||
if((mountedRef && !mountedRef.current) || (parentMountedRef && !parentMountedRef.current)){
|
||||
return null;
|
||||
}
|
||||
if(!fetched){
|
||||
setFetched(true);
|
||||
}
|
||||
//console.log("new subscription data in CallbacksTabsTasking", subscriptionData);
|
||||
const oldLength = taskingDataRef.current.task.length;
|
||||
const mergedData = data.data.task.reduce( (prev, cur) => {
|
||||
const index = prev.findIndex(element => element.id === cur.id);
|
||||
if(index > -1){
|
||||
// need to update an element
|
||||
const updated = prev.map( (element) => {
|
||||
if(element.id === cur.id){
|
||||
return cur;
|
||||
}else{
|
||||
return element;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}else{
|
||||
return [...prev, cur];
|
||||
}
|
||||
}, [...taskingDataRef.current.task]);
|
||||
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
|
||||
if(!equalTaskTrees(taskingDataRef.current.task, mergedData)){
|
||||
setTaskingData({task: mergedData});
|
||||
}
|
||||
if(mergedData.length > oldLength){
|
||||
setCanScroll(true);
|
||||
}
|
||||
if(mergedData.length > taskLimit){
|
||||
setTaskLimit(mergedData.length);
|
||||
}
|
||||
}
|
||||
useSubscription(getTaskingQuery, {
|
||||
variables: {callback_id: tabInfo.callbackID, fromNow:fromNow, limit: taskLimit},
|
||||
onError: data => {
|
||||
console.error(data)
|
||||
},
|
||||
fetchPolicy: "no-cache",
|
||||
onData: subscriptionDataCallback});
|
||||
const [getInfiniteScrollTasking, {loading: loadingMore}] = useLazyQuery(getNextBatchTaskingQuery, {
|
||||
onError: data => {
|
||||
console.error(data);
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
let foundNew = false;
|
||||
if(data.callback.length === 0){
|
||||
onCloseTab(tabInfo);
|
||||
return;
|
||||
}
|
||||
const mergedData = data.task.reduce( (prev, cur) => {
|
||||
const index = prev.findIndex(element => element.id === cur.id);
|
||||
if(index > -1){
|
||||
// need to update an element
|
||||
const updated = prev.map( (element) => {
|
||||
if(element.id === cur.id){
|
||||
return cur;
|
||||
}else{
|
||||
return element;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}else{
|
||||
foundNew = true;
|
||||
return [...prev, cur];
|
||||
}
|
||||
}, [...taskingData.task]);
|
||||
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
|
||||
setTaskingData({task: mergedData});
|
||||
if(!foundNew){
|
||||
setFetchedAllTasks(true);
|
||||
}else{
|
||||
if(data.task.length < fetchLimit){
|
||||
setFetchedAllTasks(true);
|
||||
}else{
|
||||
setFetchedAllTasks(false);
|
||||
}
|
||||
}
|
||||
if(!scrollToBottom){setScrollToBottom(true)}
|
||||
},
|
||||
fetchPolicy: "no-cache"
|
||||
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
|
||||
const onBackgroundChange = React.useCallback(() => {
|
||||
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {...previous, [tabInfo.tabID]: true});
|
||||
}, [setNewDataForTab, tabInfo.tabID]);
|
||||
const {
|
||||
fetched,
|
||||
fetchedAllTasks,
|
||||
loadingMore,
|
||||
loadMoreTasks,
|
||||
taskChildrenStore,
|
||||
tasks,
|
||||
} = useCallbackTaskingData({
|
||||
callbackID: tabInfo.callbackID,
|
||||
active,
|
||||
onBackgroundChange,
|
||||
onMissingCallback: () => onCloseTab(tabInfo),
|
||||
});
|
||||
const taskingData = React.useMemo(() => ({task: tasks}), [tasks]);
|
||||
useEffect( () => {
|
||||
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
|
||||
setCanScroll(true);
|
||||
return() => {
|
||||
mountedRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
useEffect( () => {
|
||||
if(scrollToBottom){
|
||||
messagesEndRef.current.scrollIntoView();
|
||||
if(fetched && tasks.length > 0 && !scrollToBottom){
|
||||
setScrollToBottom(true);
|
||||
}
|
||||
}, [scrollToBottom]);
|
||||
const loadMoreTasks = () => {
|
||||
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
|
||||
}
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
|
||||
//console.log(message, cmd, parsed);
|
||||
if(scrollToBottom){
|
||||
messagesEndRef.current?.scrollIntoView();
|
||||
}
|
||||
}, [fetched, scrollToBottom, tasks.length]);
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
|
||||
console.log(message, cmd, parsed);
|
||||
let params = message.split(" ");
|
||||
delete params[0];
|
||||
params = params.join(" ").trim();
|
||||
@@ -282,12 +136,13 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
}
|
||||
if(cmd.commandparameters.length === 0){
|
||||
// if there are no parameters, just send whatever the user types along
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd.cmd,
|
||||
params: params,
|
||||
parameter_group_name: "Default",
|
||||
tasking_location: newTaskingLocation,
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
});
|
||||
}else{
|
||||
// check if there's a "file" component that needs to be displayed
|
||||
@@ -322,42 +177,93 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
if(fileParamExists || force_parsed_popup || missingRequiredPrams){
|
||||
//need to do a popup
|
||||
if(cmdGroupNames.length > 0){
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0]});
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
|
||||
}else{
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed});
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
|
||||
}
|
||||
setOpenParametersDialog(true);
|
||||
|
||||
}else{
|
||||
delete parsed["_"];
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd.cmd,
|
||||
params: JSON.stringify(parsed),
|
||||
tasking_location: newTaskingLocation,
|
||||
original_params: params,
|
||||
parameter_group_name: cmdGroupNames[0],
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
taskReferenceEditCommandInfo: {...cmd, parsedParameters: parsed, groupName: cmdGroupNames[0], taskReferenceOptions},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
|
||||
const onSubmitAliasCommandLine = (message, alias) => {
|
||||
const trimmed = message.trim();
|
||||
const command = trimmed.split(/\s+/)[0];
|
||||
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
|
||||
onCreateTask({
|
||||
callback_display_id: tabInfo.displayID,
|
||||
command,
|
||||
params,
|
||||
parameter_group_name: "Default",
|
||||
tasking_location: "alias",
|
||||
payload_type: alias?.payloadtype?.name || tabInfo.payloadtype,
|
||||
});
|
||||
};
|
||||
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type, metadata={}) => {
|
||||
setOpenParametersDialog(false);
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
const selectedTaskReferences = [
|
||||
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
|
||||
...(metadata.selectedTaskReferences || []),
|
||||
];
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd,
|
||||
params: parameters,
|
||||
files: files,
|
||||
tasking_location: "modal",
|
||||
parameter_group_name: selectedParameterGroup,
|
||||
payload_type: payload_type
|
||||
payload_type: payload_type,
|
||||
selected_task_references: selectedTaskReferences,
|
||||
taskReferenceEditCommandInfo: commandInfo,
|
||||
});
|
||||
}
|
||||
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
|
||||
if(selectedToken.token_id !== undefined){
|
||||
createTask({variables: {callback_id, command, params, files, token_id: selectedToken.token_id, tasking_location, original_params, parameter_group_name, payload_type}});
|
||||
}else{
|
||||
createTask({variables: {callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}});
|
||||
const getTaskReferenceEditParameters = (variables) => {
|
||||
try{
|
||||
const parsedParameters = JSON.parse(variables.params || "{}");
|
||||
if(parsedParameters && typeof parsedParameters === "object" && !Array.isArray(parsedParameters)){
|
||||
return parsedParameters;
|
||||
}
|
||||
}catch(error){
|
||||
// fall through to an empty parameter object
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const openTaskReferenceEditDialog = (variables, commandContext) => {
|
||||
if(!commandContext?.id){
|
||||
return;
|
||||
}
|
||||
const selectedTaskReferences = variables.selected_task_references || commandContext.taskReferenceOptions?.selected_task_references || [];
|
||||
setCommandInfo({
|
||||
...commandContext,
|
||||
parsedParameters: getTaskReferenceEditParameters(variables),
|
||||
groupName: variables.parameter_group_name || commandContext.groupName,
|
||||
taskReferenceOptions: {
|
||||
...(commandContext.taskReferenceOptions || {}),
|
||||
selected_task_references: selectedTaskReferences,
|
||||
},
|
||||
});
|
||||
setOpenParametersDialog(true);
|
||||
}
|
||||
const onCreateTask = ({callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references, taskReferenceEditCommandInfo}) => {
|
||||
const variables = {callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references};
|
||||
if(selectedToken.token_id !== undefined){
|
||||
variables.token_id = selectedToken.token_id;
|
||||
}
|
||||
submitTask({
|
||||
variables,
|
||||
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
|
||||
});
|
||||
}
|
||||
const onSubmitFilter = (newFilter) => {
|
||||
setFilterOptions(newFilter);
|
||||
@@ -381,21 +287,22 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
<MythicTabPanel index={index} value={value} >
|
||||
{!fetched && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
|
||||
{loadingMore && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
|
||||
{taskReferenceSubmitDialog}
|
||||
<div style={{overflowY: "auto", flexGrow: 1, width: "100%"}} id={`taskingPanel${tabInfo.callbackID}`}>
|
||||
{!fetchedAllTasks &&
|
||||
<MythicStyledTooltip tooltipStyle={{marginLeft: "50%"}} title="Fetch Older Tasks">
|
||||
<IconButton
|
||||
<MythicStyledTooltip tooltipStyle={{marginLeft: "50%", width: "100%"}} title="Fetch Older Tasks">
|
||||
<MythicActionButton colorMode="always" tone="success" iconOnly
|
||||
onClick={loadMoreTasks}
|
||||
variant="contained"
|
||||
color="success"
|
||||
size="large"><AutorenewIcon/></IconButton>
|
||||
size="large"><AutorenewIcon/></MythicActionButton>
|
||||
</MythicStyledTooltip>}
|
||||
{
|
||||
taskingData.task.map((task) => (
|
||||
<TaskDisplay key={"taskinteractdisplay" + task.id} me={me} task={task}
|
||||
command_id={task.command == null ? 0 : task.command.id}
|
||||
collapseAllRequest={collapseAllRequest}
|
||||
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}/>
|
||||
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
|
||||
taskChildrenStore={taskChildrenStore} active={active}/>
|
||||
))
|
||||
}
|
||||
<div ref={messagesEndRef}/>
|
||||
@@ -404,16 +311,20 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
<CallbacksTabsTaskingInput filterTasks={true} me={me}
|
||||
onSubmitFilter={onSubmitFilter}
|
||||
onSubmitCommandLine={onSubmitCommandLine}
|
||||
onSubmitAliasCommandLine={onSubmitAliasCommandLine}
|
||||
changeSelectedToken={changeSelectedToken}
|
||||
filterOptions={filterOptions}
|
||||
focus={index === value}
|
||||
callback_id={tabInfo.callbackID}
|
||||
callback_display_id={tabInfo.displayID}
|
||||
operation_id={tabInfo.operation_id}
|
||||
payloadtype_name={tabInfo.payloadtype}
|
||||
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
|
||||
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
|
||||
{openParametersDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
|
||||
onClose={()=>{setOpenParametersDialog(false);}}
|
||||
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
|
||||
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
|
||||
callback_display_id={tabInfo.displayID}
|
||||
payloadtype_id={tabInfo.payloadtype_id} operation_id={tabInfo.operation_id}
|
||||
onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}}
|
||||
/>}
|
||||
@@ -422,4 +333,4 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
|
||||
</MythicTabPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
|
||||
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
|
||||
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
|
||||
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
|
||||
import React, {useEffect, useRef} from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { TaskDisplayConsole } from './TaskDisplay';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {TaskParametersDialog} from './TaskParametersDialog';
|
||||
import {CallbacksTabsTaskingInput} from './CallbacksTabsTaskingInput';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import { IconButton} from '@mui/material';
|
||||
import AutorenewIcon from '@mui/icons-material/Autorenew';
|
||||
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
|
||||
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
|
||||
import {createTaskingMutation} from "./CallbackMutations";
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
import {useTaskReferenceSubmitter} from "./taskingReferences";
|
||||
import {useCallbackTaskingData} from "./useCallbackTaskingData";
|
||||
|
||||
|
||||
export function CallbacksTabsTaskingConsoleLabel(props){
|
||||
@@ -48,7 +49,7 @@ export function CallbacksTabsTaskingConsoleLabel(props){
|
||||
]);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MythicTabLabel label={description} onDragTab={props.onDragTab} me={props.me} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
<MythicTabLabel label={description} highlight={props.newDataForTab[props.tabInfo.tabID]} onDragTab={props.onDragTab} me={props.me} {...props} contextMenuOptions={contextMenuOptions}/>
|
||||
{openEditDescriptionDialog &&
|
||||
<MythicDialog fullWidth={true} open={openEditDescriptionDialog} onClose={() => {setOpenEditDescriptionDialog(false);}}
|
||||
innerDialog={
|
||||
@@ -60,36 +61,11 @@ export function CallbacksTabsTaskingConsoleLabel(props){
|
||||
)
|
||||
}
|
||||
|
||||
// this is to listen for the latest tasking
|
||||
const fetchLimit = 20;
|
||||
const getTaskingQuery = gql`
|
||||
${taskingDataFragment}
|
||||
subscription getTasking($callback_id: Int!, $fromNow: timestamp!, $limit: Int){
|
||||
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}, timestamp: {_gt: $fromNow}}, order_by: {id: desc}, limit: $limit) {
|
||||
...taskData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const getNextBatchTaskingQuery = gql`
|
||||
${taskingDataFragment}
|
||||
query getBatchTasking($callback_id: Int!, $offset: Int!, $fetchLimit: Int!){
|
||||
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}}, order_by: {id: desc}, limit: $fetchLimit, offset: $offset) {
|
||||
...taskData
|
||||
}
|
||||
callback(where: {id: {_eq: $callback_id}}){
|
||||
id
|
||||
display_id
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me}) =>{
|
||||
const [taskLimit, setTaskLimit] = React.useState(10);
|
||||
export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me, setNewDataForTab}) =>{
|
||||
const active = index === value;
|
||||
const [scrollToBottom, setScrollToBottom] = React.useState(false);
|
||||
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
|
||||
const [commandInfo, setCommandInfo] = React.useState({});
|
||||
const [taskingData, setTaskingData] = React.useState({task: []});
|
||||
const taskingDataRef = React.useRef({task: []});
|
||||
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
|
||||
const [selectedToken, setSelectedToken] = React.useState({});
|
||||
const [filterOptions, setFilterOptions] = React.useState({
|
||||
"operatorsList": [],
|
||||
@@ -99,13 +75,7 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
"everythingButList": [],
|
||||
"hideErrors": false
|
||||
});
|
||||
const [canScroll, setCanScroll] = React.useState(true);
|
||||
const mountedRef = React.useRef(true);
|
||||
useEffect( () => {
|
||||
taskingDataRef.current = taskingData;
|
||||
}, [taskingData]);
|
||||
const [fetched, setFetched] = React.useState(false);
|
||||
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const newlyIssuedTasks = useRef([]);
|
||||
const [createTask] = useMutation(createTaskingMutation, {
|
||||
@@ -121,156 +91,39 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
console.error(data);
|
||||
}
|
||||
});
|
||||
const equalTaskTrees = (oldArray, newArray) => {
|
||||
if(oldArray.length !== newArray.length){
|
||||
return false;
|
||||
}
|
||||
for(let i = 0; i < oldArray.length; i++){
|
||||
if(oldArray[i].comment !== newArray[i].comment){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].commentOperator !== newArray[i].commentOperator){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].completed !== newArray[i].completed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].display_params !== newArray[i].display_params){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].original_params !== newArray[i].original_params){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].status !== newArray[i].status){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].timestamp !== newArray[i].timestamp){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_pre_bypassed !== newArray[i].opsec_pre_bypassed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_post_blocked !== newArray[i].opsec_post_blocked){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].opsec_post_bypassed !== newArray[i].opsec_post_bypassed){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].tasks.length !== newArray[i].tasks.length){
|
||||
return false;
|
||||
}
|
||||
if(oldArray[i].tags.length !== newArray[i].tags.length){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const subscriptionDataCallback = ({data}) => {
|
||||
if((mountedRef && !mountedRef.current) || (parentMountedRef && !parentMountedRef.current)){
|
||||
return null;
|
||||
}
|
||||
if(!fetched){
|
||||
setFetched(true);
|
||||
}
|
||||
//console.log("new subscription data in CallbacksTabsTasking", subscriptionData);
|
||||
const oldLength = taskingDataRef.current.task.length;
|
||||
const mergedData = data.data.task.reduce( (prev, cur) => {
|
||||
const index = prev.findIndex(element => element.id === cur.id);
|
||||
if(index > -1){
|
||||
// need to update an element
|
||||
const updated = prev.map( (element) => {
|
||||
if(element.id === cur.id){
|
||||
return cur;
|
||||
}else{
|
||||
return element;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}else{
|
||||
return [...prev, cur];
|
||||
}
|
||||
}, [...taskingDataRef.current.task]);
|
||||
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
|
||||
if(!equalTaskTrees(taskingDataRef.current.task, mergedData)){
|
||||
setTaskingData({task: mergedData});
|
||||
}
|
||||
if(mergedData.length > oldLength){
|
||||
setCanScroll(true);
|
||||
}
|
||||
if(mergedData.length > taskLimit){
|
||||
setTaskLimit(mergedData.length);
|
||||
}
|
||||
}
|
||||
useSubscription(getTaskingQuery, {
|
||||
variables: {callback_id: tabInfo.callbackID, fromNow:fromNow, limit: taskLimit},
|
||||
shouldResubscribe: true,
|
||||
onError: data => {
|
||||
console.error(data)
|
||||
},
|
||||
fetchPolicy: "no-cache",
|
||||
onData: subscriptionDataCallback});
|
||||
const [getInfiniteScrollTasking, {loading: loadingMore}] = useLazyQuery(getNextBatchTaskingQuery, {
|
||||
onError: data => {
|
||||
console.error(data);
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
let foundNew = false;
|
||||
if(data.callback.length === 0){
|
||||
onCloseTab(tabInfo);
|
||||
return;
|
||||
}
|
||||
const mergedData = data.task.reduce( (prev, cur) => {
|
||||
const index = prev.findIndex(element => element.id === cur.id);
|
||||
if(index > -1){
|
||||
// need to update an element
|
||||
const updated = prev.map( (element) => {
|
||||
if(element.id === cur.id){
|
||||
return cur;
|
||||
}else{
|
||||
return element;
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}else{
|
||||
foundNew = true;
|
||||
return [...prev, cur];
|
||||
}
|
||||
}, [...taskingData.task]);
|
||||
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
|
||||
setTaskingData({task: mergedData});
|
||||
if(!foundNew){
|
||||
setFetchedAllTasks(true);
|
||||
}else{
|
||||
if(data.task.length < fetchLimit){
|
||||
setFetchedAllTasks(true);
|
||||
}else{
|
||||
setFetchedAllTasks(false);
|
||||
}
|
||||
}
|
||||
if(!scrollToBottom){setScrollToBottom(true)}
|
||||
},
|
||||
fetchPolicy: "no-cache"
|
||||
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
|
||||
const onBackgroundChange = React.useCallback(() => {
|
||||
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {...previous, [tabInfo.tabID]: true});
|
||||
}, [setNewDataForTab, tabInfo.tabID]);
|
||||
const {
|
||||
fetched,
|
||||
fetchedAllTasks,
|
||||
loadingMore,
|
||||
loadMoreTasks,
|
||||
taskChildrenStore,
|
||||
tasks,
|
||||
} = useCallbackTaskingData({
|
||||
callbackID: tabInfo.callbackID,
|
||||
active,
|
||||
onBackgroundChange,
|
||||
onMissingCallback: () => onCloseTab(tabInfo),
|
||||
});
|
||||
const taskingData = React.useMemo(() => ({task: tasks}), [tasks]);
|
||||
useEffect( () => {
|
||||
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
|
||||
setCanScroll(true);
|
||||
return() => {
|
||||
mountedRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
useEffect( () => {
|
||||
if(scrollToBottom){
|
||||
messagesEndRef.current.scrollIntoView();
|
||||
if(fetched && tasks.length > 0 && !scrollToBottom){
|
||||
setScrollToBottom(true);
|
||||
}
|
||||
}, [scrollToBottom]);
|
||||
const loadMoreTasks = () => {
|
||||
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
|
||||
}
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
|
||||
if(scrollToBottom){
|
||||
messagesEndRef.current?.scrollIntoView();
|
||||
}
|
||||
}, [fetched, scrollToBottom, tasks.length]);
|
||||
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
|
||||
//console.log(message, cmd, parsed);
|
||||
let params = message.split(" ");
|
||||
delete params[0];
|
||||
@@ -283,12 +136,13 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
}
|
||||
if(cmd.commandparameters.length === 0){
|
||||
// if there are no parameters, just send whatever the user types along
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd.cmd,
|
||||
params: params,
|
||||
parameter_group_name: "Default",
|
||||
tasking_location: newTaskingLocation,
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
});
|
||||
}else{
|
||||
// check if there's a "file" component that needs to be displayed
|
||||
@@ -323,42 +177,93 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
if(fileParamExists || force_parsed_popup || missingRequiredPrams){
|
||||
//need to do a popup
|
||||
if(cmdGroupNames.length > 0){
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0]});
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
|
||||
}else{
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed});
|
||||
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
|
||||
}
|
||||
setOpenParametersDialog(true);
|
||||
|
||||
}else{
|
||||
delete parsed["_"];
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd.cmd,
|
||||
params: JSON.stringify(parsed),
|
||||
tasking_location: newTaskingLocation,
|
||||
original_params: params,
|
||||
parameter_group_name: cmdGroupNames[0],
|
||||
payload_type: cmd.payloadtype?.name,
|
||||
selected_task_references: taskReferenceOptions.selected_task_references,
|
||||
taskReferenceEditCommandInfo: {...cmd, parsedParameters: parsed, groupName: cmdGroupNames[0], taskReferenceOptions},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
|
||||
const onSubmitAliasCommandLine = (message, alias) => {
|
||||
const trimmed = message.trim();
|
||||
const command = trimmed.split(/\s+/)[0];
|
||||
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
|
||||
onCreateTask({
|
||||
callback_display_id: tabInfo.displayID,
|
||||
command,
|
||||
params,
|
||||
parameter_group_name: "Default",
|
||||
tasking_location: "alias",
|
||||
payload_type: alias?.payloadtype?.name || tabInfo.payloadtype,
|
||||
});
|
||||
};
|
||||
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type, metadata={}) => {
|
||||
setOpenParametersDialog(false);
|
||||
onCreateTask({callback_id: tabInfo.displayID,
|
||||
const selectedTaskReferences = [
|
||||
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
|
||||
...(metadata.selectedTaskReferences || []),
|
||||
];
|
||||
onCreateTask({callback_display_id: tabInfo.displayID,
|
||||
command: cmd,
|
||||
params: parameters,
|
||||
files: files,
|
||||
tasking_location: "modal",
|
||||
parameter_group_name: selectedParameterGroup,
|
||||
payload_type: payload_type
|
||||
payload_type: payload_type,
|
||||
selected_task_references: selectedTaskReferences,
|
||||
taskReferenceEditCommandInfo: commandInfo,
|
||||
});
|
||||
}
|
||||
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
|
||||
if(selectedToken.token_id !== undefined){
|
||||
createTask({variables: {callback_id, command, params, files, token_id: selectedToken.token_id, tasking_location, original_params, parameter_group_name, payload_type}});
|
||||
}else{
|
||||
createTask({variables: {callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}});
|
||||
const getTaskReferenceEditParameters = (variables) => {
|
||||
try{
|
||||
const parsedParameters = JSON.parse(variables.params || "{}");
|
||||
if(parsedParameters && typeof parsedParameters === "object" && !Array.isArray(parsedParameters)){
|
||||
return parsedParameters;
|
||||
}
|
||||
}catch(error){
|
||||
// fall through to an empty parameter object
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const openTaskReferenceEditDialog = (variables, commandContext) => {
|
||||
if(!commandContext?.id){
|
||||
return;
|
||||
}
|
||||
const selectedTaskReferences = variables.selected_task_references || commandContext.taskReferenceOptions?.selected_task_references || [];
|
||||
setCommandInfo({
|
||||
...commandContext,
|
||||
parsedParameters: getTaskReferenceEditParameters(variables),
|
||||
groupName: variables.parameter_group_name || commandContext.groupName,
|
||||
taskReferenceOptions: {
|
||||
...(commandContext.taskReferenceOptions || {}),
|
||||
selected_task_references: selectedTaskReferences,
|
||||
},
|
||||
});
|
||||
setOpenParametersDialog(true);
|
||||
}
|
||||
const onCreateTask = ({callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references, taskReferenceEditCommandInfo}) => {
|
||||
const variables = {callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references};
|
||||
if(selectedToken.token_id !== undefined){
|
||||
variables.token_id = selectedToken.token_id;
|
||||
}
|
||||
submitTask({
|
||||
variables,
|
||||
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
|
||||
});
|
||||
}
|
||||
const onSubmitFilter = (newFilter) => {
|
||||
setFilterOptions(newFilter);
|
||||
@@ -377,34 +282,37 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
|
||||
{!fetched && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
|
||||
{loadingMore && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
|
||||
{taskReferenceSubmitDialog}
|
||||
<div style={{overflowY: "auto", flexGrow: 1, width: "100%"}} id={`taskingPanelConsole${tabInfo.callbackID}`}>
|
||||
{!fetchedAllTasks &&
|
||||
<MythicStyledTooltip title="Fetch Older Tasks" style={{marginLeft: "50%"}}>
|
||||
<IconButton
|
||||
<MythicActionButton colorMode="always" tone="success" iconOnly
|
||||
onClick={loadMoreTasks}
|
||||
variant="contained"
|
||||
color="success"
|
||||
|
||||
size="large"><AutorenewIcon /></IconButton>
|
||||
size="large"><AutorenewIcon /></MythicActionButton>
|
||||
</MythicStyledTooltip>}
|
||||
{
|
||||
taskingData.task.map((task) => (
|
||||
<TaskDisplayConsole key={"taskinteractdisplayconsole" + task.id} me={me} task={task}
|
||||
command_id={task.command == null ? 0 : task.command.id}
|
||||
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}/>
|
||||
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
|
||||
taskChildrenStore={taskChildrenStore} active={active}/>
|
||||
))
|
||||
}
|
||||
<div ref={messagesEndRef}/>
|
||||
</div>
|
||||
|
||||
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
|
||||
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} changeSelectedToken={changeSelectedToken}
|
||||
filterOptions={filterOptions} callback_id={tabInfo.callbackID}
|
||||
callback_display_id={tabInfo.displayID}
|
||||
operation_id={tabInfo.operation_id}
|
||||
payloadtype_name={tabInfo.payloadtype} focus={index === value}
|
||||
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
|
||||
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
|
||||
{openParametersDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
|
||||
onClose={()=>{setOpenParametersDialog(false);}}
|
||||
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
|
||||
callback_display_id={tabInfo.displayID}
|
||||
payloadtype_id={tabInfo.payloadtype_id} operation_id={tabInfo.operation_id}
|
||||
onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}}
|
||||
/>}
|
||||
@@ -413,4 +321,4 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
|
||||
</MythicTabPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
+259
-153
@@ -1,63 +1,40 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Box from '@mui/material/Box';
|
||||
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import MythicTextField from '../../MythicComponents/MythicTextField';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Select from '@mui/material/Select';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Input from '@mui/material/Input';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Select from '@mui/material/Select';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import FilterAltIcon from '@mui/icons-material/FilterAlt';
|
||||
import MythicTextField from '../../MythicComponents/MythicTextField';
|
||||
import {MythicChip, SquareChip} from '../../MythicComponents/MythicChip';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import { meState } from '../../../cache';
|
||||
import {useReactiveVar} from '@apollo/client';
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogGrid,
|
||||
MythicDialogSection,
|
||||
MythicFormField,
|
||||
MythicFormSwitchRow
|
||||
} from '../../MythicComponents/MythicDialogLayout';
|
||||
|
||||
const PREFIX = 'CallbacksTabsTaskingFilterDialog';
|
||||
|
||||
const classes = {
|
||||
formControl: `${PREFIX}-formControl`,
|
||||
chips: `${PREFIX}-chips`,
|
||||
chip: `${PREFIX}-chip`,
|
||||
noLabel: `${PREFIX}-noLabel`
|
||||
};
|
||||
|
||||
const Root = styled('div')((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.formControl}`]: {
|
||||
margin: theme.spacing(1),
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
[`& .${classes.chips}`]: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
[`& .${classes.chip}`]: {
|
||||
margin: 2,
|
||||
},
|
||||
|
||||
[`& .${classes.noLabel}`]: {
|
||||
marginTop: theme.spacing(2),
|
||||
}
|
||||
}));
|
||||
|
||||
const ITEM_HEIGHT = 48;
|
||||
const ITEM_HEIGHT = 42;
|
||||
const ITEM_PADDING_TOP = 8;
|
||||
const MenuProps = {
|
||||
PaperProps: {
|
||||
style: {
|
||||
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
|
||||
width: 250,
|
||||
maxHeight: ITEM_HEIGHT * 6 + ITEM_PADDING_TOP,
|
||||
width: 320,
|
||||
},
|
||||
},
|
||||
variant: "menu",
|
||||
@@ -72,6 +49,60 @@ query operatorQuery($operation_id: Int!) {
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const selectedLabel = (values, singular, plural = singular) => {
|
||||
if(values.length === 0){
|
||||
return "";
|
||||
}
|
||||
return `${values.length} ${values.length === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
const FilterSummaryChip = ({icon, label, muted=false}) => (
|
||||
<MythicChip
|
||||
icon={icon}
|
||||
label={label}
|
||||
size="small"
|
||||
tone={muted ? "neutral" : "primary"}
|
||||
/>
|
||||
)
|
||||
|
||||
const MultiSelectField = ({label, value, options, onChange, emptyLabel}) => {
|
||||
const renderValue = (selected) => {
|
||||
if(selected.length === 0){
|
||||
return <span className="mythic-tasking-filter-select-empty text-muted text-xs font-650">{emptyLabel}</span>
|
||||
}
|
||||
return (
|
||||
<Box className="mythic-tasking-filter-select-chips items-center flex gap-2 flex-wrap min-w-0">
|
||||
{selected.map((selectedValue) => (
|
||||
<SquareChip key={selectedValue} label={selectedValue} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl fullWidth={true} size="small" className="mythic-tasking-filter-select">
|
||||
<Select
|
||||
multiple
|
||||
displayEmpty
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
input={<OutlinedInput />}
|
||||
renderValue={renderValue}
|
||||
MenuProps={MenuProps}
|
||||
aria-label={label}
|
||||
>
|
||||
{options.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
<Checkbox color="primary" checked={value.indexOf(name) > -1} />
|
||||
<ListItemText primary={name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
export function CallbacksTabsTaskingFilterDialog(props) {
|
||||
const me = useReactiveVar(meState);
|
||||
const [onlyOperators, setOnlyOperators] = React.useState([]);
|
||||
@@ -111,7 +142,7 @@ export function CallbacksTabsTaskingFilterDialog(props) {
|
||||
const commandOptionNames = props.filterCommandOptions.map(c => c.cmd);
|
||||
setCommandOptions(commandOptionNames);
|
||||
}
|
||||
}, [props.filterOptions]);
|
||||
}, [props.filterOptions, props.filterCommandOptions]);
|
||||
const onSubmit = () => {
|
||||
props.onSubmit({
|
||||
"operatorsList": onlyOperators,
|
||||
@@ -133,17 +164,20 @@ export function CallbacksTabsTaskingFilterDialog(props) {
|
||||
setHideErrors(event.target.checked);
|
||||
}
|
||||
const handleOperatorChange = (event) => {
|
||||
setOnlyOperators(event.target.value);
|
||||
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
|
||||
setOnlyOperators(value);
|
||||
}
|
||||
const handleOnlyCommandsChange = (event) => {
|
||||
setOnlyCommands(event.target.value);
|
||||
if(event.target.value.length > 0){
|
||||
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
|
||||
setOnlyCommands(value);
|
||||
if(value.length > 0){
|
||||
setEverythingBut([]);
|
||||
}
|
||||
}
|
||||
const handleEverythingButChange = (event) => {
|
||||
setEverythingBut(event.target.value);
|
||||
if(event.target.value.length > 0){
|
||||
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
|
||||
setEverythingBut(value);
|
||||
if(value.length > 0){
|
||||
setOnlyCommands([]);
|
||||
}
|
||||
}
|
||||
@@ -153,110 +187,182 @@ export function CallbacksTabsTaskingFilterDialog(props) {
|
||||
const clearAllEverythingBut = () => {
|
||||
setEverythingBut([]);
|
||||
}
|
||||
const clearAllFilters = () => {
|
||||
setOnlyOperators([]);
|
||||
setOnlyHasComments(false);
|
||||
setOnlyCommands([]);
|
||||
setEverythingBut([]);
|
||||
setOnlyParameters("");
|
||||
setHideErrors(false);
|
||||
}
|
||||
const activeFilters = [
|
||||
selectedLabel(onlyOperators, "operator"),
|
||||
onlyHasComments ? "Has comments" : "",
|
||||
hideErrors ? "Errors hidden" : "",
|
||||
selectedLabel(onlyCommands, "included command", "included commands"),
|
||||
selectedLabel(everythingBut, "excluded command", "excluded commands"),
|
||||
onlyParameters !== "" ? "Parameter regex" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<DialogTitle id="form-dialog-title">Filter Which Tasks Are Visible in this Callback</DialogTitle>
|
||||
<DialogContent dividers={true} style={{overflow: "hidden"}}>
|
||||
<React.Fragment>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel id="operator-chip-label">Only Show Tasks by the Following Operators</InputLabel>
|
||||
<Select
|
||||
labelId="operator-chip-label"
|
||||
multiple
|
||||
id="operator-chip"
|
||||
<Box className="mythic-tasking-filter-dialog bg-surface-raised text-primary">
|
||||
<DialogTitle id="mythic-draggable-title" className="mythic-accent-dialog-title relative">
|
||||
<Box className="mythic-accent-dialog-title-row items-center flex gap-6 min-w-0">
|
||||
<Box className="mythic-accent-dialog-title-icon items-center inline-flex flex-none justify-center rounded">
|
||||
<FilterAltIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography component="div" className="mythic-tasking-filter-title-main text-header text-base font-850 leading-115">
|
||||
Task visibility filters
|
||||
</Typography>
|
||||
<Typography component="div" className="mythic-accent-dialog-title-subtitle text-header-muted text-xs font-600 leading-125">
|
||||
Control which tasks are shown for this callback.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true} className="mythic-tasking-filter-dialog-content overflow-auto">
|
||||
<MythicDialogBody compact={true}>
|
||||
<Box className="mythic-tasking-filter-summary items-center flex flex-wrap gap-3">
|
||||
{activeFilters.length > 0 ? (
|
||||
activeFilters.map((filterLabel) => (
|
||||
<FilterSummaryChip key={filterLabel} icon={<FilterAltIcon />} label={filterLabel} />
|
||||
))
|
||||
) : (
|
||||
<FilterSummaryChip muted={true} icon={<FilterAltIcon />} label="No active filters" />
|
||||
)}
|
||||
</Box>
|
||||
<MythicDialogSection
|
||||
title="Operator and task state"
|
||||
description="Narrow the task list by operator, comments, or error state."
|
||||
>
|
||||
<MythicDialogGrid minWidth="18rem">
|
||||
<MythicFormField
|
||||
label="Operators"
|
||||
description="Only show tasks created by selected operators."
|
||||
>
|
||||
<MultiSelectField
|
||||
label="Operators"
|
||||
value={onlyOperators}
|
||||
options={operatorUsernames}
|
||||
onChange={handleOperatorChange}
|
||||
input={<Input />}
|
||||
renderValue={(selected) => (
|
||||
<div className={classes.chips}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} className={classes.chip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
>
|
||||
{operatorUsernames.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
<Checkbox color="primary" checked={onlyOperators.indexOf(name) > -1} />
|
||||
<ListItemText primary={name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
Only Show Tasks with Comments: <Switch checked={onlyHasComments} onChange={handleCommentsChange} color="primary" name="Only Comments" inputProps={{'aria-label': 'primary checkbox'}}/>
|
||||
<br/>
|
||||
Hide Error Tasks:<Switch checked={hideErrors} onChange={handleHideErrorsChange} color="primary" name="Hide Errors" inputProps={{'aria-label': 'primary checkbox'}}/>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel id="include-chip-label">Only Show These Commands</InputLabel>
|
||||
<Select
|
||||
labelId="include-chip-label"
|
||||
multiple
|
||||
id="include-chip"
|
||||
value={onlyCommands}
|
||||
onChange={handleOnlyCommandsChange}
|
||||
input={<Input />}
|
||||
renderValue={(selected) => (
|
||||
<div className={classes.chips}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} className={classes.chip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
emptyLabel="Any operator"
|
||||
/>
|
||||
</MythicFormField>
|
||||
<Box className="mythic-column-stack flex flex-column gap-4 min-w-0">
|
||||
<MythicFormSwitchRow
|
||||
label="Only tasks with comments"
|
||||
description="Require at least one comment."
|
||||
control={
|
||||
<Switch
|
||||
checked={onlyHasComments}
|
||||
onChange={handleCommentsChange}
|
||||
color="primary"
|
||||
name="Only Comments"
|
||||
inputProps={{'aria-label': 'Only tasks with comments'}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<MythicFormSwitchRow
|
||||
label="Hide error tasks"
|
||||
description="Remove tasks that are currently marked as errors."
|
||||
control={
|
||||
<Switch
|
||||
checked={hideErrors}
|
||||
onChange={handleHideErrorsChange}
|
||||
color="primary"
|
||||
name="Hide Errors"
|
||||
inputProps={{'aria-label': 'Hide error tasks'}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</MythicDialogGrid>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection
|
||||
title="Command scope"
|
||||
description="Choose commands to include, or choose commands to hide."
|
||||
>
|
||||
<Box className="mythic-tasking-filter-command-grid items-logical-start gap-5 min-w-0 w-full grid">
|
||||
<MythicFormField
|
||||
label="Only show commands"
|
||||
description="When set, only matching command names remain visible."
|
||||
>
|
||||
<MultiSelectField
|
||||
label="Only show commands"
|
||||
value={onlyCommands}
|
||||
options={commandOptions}
|
||||
onChange={handleOnlyCommandsChange}
|
||||
emptyLabel="No include filter"
|
||||
/>
|
||||
{onlyCommands.length > 0 &&
|
||||
<MythicActionButton
|
||||
className="mythic-tasking-filter-clear-button"
|
||||
compact
|
||||
tone="warning"
|
||||
onClick={clearAllOnlyCommands}
|
||||
size="small"
|
||||
startIcon={<ClearIcon fontSize="small" />}
|
||||
variant="outlined"
|
||||
>
|
||||
{commandOptions.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
<Checkbox color="primary" checked={onlyCommands.indexOf(name) > -1} />
|
||||
<ListItemText primary={name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{onlyCommands.length > 0 &&
|
||||
<Button onClick={clearAllOnlyCommands} variant={"contained"}>Clear</Button>
|
||||
}
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel id="exclude-chip-label">Do Not Show These Commands</InputLabel>
|
||||
<Select
|
||||
labelId="exclude-chip-label"
|
||||
multiple
|
||||
id="exclude-chip"
|
||||
value={everythingBut}
|
||||
onChange={handleEverythingButChange}
|
||||
input={<Input />}
|
||||
renderValue={(selected) => (
|
||||
<div className={classes.chips}>
|
||||
{selected.map((value) => (
|
||||
<Chip key={value} label={value} className={classes.chip} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
MenuProps={MenuProps}
|
||||
Clear include
|
||||
</MythicActionButton>
|
||||
}
|
||||
</MythicFormField>
|
||||
<Box className="mythic-tasking-filter-choice-divider text-xs font-750 text-muted text-center">or</Box>
|
||||
<MythicFormField
|
||||
label="Hide commands"
|
||||
description="When set, selected command names are removed from view."
|
||||
>
|
||||
<MultiSelectField
|
||||
label="Hide commands"
|
||||
value={everythingBut}
|
||||
options={commandOptions}
|
||||
onChange={handleEverythingButChange}
|
||||
emptyLabel="No exclude filter"
|
||||
/>
|
||||
{everythingBut.length > 0 &&
|
||||
<MythicActionButton
|
||||
className="mythic-tasking-filter-clear-button"
|
||||
compact
|
||||
tone="warning"
|
||||
onClick={clearAllEverythingBut}
|
||||
size="small"
|
||||
startIcon={<ClearIcon fontSize="small" />}
|
||||
variant="outlined"
|
||||
>
|
||||
{commandOptions.map((name) => (
|
||||
<MenuItem key={name} value={name}>
|
||||
<Checkbox color="primary" checked={everythingBut.indexOf(name) > -1} />
|
||||
<ListItemText primary={name} />
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
{everythingBut.length > 0 &&
|
||||
<Button onClick={clearAllEverythingBut} variant={"contained"}>Clear</Button>
|
||||
}
|
||||
<MythicTextField value={onlyParameters} onChange={onChange} name="Only Show Tasks with the Following Parameter Regex"/>
|
||||
</React.Fragment>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" >
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onSubmit} color="success" variant="contained" >
|
||||
Filter
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Root>
|
||||
Clear exclude
|
||||
</MythicActionButton>
|
||||
}
|
||||
</MythicFormField>
|
||||
</Box>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection
|
||||
title="Parameter matching"
|
||||
description="Apply a regular expression to task parameters."
|
||||
>
|
||||
<MythicTextField
|
||||
value={onlyParameters}
|
||||
onChange={onChange}
|
||||
name="Parameter regex"
|
||||
placeholder="Regex to match task parameters"
|
||||
marginBottom="0px"
|
||||
marginTop="0px"
|
||||
/>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter className="mythic-tasking-filter-dialog-actions">
|
||||
<MythicDialogButton onClick={clearAllFilters}>
|
||||
Clear all
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton onClick={onSubmit} intent="primary">
|
||||
Apply filters
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 1016 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user