mirror of
https://github.com/its-a-feature/Mythic.git
synced 2026-11-08 17:19:27 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 |
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,25 +99,25 @@ 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/Dockerfile
|
||||
@@ -141,25 +141,25 @@ 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/Dockerfile
|
||||
@@ -183,25 +183,25 @@ 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/Dockerfile
|
||||
@@ -225,27 +225,27 @@ 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/Dockerfile
|
||||
@@ -269,27 +269,27 @@ 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/Dockerfile
|
||||
@@ -313,27 +313,27 @@ 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/Dockerfile
|
||||
@@ -357,32 +357,30 @@ 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
|
||||
file: mythic-react-docker/Dockerfile
|
||||
context: .
|
||||
file: mythic-react-docker/.docker/Dockerfile
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:${{ env.VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:latest
|
||||
@@ -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,7 +435,7 @@ 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/Dockerfile
|
||||
@@ -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
|
||||
|
||||
@@ -169,4 +169,9 @@ 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
|
||||
@@ -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,12 +4,6 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
Generated
+1490
-47
File diff suppressed because it is too large.
Load diff
@@ -26,8 +26,9 @@
|
||||
"@mui/material": "^7.0.2",
|
||||
"@mui/utils": "^7.0.2",
|
||||
"@mui/x-charts": "^8.1.0",
|
||||
"@mui/x-data-grid": "^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",
|
||||
@@ -49,6 +50,7 @@
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-moment": "^1.1.3",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"react-scrollbar-size": "^5.0.0",
|
||||
@@ -58,6 +60,7 @@
|
||||
"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",
|
||||
"sql.js": "^1.13.0",
|
||||
|
||||
@@ -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>
|
||||
|
||||
+225
-61
@@ -18,22 +18,74 @@ 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",
|
||||
"CreatePayload", "Eventing", "Chat",
|
||||
].sort();
|
||||
export const operatorSettingDefaults = {
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
navBarOpen: false,
|
||||
fontFamily: "Verdana, Arial, sans-serif",
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
||||
showMedia: true,
|
||||
hideUsernames: false,
|
||||
showIP: false,
|
||||
showHostname: false,
|
||||
showOPSECBypassUsername: false,
|
||||
showCallbackGroups: false,
|
||||
taskingDisplayFields: defaultTaskingDisplayFields,
|
||||
useDisplayParamsForCLIHistory: true,
|
||||
interactType: "interactSplit",
|
||||
taskTimestampDisplayField: "timestamp",
|
||||
@@ -44,119 +96,232 @@ export const operatorSettingDefaults = {
|
||||
hideTaskingContext: false,
|
||||
taskingContextFields: ["impersonation_context", "cwd"],
|
||||
["experiment-responseStreamLimit"]: 200,
|
||||
chatSelectedChannelID: 0,
|
||||
sideShortcuts: defaultShortcuts,
|
||||
palette: {
|
||||
primary: {
|
||||
dark: "#75859b",
|
||||
light: "#75859b",
|
||||
dark: "#7dd3fc",
|
||||
light: "#2563eb",
|
||||
},
|
||||
error: {
|
||||
dark: '#bd5142',
|
||||
light: '#c42c32'
|
||||
dark: '#fb7185',
|
||||
light: '#dc2626'
|
||||
},
|
||||
success: {
|
||||
dark: '#85b089',
|
||||
light: '#0e7004',
|
||||
dark: '#34d399',
|
||||
light: '#15803d',
|
||||
},
|
||||
secondary: {
|
||||
dark: '#bebebe',
|
||||
light: '#a6a5a5'
|
||||
dark: '#94a3b8',
|
||||
light: '#64748b'
|
||||
},
|
||||
info: {
|
||||
dark: '#84b4dc',
|
||||
light: '#4990b2'
|
||||
dark: '#22d3ee',
|
||||
light: '#0891b2'
|
||||
},
|
||||
warning: {
|
||||
dark: "#dc8455",
|
||||
light: "#ffb74d",
|
||||
dark: "#fbbf24",
|
||||
light: "#b45309",
|
||||
},
|
||||
background: {
|
||||
dark: '#282828',
|
||||
light: '#f6f6f6'
|
||||
dark: '#0b0f14',
|
||||
light: '#f6f8fb'
|
||||
},
|
||||
paper: {
|
||||
dark: '#282828',
|
||||
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: '#1b2025',
|
||||
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: "#e4e4e4",
|
||||
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: "#194573",
|
||||
light: "#3b606d",
|
||||
dark: "#1a283d",
|
||||
light: "#5c73a5",
|
||||
},
|
||||
navBarBottomColor: {
|
||||
dark: "#330814",
|
||||
light: "#283581",
|
||||
dark: "#0c131c",
|
||||
light: "#1f2937",
|
||||
},
|
||||
taskPromptTextColor: {
|
||||
dark: '#bebebe',
|
||||
light: '#a6a5a5'
|
||||
dark: '#9fb3c8',
|
||||
light: '#64748b'
|
||||
},
|
||||
taskPromptCommandTextColor: {
|
||||
dark: "#e4e4e4",
|
||||
light: "#000000",
|
||||
dark: "#f8fafc",
|
||||
light: "#0f172a",
|
||||
},
|
||||
taskContextColor: {
|
||||
dark: "#122848",
|
||||
light: "#acc0da",
|
||||
dark: "#14324f",
|
||||
light: "#dbeafe",
|
||||
},
|
||||
taskContextImpersonationColor: {
|
||||
dark: "#641616",
|
||||
light: "#dec0c0",
|
||||
dark: "#4a1824",
|
||||
light: "#fee2e2",
|
||||
},
|
||||
taskContextExtraColor: {
|
||||
dark: "#2a5953",
|
||||
light: "#a7ce9d",
|
||||
dark: "#123a35",
|
||||
light: "#dcfce7",
|
||||
},
|
||||
emptyFolderColor: {
|
||||
dark: '#bebebe',
|
||||
light: '#a6a5a5'
|
||||
dark: '#8997aa',
|
||||
light: '#64748b'
|
||||
},
|
||||
folderColor: {
|
||||
dark: '#f2c86b',
|
||||
light: '#d6a23f'
|
||||
},
|
||||
outputBackgroundColor: {
|
||||
dark: '#282828',
|
||||
light: '#f6f6f6'
|
||||
dark: '#070b10',
|
||||
light: '#f8fafc'
|
||||
},
|
||||
outputTextColor: {
|
||||
dark: '#f6f6f6',
|
||||
light: '#282828',
|
||||
dark: '#e6edf3',
|
||||
light: '#111827',
|
||||
},
|
||||
borderColor: {
|
||||
dark: "#595858",
|
||||
light: "#c5c5c5"
|
||||
dark: "#263241",
|
||||
light: "#d5dee9"
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -190,7 +355,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 = {...meState().user, ...(data.user || {})};
|
||||
me.server_skew = difference;
|
||||
me.login_time = now;
|
||||
meState({
|
||||
@@ -227,4 +392,3 @@ export const FailedRefresh = (restart_websockets) =>{
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+952
-118
File diff suppressed because it is too large.
Load diff
@@ -22,7 +22,7 @@ const subscribe_payloads = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export function EventFeedNotifications(props) {
|
||||
export function EventFeedNotifications() {
|
||||
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
|
||||
//const fromNow = React.useRef( );
|
||||
useSubscription(subscribe_payloads, {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import React from 'react';
|
||||
import {Box, Button, Checkbox, Chip, InputAdornment, TextField, Typography} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import {gql, useQuery} from "@apollo/client";
|
||||
|
||||
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}}>
|
||||
<Chip className="mythic-api-token-scope-count" size="small" label={`${visibleScopeCount} visible`} />
|
||||
<Chip className="mythic-api-token-scope-count" size="small" label={selectedScopesLabel} />
|
||||
<Button disabled={scopesUnavailable || visibleScopeCount === 0 || scopeIsSelected("*")} size="small" onClick={selectVisibleScopes}>
|
||||
Select Visible
|
||||
</Button>
|
||||
<Button disabled={normalizedSelectedScopes.length === 0} size="small" onClick={clearScopes}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
{requiredScopes.length > 0 &&
|
||||
<Box className="mythic-api-token-scope-card mythic-api-token-scope-card-selected" sx={{mb: 1.25}}>
|
||||
<Box className="mythic-api-token-scope-card-copy">
|
||||
<Typography className="mythic-api-token-scope-card-title">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" sx={librarySx}>
|
||||
{scopesUnavailable &&
|
||||
<Box className={`mythic-api-token-scope-state${scopeLoadFailed ? " mythic-api-token-scope-state-error" : ""}`}>
|
||||
<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 mythic-api-token-scope-card-full${scopeIsSelected("*") ? " mythic-api-token-scope-card-selected" : ""}${fullAccessDisabled ? " mythic-api-token-scope-card-disabled" : ""}`}
|
||||
component="label"
|
||||
>
|
||||
<Checkbox
|
||||
disabled={fullAccessDisabled}
|
||||
checked={scopeIsSelected("*")}
|
||||
onChange={() => toggleScope("*")}
|
||||
/>
|
||||
<Box className="mythic-api-token-scope-card-copy">
|
||||
<Typography className="mythic-api-token-scope-card-title">Full access (*)</Typography>
|
||||
<Typography className="mythic-api-token-scope-card-description">
|
||||
Grants every current and future API scope available to this operator.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{visibleResources.length === 0 &&
|
||||
<Box className="mythic-api-token-scope-state">
|
||||
<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" key={resource}>
|
||||
<Box className="mythic-api-token-resource-header">
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography className="mythic-api-token-resource-title">
|
||||
{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${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">
|
||||
{resourceScopes.map(scope => {
|
||||
const includedByWildcard = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
|
||||
const scopeSelected = scopeIsSelected(scope.name) || includedByWildcard;
|
||||
return (
|
||||
<Box
|
||||
className={`mythic-api-token-scope-card${scopeSelected ? " mythic-api-token-scope-card-selected" : ""}${includedByWildcard ? " mythic-api-token-scope-card-inherited" : ""}`}
|
||||
component="label"
|
||||
key={scope.name}
|
||||
>
|
||||
<Checkbox
|
||||
size="small"
|
||||
disabled={includedByWildcard}
|
||||
checked={scopeSelected}
|
||||
onChange={() => toggleScope(scope.name)}
|
||||
/>
|
||||
<Box className="mythic-api-token-scope-card-copy">
|
||||
<Box className="mythic-api-token-scope-card-title-row">
|
||||
<Typography className="mythic-api-token-scope-card-title">{scope.display_name || scope.name}</Typography>
|
||||
<Chip
|
||||
className={`mythic-status-chip mythic-status-chip-compact mythic-tone-${scope.access === "read" ? "info" : ["write", "create", "update"].includes(scope.access) ? "success" : ["delete", "admin"].includes(scope.access) ? "error" : "neutral"}`}
|
||||
label={scope.access || "scope"}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Typography className="mythic-api-token-scope-name">
|
||||
{scope.name}
|
||||
</Typography>
|
||||
<Typography className="mythic-api-token-scope-card-description">
|
||||
{scope.description}
|
||||
</Typography>
|
||||
{scope.includes?.length > 0 &&
|
||||
<Typography className="mythic-api-token-scope-includes">
|
||||
Includes {scope.includes.join(", ")}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import WifiIcon from '@mui/icons-material/Wifi';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import { faLink } from '@fortawesome/free-solid-svg-icons';
|
||||
import {ImageWithAuth} from "../utilities/ImageWithAuth";
|
||||
|
||||
export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
|
||||
const theme = useTheme();
|
||||
@@ -11,8 +12,8 @@ export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
|
||||
}
|
||||
return (
|
||||
<MythicStyledTooltip title={payload_type} >
|
||||
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
|
||||
style={{...style}} />
|
||||
<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",
|
||||
@@ -35,7 +36,8 @@ export const MythicAgentSVGIconNoTooltip = ({payload_type, is_p2p, className, st
|
||||
const extraStyles = style ? style : {};
|
||||
return (
|
||||
<div style={{display: "inline-block", left: "50%"}} className={className}>
|
||||
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"} style={{height: "20px", margin: 'auto', ...extraStyles}}/>
|
||||
<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",
|
||||
|
||||
@@ -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,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,5 @@
|
||||
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';
|
||||
@@ -23,8 +21,52 @@ import {IconButton} from '@mui/material';
|
||||
import {MythicStyledTooltip} from "./MythicStyledTooltip";
|
||||
import Draggable from 'react-draggable';
|
||||
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
|
||||
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
|
||||
|
||||
export function MythicDialog(props) {
|
||||
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: {
|
||||
@@ -38,8 +80,20 @@ export function MythicDialog(props) {
|
||||
hideBackdrop: false,
|
||||
modified: false
|
||||
});
|
||||
const nodeRef = React.useRef(null);
|
||||
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;
|
||||
@@ -49,71 +103,77 @@ export function MythicDialog(props) {
|
||||
}
|
||||
}, [props.open]);
|
||||
const dialogOnClick = (e) => {
|
||||
//e.preventDefault();
|
||||
//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();
|
||||
props.onClose?.();
|
||||
}
|
||||
const onStart = (e) => {
|
||||
const onStart = React.useCallback((e, _data, paperNode) => {
|
||||
if(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
if(!draggedState.modified){
|
||||
setDraggedState({
|
||||
style: {
|
||||
height: e.target.offsetParent.offsetHeight + "px",
|
||||
width: e.target.offsetParent.offsetWidth + "px",
|
||||
margin: "auto",
|
||||
overflowY: "auto",
|
||||
},
|
||||
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: {
|
||||
//height: e.target.offsetParent.offsetHeight + "px",
|
||||
width: e.target.offsetParent.offsetWidth + "px",
|
||||
...(targetWidth > 0 ? {width: targetWidth + "px"} : {}),
|
||||
margin: 0,
|
||||
overflowY: "auto",
|
||||
},
|
||||
containerStyle: {
|
||||
height: "fit-content",
|
||||
overflowY: "auto"
|
||||
overflow: "visible",
|
||||
},
|
||||
hideBackdrop: true,
|
||||
modified: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
const onStop = (e) => {
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const onStop = React.useCallback((e) => {
|
||||
if(e){
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Draggable
|
||||
nodeRef={nodeRef}
|
||||
handle="#mythic-draggable-title"
|
||||
cancel={'[class*="MuiDialogContent-root"]'}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
>
|
||||
<Dialog
|
||||
ref={nodeRef}
|
||||
open={props.open}
|
||||
onClick={dialogOnClick}
|
||||
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}
|
||||
@@ -121,10 +181,13 @@ export function MythicDialog(props) {
|
||||
aria-labelledby="scroll-dialog-title"
|
||||
aria-describedby="scroll-dialog-description"
|
||||
sx={{
|
||||
[".MuiPaper-root"]: {
|
||||
".MuiPaper-root": {
|
||||
borderRadius: "8px",
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.borderColor}`,
|
||||
...draggedState.paperStyle
|
||||
},
|
||||
[".MuiDialog-container"]: {
|
||||
".MuiDialog-container": {
|
||||
...draggedState.containerStyle
|
||||
}
|
||||
}}
|
||||
@@ -133,10 +196,11 @@ export function MythicDialog(props) {
|
||||
>
|
||||
{props.innerDialog}
|
||||
</Dialog>
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
export {MythicDialog} from "./MythicDialogBase";
|
||||
|
||||
export function MythicModifyStringDialog(props) {
|
||||
const [comment, setComment] = React.useState("");
|
||||
const [wrap, setWrap] = React.useState(props.wrap ? props.wrap : false);
|
||||
@@ -190,162 +254,238 @@ export function MythicModifyStringDialog(props) {
|
||||
/>
|
||||
</DialogContent>
|
||||
{ (props.onClose || props.onSubmit) &&
|
||||
<DialogActions>
|
||||
<MythicDialogFooter>
|
||||
{props.onClose &&
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
}
|
||||
{props.onSubmit &&
|
||||
<Button onClick={onCommitSubmit} variant="contained" color="success">
|
||||
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
|
||||
{props.onSubmitText ? props.onSubmitText : "Submit"}
|
||||
</Button>
|
||||
</MythicDialogButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</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 <span className={`mythic-json-type-badge mythic-json-type-${type}`}>{label}</span>
|
||||
}
|
||||
|
||||
const JSONPrimitiveValue = ({name, value, me}) => {
|
||||
const type = getJSONValueType(value);
|
||||
if(type === "null" || type === "empty" || value === ""){
|
||||
return <span className="mythic-json-value-empty">None</span>
|
||||
}
|
||||
if(type === "boolean"){
|
||||
return <span className={`mythic-json-value-boolean ${value ? "mythic-json-value-true" : "mythic-json-value-false"}`}>{value ? "True" : "False"}</span>
|
||||
}
|
||||
return <span className="mythic-json-value-primitive">{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 ${depth === 0 ? "mythic-json-panel-root" : ""}`}>
|
||||
{showPanelHeader &&
|
||||
<div className="mythic-json-panel-header">
|
||||
<span className="mythic-json-panel-title">{label}</span>
|
||||
<JSONTypeBadge value={value} />
|
||||
</div>
|
||||
}
|
||||
{entries.length === 0 ? (
|
||||
<div className="mythic-json-empty-state">No fields to display.</div>
|
||||
) : (
|
||||
<TableContainer className="mythicElement mythic-json-table-wrap">
|
||||
<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">
|
||||
<span className="mythic-json-key">{key}</span>
|
||||
<JSONTypeBadge value={entryValue} />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="mythic-json-value-cell">
|
||||
<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 ${depth === 0 ? "mythic-json-panel-root" : ""}`}>
|
||||
{showPanelHeader &&
|
||||
<div className="mythic-json-panel-header">
|
||||
<span className="mythic-json-panel-title">{label}</span>
|
||||
<JSONTypeBadge value={value} />
|
||||
</div>
|
||||
}
|
||||
{value.length === 0 ? (
|
||||
<div className="mythic-json-empty-state">No items to display.</div>
|
||||
) : isObjectArray ? (
|
||||
<TableContainer className="mythicElement mythic-json-table-wrap">
|
||||
<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">{rowIndex + 1}</TableCell>
|
||||
{objectHeaders.map((header) => (
|
||||
<TableCell key={`array-row-${rowIndex}-${header}`} className="mythic-json-value-cell">
|
||||
<JSONTableCellValue name={header} value={row[header]} me={me} depth={depth} />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<TableContainer className="mythicElement mythic-json-table-wrap">
|
||||
<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">{rowIndex + 1}</TableCell>
|
||||
<TableCell className="mythic-json-value-cell">
|
||||
<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>
|
||||
<MythicDraggableDialogTitle style={{wordBreak: "break-all", maxWidth: "100%"}}>{props.title}</MythicDraggableDialogTitle>
|
||||
|
||||
<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">
|
||||
<span>{rootLabel}</span>
|
||||
<JSONTypeBadge value={parsedValue} />
|
||||
</div>
|
||||
</MythicDraggableDialogTitle>
|
||||
<DialogContent dividers={true} className="mythic-dialog-body mythic-json-dialog-body">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -389,30 +529,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);
|
||||
@@ -480,4 +624,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,169 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
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", compact && "mythic-dialog-body-compact", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogSection({title, description, actions, children, className, ...props}) {
|
||||
return (
|
||||
<Box component="section" className={joinClasses("mythic-dialog-section", className)} {...props}>
|
||||
{(title || description || actions) &&
|
||||
<Box className="mythic-dialog-section-header">
|
||||
<Box sx={{minWidth: 0}}>
|
||||
{title &&
|
||||
<Typography component="h3" className="mythic-dialog-section-title">
|
||||
{title}
|
||||
</Typography>
|
||||
}
|
||||
{description &&
|
||||
<Typography component="div" className="mythic-dialog-section-description">
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
{actions &&
|
||||
<Box className="mythic-dialog-section-actions">
|
||||
{actions}
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
|
||||
return (
|
||||
<Box
|
||||
className={joinClasses("mythic-dialog-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", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicDialogChoiceDivider({children = "OR", className, ...props}) {
|
||||
return (
|
||||
<Box component="span" className={joinClasses("mythic-dialog-choice-divider", 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 (
|
||||
<Button
|
||||
className={joinClasses("mythic-dialog-button", "mythic-action-tone", `mythic-tone-${tone}`, className)}
|
||||
size="small"
|
||||
variant="contained"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicForm({children, className, ...props}) {
|
||||
return (
|
||||
<Box component="form" className={joinClasses("mythic-form", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
|
||||
return (
|
||||
<Box
|
||||
className={joinClasses("mythic-form-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", className)} {...props}>
|
||||
{(label || description) &&
|
||||
<Box className="mythic-form-field-copy">
|
||||
{label &&
|
||||
<Typography component="label" className="mythic-form-field-label">
|
||||
{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">
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormNote({children, className, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-form-note", className)} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function MythicFormSwitchRow({control, label, description, className, ...props}) {
|
||||
return (
|
||||
<Box className={joinClasses("mythic-form-switch-row", className)} {...props}>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography component="div" className="mythic-form-field-label">
|
||||
{label}
|
||||
</Typography>
|
||||
{description &&
|
||||
<Typography component="div" className="mythic-form-field-description">
|
||||
{description}
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
<Box className="mythic-form-switch-control">
|
||||
{control}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +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%" }}>
|
||||
<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?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor,
|
||||
}}
|
||||
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?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor, }}
|
||||
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?.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}
|
||||
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 className="mythic-process-browser-table-shell">
|
||||
<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,13 +2,13 @@ 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!){
|
||||
@@ -85,9 +85,9 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
|
||||
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={onClose}
|
||||
@@ -99,4 +99,4 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export const Dropdown = React.forwardRef(
|
||||
style,
|
||||
transformOrigin,
|
||||
anchorOrigin,
|
||||
MenuListProps,
|
||||
PaperProps,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -70,7 +72,11 @@ export const Dropdown = React.forwardRef(
|
||||
<>
|
||||
<Menu
|
||||
elevation={5}
|
||||
PaperProps={{ sx: { minWidth: minWidth ?? 0 } }}
|
||||
PaperProps={{
|
||||
...PaperProps,
|
||||
sx: { minWidth: minWidth ?? 0, ...(PaperProps?.sx || {}) }
|
||||
}}
|
||||
MenuListProps={MenuListProps}
|
||||
style={{zIndex: 100000, position: "absolute"}}
|
||||
anchorEl={anchorReference === "anchorEl" ? anchorEl : undefined}
|
||||
transition={"true"}
|
||||
@@ -254,4 +260,4 @@ export const DropdownMenuItem = styled(MenuItem)`
|
||||
|
||||
export const DropdownNestedMenuItem = styled(NestedMenuItem)`
|
||||
|
||||
`;
|
||||
`;
|
||||
@@ -5,9 +5,12 @@ export const MythicPageBody = ({children}) => {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
margin: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
gap: "0.5rem",
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,269 @@
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {Typography} from "@mui/material";
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {alpha, useTheme} from '@mui/material/styles';
|
||||
|
||||
export const MythicPageHeader = ({children, title, style={}, headerVariant="h5"}) => {
|
||||
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 elevation={2}
|
||||
variant={"elevation"}
|
||||
style={{
|
||||
backgroundColor: theme.pageHeader.main,
|
||||
color: theme.pageHeaderText.main,
|
||||
border: `1px solid ${theme.borderColor}`,
|
||||
alignItems: "center",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "0.5rem",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "5px",
|
||||
...style
|
||||
}}
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<Typography variant={headerVariant} style={{textAlign: "left", display: "inline-block", marginLeft: "1rem", width: "100%"}}>
|
||||
{title}
|
||||
</Typography>
|
||||
<div style={{display: "flex", marginRight: "1rem", alignItems: "center"}}>
|
||||
{children}
|
||||
</div>
|
||||
<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 (
|
||||
<Chip
|
||||
size="small"
|
||||
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"
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -13,13 +13,22 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
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();
|
||||
@@ -29,22 +38,24 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
}
|
||||
};
|
||||
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();
|
||||
@@ -52,10 +63,24 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
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();
|
||||
@@ -83,7 +108,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
|
||||
);
|
||||
return (
|
||||
<div style={{...style, ...cellStyle, ...rowStyle}}
|
||||
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass}`}
|
||||
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -126,6 +151,7 @@ export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOpti
|
||||
<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}
|
||||
@@ -136,11 +162,12 @@ export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOpti
|
||||
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>
|
||||
))
|
||||
@@ -183,4 +210,4 @@ export default Cell;
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
*/
|
||||
*/
|
||||
@@ -1,59 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import {classes} from './styles';
|
||||
import Draggable from 'react-draggable';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
|
||||
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={nodeRef}
|
||||
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);
|
||||
}}>
|
||||
<DragHandleIcon
|
||||
ref={nodeRef}
|
||||
className={isDragging === i ? classes.draggableHandlesClickAreaSelected : classes.draggableHandlesClickArea}
|
||||
style={{
|
||||
left: leftOffset + columnWidths[i] - 1 - 7,
|
||||
rotate: isDragging === i ? "0deg" : "90deg",
|
||||
}}>
|
||||
</DragHandleIcon>
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default DraggableHandles;
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
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 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">
|
||||
<DialogTitle className="mythic-grid-filter-dialog-title">
|
||||
Filter {columnName}
|
||||
</DialogTitle>
|
||||
<DialogContent className="mythic-grid-filter-dialog-content">
|
||||
<Typography className="mythic-grid-filter-dialog-copy" 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">
|
||||
<Typography className="mythic-grid-filter-dialog-label" 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">
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Include rows matching"
|
||||
minRows={4}
|
||||
multiline
|
||||
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">
|
||||
<Button onClick={handleClear}>Clear</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} variant="contained">Apply Filter</Button>
|
||||
</DialogActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ 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 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 = ({
|
||||
@@ -14,11 +15,16 @@ const HeaderCell = ({
|
||||
sortIndicatorIndex,
|
||||
sortDirection,
|
||||
headerNameKey = "name",
|
||||
isResizing = false,
|
||||
onResizePointerDown = () => {},
|
||||
VariableSizeGridProps: { style, rowIndex, columnIndex, data },
|
||||
}) => {
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
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) => {
|
||||
@@ -33,44 +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, clickOption) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clickOption({event, columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}});
|
||||
clickOption({event, ...menuContext});
|
||||
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>)}
|
||||
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={contextMenuOptions}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
+420
-173
@@ -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 = 100;
|
||||
const MIN_FLEX_COLUMN_WIDTH = 150;
|
||||
const AUTOSIZE_HORIZONTAL_PADDING = 44;
|
||||
const AUTOSIZE_HEADER_EXTRA_WIDTH = 28;
|
||||
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,152 +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(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: columnWidths});
|
||||
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 !== undefined){
|
||||
if(typeof itemRow[columnIndex]?.props?.cellData === 'number'){
|
||||
return String(itemRow[columnIndex]?.props?.cellData)?.length;
|
||||
}
|
||||
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(updatedColumnWidths, columnWidths, longestElementInColumn);
|
||||
let updatedValues = false;
|
||||
for(let i = 0; i < updatedColumnWidths.length; i++){
|
||||
if(updatedColumnWidths[i] !== columnWidths[i]){
|
||||
updatedValues = true;
|
||||
break;
|
||||
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
|
||||
if(didUpdateWidths){
|
||||
if(name !== undefined){
|
||||
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
|
||||
}
|
||||
}
|
||||
if(updatedValues){
|
||||
setColumnWidths(() => updatedColumnWidths);
|
||||
}, [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});
|
||||
}
|
||||
}, [columnWidths]);
|
||||
}, [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){
|
||||
@@ -281,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}
|
||||
@@ -311,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>
|
||||
|
||||
</>
|
||||
@@ -349,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -381,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 = {
|
||||
root: `${PREFIX}-root`,
|
||||
grid: `${PREFIX}-grid`,
|
||||
headerCellRow: `${PREFIX}-headerCellRow`,
|
||||
headerCell: `${PREFIX}-headerCell`,
|
||||
headerCellNoSort: `${PREFIX}-headerCellNoSort`,
|
||||
headerContent: `${PREFIX}-headerContent`,
|
||||
headerLabel: `${PREFIX}-headerLabel`,
|
||||
headerActions: `${PREFIX}-headerActions`,
|
||||
headerIndicator: `${PREFIX}-headerIndicator`,
|
||||
headerFilterIcon: `${PREFIX}-headerFilterIcon`,
|
||||
headerSortIcon: `${PREFIX}-headerSortIcon`,
|
||||
headerResizeHandle: `${PREFIX}-headerResizeHandle`,
|
||||
headerResizeHandleActive: `${PREFIX}-headerResizeHandleActive`,
|
||||
resizeGuide: `${PREFIX}-resizeGuide`,
|
||||
hoveredRow: `${PREFIX}-hoveredRow`,
|
||||
contextRow: `${PREFIX}-contextRow`,
|
||||
rowFirstCell: `${PREFIX}-rowFirstCell`,
|
||||
rowLastCell: `${PREFIX}-rowLastCell`,
|
||||
rowInteractive: `${PREFIX}-rowInteractive`,
|
||||
cell: `${PREFIX}-cell`,
|
||||
cellInner: `${PREFIX}-cellInner`,
|
||||
draggableHandlesContainer: `${PREFIX}-draggableHandlesContainer`,
|
||||
draggableHandlesClickArea: `${PREFIX}-draggableHandlesClickArea`,
|
||||
draggableHandlesClickAreaSelected: `${PREFIX}-draggableHandlesClickAreaSelected`,
|
||||
draggableHandlesIndicator: `${PREFIX}-draggableHandlesIndicator`,
|
||||
}
|
||||
@@ -17,12 +17,26 @@ export const GetComputedFontSize = () => {
|
||||
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
|
||||
|
||||
@@ -43,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() {
|
||||
@@ -57,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(),
|
||||
...getPreferenceSnapshot(),
|
||||
...settings,
|
||||
};
|
||||
mePreferences(updatedPreferences);
|
||||
latestPreferenceSnapshot = updatedPreferences;
|
||||
if(broadcast){
|
||||
mePreferences(updatedPreferences);
|
||||
}
|
||||
updateSetting({variables: {preferences: updatedPreferences}});
|
||||
},
|
||||
() => {
|
||||
latestPreferenceSnapshot = operatorSettingDefaults;
|
||||
mePreferences(operatorSettingDefaults);
|
||||
updateSetting({variables: {preferences: operatorSettingDefaults}});
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -8,13 +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 MythicStyledTableCell from "./MythicTableCell";
|
||||
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([]);
|
||||
@@ -78,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];
|
||||
@@ -90,22 +93,33 @@ export function MythicSelectFromRawListDialog(props) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle >{props.title}</DialogTitle>
|
||||
<div style={{height: "100%", display: "flex"}}>
|
||||
<TableContainer component={Paper} className="mythicElement" style={{flexGrow: 1, overflowY: "auto"}}>
|
||||
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
<TableBody style={{whiteSpace: "pre"}}>
|
||||
{options.map( (choice, i) => (
|
||||
<TableRow hover key={choice + i}>
|
||||
<MythicStyledTableCell style={{width: "5rem"}}>
|
||||
<Button onClick={() => handleSubmit(choice)} variant="contained" color="primary">Select</Button>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>{choice}</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
<DialogContent dividers={true} className="mythic-raw-select-dialog-content">
|
||||
{options.length === 0 ? (
|
||||
<Box className="mythic-raw-select-empty">
|
||||
<Typography variant="body2">No options available</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Stack className="mythic-raw-select-list">
|
||||
{options.map( (choice, i) => (
|
||||
<Box
|
||||
key={String(choice) + i}
|
||||
className="mythic-raw-select-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSubmit(choice)}
|
||||
onKeyDown={(event) => handleKeyDown(event, choice)}
|
||||
>
|
||||
<Typography className="mythic-raw-select-value" title={String(choice)}>
|
||||
{String(choice)}
|
||||
</Typography>
|
||||
<Button className="mythic-raw-select-action mythic-action-tone mythic-tone-info" variant="outlined" size="small" tabIndex={-1}>
|
||||
{actionText}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
Close
|
||||
|
||||
@@ -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,18 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export function MythicStateChip({children, className = "", compact = false, label, state = "secondary", ...props}) {
|
||||
const chipLabel = label ?? children;
|
||||
const tone = state === "active" || state === "enabled" ? "success" :
|
||||
state === "inactive" || state === "disabled" ? "warning" :
|
||||
state === "neutral" ? "secondary" : state;
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
className={`mythic-status-chip mythic-tone-${tone}${compact ? " mythic-status-chip-compact" : ""}${className ? ` ${className}` : ""}`}
|
||||
{...props}
|
||||
>
|
||||
{chipLabel}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
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 || (
|
||||
<Button size="small" variant="outlined" onClick={onAction} sx={{mt: 0.5}}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)
|
||||
) : 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,78 @@
|
||||
import React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
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';
|
||||
|
||||
const statusConfig = {
|
||||
success: {tone: "success", icon: <CheckCircleOutlineIcon />, label: "Success"},
|
||||
error: {tone: "error", icon: <ErrorOutlineIcon />, label: "Error"},
|
||||
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"},
|
||||
deleted: {tone: "error", icon: <DeleteOutlineIcon />, label: "Deleted"},
|
||||
locked: {tone: "warning", icon: <LockOutlinedIcon />, label: "Locked"},
|
||||
building: {tone: "info", 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"},
|
||||
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 "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
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 (
|
||||
<Chip
|
||||
className={`mythic-status-chip mythic-tone-${config.tone}${variant === "outlined" ? " mythic-status-chip-outlined" : ""}${className ? ` ${className}` : ""}`}
|
||||
label={label || config.label}
|
||||
icon={chipIcon}
|
||||
size={size}
|
||||
variant="outlined"
|
||||
sx={sx}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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";
|
||||
@@ -22,6 +23,7 @@ export function MythicTabPanel(props) {
|
||||
display: value === index ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
overflowY: "auto",
|
||||
@@ -72,6 +74,7 @@ export function MythicTabLabel(props) {
|
||||
};
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const dropdownAnchorRef = React.useRef(null);
|
||||
const theme = useTheme();
|
||||
const handleContextClick = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -94,8 +97,13 @@ export function MythicTabLabel(props) {
|
||||
{(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}>
|
||||
<Badge color="success" variant="dot" invisible={!highlight} >
|
||||
@@ -109,10 +117,24 @@ export function MythicTabLabel(props) {
|
||||
{...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: "0px 6px",
|
||||
margin: "0 3px 0 0",
|
||||
minHeight: "32px",
|
||||
borderRadius: `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0`,
|
||||
borderTop: `2px solid ${selected ? tabAccent : "transparent"}`,
|
||||
borderRight: `1px solid ${theme.borderColor}`,
|
||||
borderBottom: selected ? `1px solid ${theme.palette.background.paper}` : `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
|
||||
@@ -150,10 +172,9 @@ export function MythicSearchTabLabel(props) {
|
||||
return (
|
||||
<Tab
|
||||
label={
|
||||
<span>
|
||||
{label}
|
||||
<br />
|
||||
<span className="mythic-search-tab-label">
|
||||
{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, {})({
|
||||
padding: "2px 0 2px 10px !important",
|
||||
});
|
||||
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 ${className}`.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">{summary}</Typography>
|
||||
) : totalCount !== undefined && (
|
||||
<Typography className="mythic-table-total">{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,129 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import MythicTextField from './MythicTextField';
|
||||
|
||||
export const MythicTableToolbar = ({children, className = "", style = {}, variant}) => {
|
||||
return (
|
||||
<Box className={`mythic-table-toolbar ${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 ${grow ? "mythic-table-toolbar-group-grow" : ""} ${className}`.trim()} style={style}>
|
||||
{label &&
|
||||
<span className="mythic-table-toolbar-group-label">{label}</span>
|
||||
}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarSelect = ({children, className = "", style = {}, ...props}) => {
|
||||
return (
|
||||
<Select
|
||||
className={`mythic-toolbar-select ${className}`.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}
|
||||
<Tooltip title="Search">
|
||||
<span>
|
||||
<IconButton
|
||||
className="mythic-toolbar-icon-button"
|
||||
disabled={disabled}
|
||||
onClick={onSearch}
|
||||
size="small"
|
||||
>
|
||||
<SearchIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
) : endAdornment,
|
||||
style: {padding: 0},
|
||||
...inputProps,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarButton = ({children, className = "", ...props}) => {
|
||||
return (
|
||||
<Button className={`mythic-toolbar-toggle ${className}`.trim()} size="small" {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const MythicToolbarToggle = ({
|
||||
checked,
|
||||
onClick,
|
||||
label,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon,
|
||||
inactiveIcon,
|
||||
value = "toggle",
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<ToggleButton
|
||||
className={`mythic-toolbar-toggle ${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,9 @@
|
||||
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, IconButton } from '@mui/material';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
@@ -23,20 +16,40 @@ import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import WebhookIcon from '@mui/icons-material/Webhook';
|
||||
import Chip from '@mui/material/Chip';
|
||||
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 MythicStyledTableCell from "./MythicTableCell";
|
||||
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!){
|
||||
createTag(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
|
||||
@@ -121,8 +134,11 @@ export const TagsDisplay = ({tags, expand}) => {
|
||||
)
|
||||
}
|
||||
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();
|
||||
@@ -152,9 +168,18 @@ const TagChipDisplay = ({tag, expand}) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Chip onMouseOver={onMouseOver} onMouseOut={onMouseOut} label={label} size="small" onClick={(e) => onSelectTag(e)}
|
||||
style={{float: "right", backgroundColor:tag.tagtype.color, height: "15px"}}
|
||||
sx={{
|
||||
"& .MuiChip-label": {overflow: "visible"}
|
||||
backgroundColor: color || "transparent",
|
||||
color: textColor,
|
||||
float: "right",
|
||||
height: "15px",
|
||||
"& .MuiChip-label": {
|
||||
color: "inherit",
|
||||
overflow: "visible"
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: color || "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{openTagDisplay &&
|
||||
@@ -173,7 +198,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 {
|
||||
@@ -220,6 +245,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">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">
|
||||
<Box className="mythic-tag-data-preview-key">value</Box>
|
||||
<Box className="mythic-tag-data-preview-value">{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">JSON object is empty.</Box>;
|
||||
}
|
||||
return (
|
||||
<Box className="mythic-tag-data-preview-list">
|
||||
{entries.map(([key, value]) => (
|
||||
<Box className="mythic-tag-data-preview-row" key={key}>
|
||||
<Box className="mythic-tag-data-preview-key">{key}</Box>
|
||||
<Box className="mythic-tag-data-preview-value">
|
||||
{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">Enter valid JSON to render a preview.</Box>;
|
||||
}
|
||||
}
|
||||
const TagReadonlyValue = ({children}) => (
|
||||
<Box className="mythic-tag-readonly-value">
|
||||
{children || "None"}
|
||||
</Box>
|
||||
)
|
||||
function ViewTagDialog(props) {
|
||||
const theme = useTheme();
|
||||
const [selectedTag, setSelectedTag] = React.useState({});
|
||||
@@ -277,118 +352,81 @@ function ViewTagDialog(props) {
|
||||
}
|
||||
props.onClose(event);
|
||||
}
|
||||
const stopClicks = (e) => {
|
||||
const stopPropagation = (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
const rawData = selectedTag?.is_json ? JSON.stringify(selectedTag?.data || {}, null, 2) : selectedTag?.data || "";
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title" onClick={stopClicks}>View Tag</DialogTitle>
|
||||
<TableContainer className="mythicElement" onClick={stopClicks}>
|
||||
<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>
|
||||
<MythicStyledTableCell>Source</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
{selectedTag?.source ||""}
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>Reference URL</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
{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>
|
||||
)}
|
||||
</MythicStyledTableCell>
|
||||
</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>
|
||||
<MythicStyledTableCell>{key}</MythicStyledTableCell>
|
||||
{typeof selectedTag.data[key] === "string" ? (
|
||||
<MythicStyledTableCell style={{whiteSpace: "pre-wrap"}}>
|
||||
<StringTagDataEntry name={key} value={String(selectedTag.data[key])} />
|
||||
</MythicStyledTableCell>
|
||||
) : typeof selectedTag.data[key] === "object" ? (
|
||||
Array.isArray(selectedTag.data[key]) ? (
|
||||
<TableCell style={{whiteSpace: "pre-wrap"}}>{JSON.stringify(selectedTag.data[key], null, 2)}</TableCell>
|
||||
) : (
|
||||
<MythicStyledTableCell>
|
||||
<Table size={"small"} >
|
||||
<TableBody>
|
||||
{Object.keys(selectedTag.data[key]).map(key2 => (
|
||||
<TableRow key={key2} >
|
||||
<MythicStyledTableCell>{key2}</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<StringTagDataEntry name={key2} value={String(selectedTag.data[key][key2])} />
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
</Table>
|
||||
</MythicStyledTableCell>
|
||||
)
|
||||
) : typeof selectedTag.data[key] === "boolean" ? (
|
||||
<MythicStyledTableCell>{selectedTag.data[key] ? "True" : "False"}</MythicStyledTableCell>
|
||||
) :
|
||||
(
|
||||
<MythicStyledTableCell>{String(selectedTag.data[key])}</MythicStyledTableCell>
|
||||
)
|
||||
}
|
||||
|
||||
</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} 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">
|
||||
<RenderedTagDataPreview data={selectedTag?.data || {}} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box className="mythic-tag-editor-frame">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -486,8 +524,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}});
|
||||
@@ -502,92 +547,122 @@ return (
|
||||
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} />}
|
||||
/>}
|
||||
<Table size="small" style={{ width: "100%", overflow: "scroll"}}>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell style={{width: "30%"}}>Select Existing Tag to Edit or Add New</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{display: "inline-flex", flexDirection: "row-reverse"}}>
|
||||
<MythicStyledTooltip title={"Add New Tag"}>
|
||||
<IconButton variant='contained' color="success" style={{float: "right"}} onClick={() => setOpenNewDialog(true)} >
|
||||
<AddCircleOutlineIcon />
|
||||
<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"}>
|
||||
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-success" size="small" onClick={() => setOpenNewDialog(true)}>
|
||||
<AddCircleOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</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}}>
|
||||
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-error" size="small" onClick={()=>{setOpenDeleteDialog(true);}}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</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>
|
||||
</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}/>
|
||||
}
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>Tag Description</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>{selectedTag?.tagtype?.description || ""}</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>Source</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>External URL</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL ? "click here" : ""}</Link>
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>JSON Data</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<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
|
||||
}}/>
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MythicFormField>
|
||||
</MythicFormGrid>
|
||||
</MythicDialogSection>
|
||||
<MythicDialogSection title="JSON Data">
|
||||
<Box className="mythic-tag-data-split">
|
||||
<Box className="mythic-tag-editor-frame">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -598,7 +673,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){
|
||||
@@ -611,7 +687,7 @@ export function NewTagDialog(props) {
|
||||
onCompleted: data => {
|
||||
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});
|
||||
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);
|
||||
@@ -625,11 +701,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) => {
|
||||
@@ -644,91 +725,107 @@ export function NewTagDialog(props) {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{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%"}}>
|
||||
<Table size="small" style={{ overflow: "scroll", width: "100%"}}>
|
||||
<TableBody>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell 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>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<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>
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>Source</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>External URL</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
|
||||
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL}</Link>
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
<TableRow hover>
|
||||
<MythicStyledTableCell>JSON Data</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<AceEditor
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Tag Type"
|
||||
description="Select the taxonomy entry this tag should use."
|
||||
actions={
|
||||
<Button className="mythic-compact-action mythic-action-tone-hover mythic-tone-info" 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">
|
||||
<Box className="mythic-tag-editor-frame">
|
||||
<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
|
||||
}}/>
|
||||
</MythicStyledTableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Box className="mythic-tag-data-preview-frame">
|
||||
<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}) => {
|
||||
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) => {
|
||||
@@ -747,9 +844,9 @@ export const ViewEditTags = ({target_object, target_object_id}) => {
|
||||
{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,36 @@
|
||||
import React from 'react';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {getReadableTextColor, isValidHexColor} from './MythicColorInput';
|
||||
|
||||
export const getTagReadableTextColor = (theme, color) => {
|
||||
return isValidHexColor(color) ? getReadableTextColor(color) : theme.palette.text.primary;
|
||||
}
|
||||
|
||||
export const TagTypeChip = ({tagtype, label, sx={}, ...props}) => {
|
||||
const theme = useTheme();
|
||||
const color = tagtype?.color || "";
|
||||
const textColor = getTagReadableTextColor(theme, color);
|
||||
return (
|
||||
<Chip
|
||||
label={label || tagtype?.name || "Tag"}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: color || "transparent",
|
||||
border: "1px solid",
|
||||
borderColor: color ? "rgba(0,0,0,0.16)" : theme.table?.borderSoft || theme.borderColor,
|
||||
color: textColor,
|
||||
fontWeight: 800,
|
||||
maxWidth: "100%",
|
||||
"& .MuiChip-label": {
|
||||
color: "inherit",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
...sx,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -17,20 +17,31 @@ const Root = styled('div')(({theme}) => ({
|
||||
|
||||
const ValidationTextField = styled(TextField)(({theme}) => ({
|
||||
[`&.${classes.textFieldRoot}`]: {
|
||||
'& .MuiInputBase-root': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
'& fieldset': {
|
||||
borderColor: 'grey',
|
||||
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',
|
||||
borderColor: theme.palette.error.main,
|
||||
borderWidth: 2,
|
||||
},
|
||||
'& input:valid:focus + fieldset': {
|
||||
borderLeftWidth: 6,
|
||||
borderLeftWidth: 2,
|
||||
padding: '4px !important', // override inline-style
|
||||
},
|
||||
'& textarea:focus + textarea + fieldset': {
|
||||
borderLeftWidth: 6,
|
||||
borderLeftWidth: 2,
|
||||
//padding: '4px !important', // override inline-style
|
||||
},
|
||||
},
|
||||
@@ -55,6 +66,7 @@ const MythicTextField = ({
|
||||
disabled = false,
|
||||
marginTop = "5px",
|
||||
InputProps = {},
|
||||
inputProps = {},
|
||||
inputLabelProps = {},
|
||||
multiline = false,
|
||||
maxRows = 10,
|
||||
@@ -93,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": "",}}>
|
||||
@@ -107,7 +122,7 @@ const MythicTextField = ({
|
||||
autoFocus={autoFocus}
|
||||
variant={variant}
|
||||
data-lpignore={true}
|
||||
autoComplete={autoComplete === undefined ? "new-password" : (autoComplete ? "on" : "off")}
|
||||
autoComplete={resolvedAutoComplete}
|
||||
disabled={disabled}
|
||||
required={requiredValue}
|
||||
InputLabelProps={inputLabelProps}
|
||||
@@ -117,6 +132,7 @@ const MythicTextField = ({
|
||||
type={type}
|
||||
onWheel={ event => event.target.blur() }
|
||||
InputProps={{...InputProps, spellCheck: false}}
|
||||
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
|
||||
helperText={localError ? errorText : helperText}
|
||||
style={{
|
||||
padding:0,
|
||||
|
||||
@@ -9,8 +9,6 @@ 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';
|
||||
@@ -26,8 +24,8 @@ 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)((
|
||||
@@ -61,10 +59,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">
|
||||
<div className="mythic-transfer-list-header">{title}</div>
|
||||
<div className="mythic-transfer-list-body">
|
||||
<List dense component="div" role="list" style={{padding:0, width: "100%"}}>
|
||||
{items.map((value, index) => (
|
||||
<div key={index}>
|
||||
@@ -80,8 +77,8 @@ const CustomList = ({title, items, left, onClick, itemKey}) => {
|
||||
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData, parentRightData, itemKey}) => {
|
||||
@@ -148,7 +145,7 @@ 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"}}>
|
||||
<div className="mythic-transfer-controls">
|
||||
<StyledButton
|
||||
variant="contained"
|
||||
size="small"
|
||||
@@ -253,4 +250,3 @@ export function MythicTransferListDialog(props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-5
@@ -2,14 +2,13 @@ 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 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-monokai';
|
||||
import 'ace-builds/src-noconflict/theme-xcode';
|
||||
import {ResponseDisplayMedia} from "../Callbacks/ResponseDisplayMedia";
|
||||
import {MythicDraggableDialogTitle} from "../../MythicComponents/MythicDraggableDialogTitle";
|
||||
import {ResponseDisplayMedia} from "../pages/Callbacks/ResponseDisplayMedia";
|
||||
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
|
||||
|
||||
export function PreviewFileMediaDialog({agent_file_id, filename, onClose}) {
|
||||
export function PreviewFileMediaDialog({agent_file_id, filename, onClose, editable=false}) {
|
||||
const onClick = (e) => {
|
||||
if(e){
|
||||
e.preventDefault();
|
||||
@@ -22,7 +21,7 @@ export function PreviewFileMediaDialog({agent_file_id, filename, onClose}) {
|
||||
Previewing <b>{filename}</b>
|
||||
</MythicDraggableDialogTitle>
|
||||
<DialogContent onClick={onClick} style={{height: "calc(80vh)", margin: 0, padding: 0}}>
|
||||
<ResponseDisplayMedia media={{agent_file_id, filename}} expand={true} />
|
||||
<ResponseDisplayMedia media={{agent_file_id, filename, editable}} expand={true} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={onClose} color="primary">
|
||||
@@ -4,20 +4,23 @@ import Badge from '@mui/material/Badge';
|
||||
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
import {alertCount} from "../cache";
|
||||
import {MeContext} from "./App";
|
||||
|
||||
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!){
|
||||
operation_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 1, where: {id: {_eq: $operation_id}}) {
|
||||
id
|
||||
alert_count
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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, {
|
||||
variables: {operation_id: me?.user?.current_operation_id},
|
||||
onError: data => {
|
||||
console.log("vertical event log error")
|
||||
console.error(data);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,8 +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`
|
||||
@@ -137,8 +137,8 @@ export function BrowserScripts({me}){
|
||||
}, [])
|
||||
return (
|
||||
<MythicPageBody>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={false}>
|
||||
<CircularProgress color="inherit" />
|
||||
<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}
|
||||
|
||||
@@ -1,53 +1,130 @@
|
||||
import React from 'react';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableHead from '@mui/material/TableHead';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { BrowserScriptsTableRow } from './BrowserScriptsTableRow';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||
import Button from '@mui/material/Button';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
import {EditScriptDialog} from './EditScriptDialog';
|
||||
import {MythicPageHeader} from "../../MythicComponents/MythicPageHeader";
|
||||
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 (
|
||||
<>
|
||||
<MythicPageHeader title={"Browser Scripts"}>
|
||||
<Button size="small" onClick={() => setOpenNewScriptDialog(true)} style={{whiteSpace: "nowrap", 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} />
|
||||
} />
|
||||
<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`} />}
|
||||
</>
|
||||
}
|
||||
</MythicPageHeader>
|
||||
<TableContainer className="mythicElement" style={{height: "100%", flexGrow: 1}}>
|
||||
<Table stickyHeader={true} size="small" style={{"tableLayout": "fixed", "maxWidth": "100%", "overflow": "scroll"}}>
|
||||
actions={
|
||||
<MythicToolbarButton variant="contained" color="primary" 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 className="mythicElement" style={{flexGrow: 1, minHeight: 0, overflow: "auto"}}>
|
||||
<Table className="mythic-browser-scripts-table" 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" />
|
||||
</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}
|
||||
@@ -63,4 +140,3 @@ export function BrowserScriptsTable(props){
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 EditIcon from '@mui/icons-material/Edit';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
|
||||
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
|
||||
import {MythicStateChip} from "../../MythicComponents/MythicStateChip";
|
||||
|
||||
export function BrowserScriptsTableRow(props){
|
||||
const [openEdit, setOpenEdit] = React.useState(false);
|
||||
@@ -22,27 +24,45 @@ 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">
|
||||
<MythicStyledTooltip title={props.payloadtype.name}>
|
||||
<Box className="mythic-browser-script-payload-icon">
|
||||
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "32px", height: "32px"}} />
|
||||
</Box>
|
||||
</MythicStyledTooltip>
|
||||
<Box className="mythic-browser-script-script-copy">
|
||||
<Box className="mythic-browser-script-command-name">{props.command.cmd}</Box>
|
||||
<Box className="mythic-browser-script-payload-name">{props.payloadtype.name}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>{props.author}</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<Box className="mythic-browser-script-active-cell">
|
||||
<Switch
|
||||
checked={props.active}
|
||||
onChange={onToggleActive}
|
||||
color="success"
|
||||
inputProps={{ 'aria-label': 'Toggle browser script active state', "track": "white" }}
|
||||
name="Active"
|
||||
size="small"
|
||||
/>
|
||||
<MythicStateChip label={props.active ? "Active" : "Disabled"} state={props.active ? "active" : "disabled"} />
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicStateChip label={props.user_modified ? "User modified" : "Container default"} state={props.user_modified ? "warning" : "neutral"} />
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell style={{textAlign: "center"}}>
|
||||
<Box className="mythic-compact-actions mythic-compact-actions-nowrap mythic-browser-script-actions">
|
||||
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info" size="small" onClick={()=>{setOpenEdit(true);}}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell className="mythic-browser-script-spacer-cell" />
|
||||
|
||||
{openEdit &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openEdit}
|
||||
@@ -56,4 +76,3 @@ export function BrowserScriptsTableRow(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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 Chip from '@mui/material/Chip';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AceEditor from 'react-ace';
|
||||
import "ace-builds/src-noconflict/mode-javascript";
|
||||
import "ace-builds/src-noconflict/theme-github";
|
||||
@@ -14,11 +16,21 @@ import MenuItem from '@mui/material/MenuItem';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import Select from '@mui/material/Select';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Input from '@mui/material/Input';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import Split from 'react-split';
|
||||
import {TaskDisplay} from "../Callbacks/TaskDisplay";
|
||||
import {taskingDataFragment} from '../Callbacks/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">
|
||||
<Box component="span">{props.title ? props.title : "Edit " + props.author + "'s BrowserScript Code"}</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent className="mythic-browser-script-dialog-content" dividers={true}>
|
||||
<MythicDialogBody className="mythic-browser-script-dialog-body">
|
||||
<Box className={`mythic-browser-script-target-panel ${targetOpen ? "mythic-browser-script-target-panel-open" : ""}`}>
|
||||
<Box className="mythic-browser-script-target-summary">
|
||||
<Box className="mythic-browser-script-target-copy">
|
||||
<Box component="span" className="mythic-browser-script-target-label">Script Target</Box>
|
||||
<Box className="mythic-browser-script-target-chips">
|
||||
<Chip size="small" label={selectedPayloadTypeOption?.name || (targetLoading ? "Loading payload types" : "No payload type")} />
|
||||
<Chip size="small" label={selectedCommandOption?.cmd || "No command"} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Tooltip title={targetOpen ? "Collapse target settings" : "Edit target settings"}>
|
||||
<IconButton
|
||||
aria-expanded={targetOpen}
|
||||
aria-label={targetOpen ? "Collapse target settings" : "Edit target settings"}
|
||||
className="mythic-browser-script-target-toggle"
|
||||
onClick={onToggleTargetOpen}
|
||||
size="small"
|
||||
>
|
||||
{targetOpen ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</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">
|
||||
<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"
|
||||
direction="vertical"
|
||||
gutterSize={8}
|
||||
minSize={[210, 300]}
|
||||
onDrag={resizeEditors}
|
||||
onDragEnd={onWorkbenchDragEnd}
|
||||
sizes={workbenchSplitSizes}
|
||||
>
|
||||
<Split
|
||||
className="mythic-browser-script-top-split"
|
||||
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 mythic-browser-script-editor-pane">
|
||||
<div className="mythic-browser-script-pane-header">
|
||||
<span>Script Code</span>
|
||||
<span>JavaScript</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-editor-frame">
|
||||
<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 mythic-browser-script-console-pane">
|
||||
<div className="mythic-browser-script-pane-header">
|
||||
<span>Console Output</span>
|
||||
<span>console.log</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-editor-frame">
|
||||
<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 mythic-browser-script-preview-pane">
|
||||
<div className="mythic-browser-script-pane-header">
|
||||
<span>Test Preview</span>
|
||||
<span>{availableTasks.length === 1 ? "1 task" : `${availableTasks.length} tasks`}</span>
|
||||
</div>
|
||||
<div className="mythic-browser-script-preview-controls">
|
||||
<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">
|
||||
{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,11 +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 {MythicDraggableDialogTitle} from "../../MythicComponents/MythicDraggableDialogTitle";
|
||||
import {classes, StyledButton} from '../../MythicComponents/MythicTransferList';
|
||||
import {
|
||||
MythicDialogButton,
|
||||
MythicDialogBody,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection
|
||||
} from '../../MythicComponents/MythicDialogLayout';
|
||||
|
||||
const getCommandsQuery = gql`
|
||||
query GetCallbackDetails($callback_id: Int!) {
|
||||
@@ -68,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">
|
||||
<div className="mythic-transfer-list-header">{title}</div>
|
||||
<div className="mythic-transfer-list-body">
|
||||
<List dense component="div" role="list" style={{padding: 0, width: "100%"}}>
|
||||
{renderedList}
|
||||
</List>
|
||||
</CardContent>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
};
|
||||
export function AddRemoveCallbackCommandsDialog(props) {
|
||||
@@ -187,67 +187,71 @@ export function AddRemoveCallbackCommandsDialog(props) {
|
||||
<React.Fragment>
|
||||
<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"}}>
|
||||
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>
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export const taskingDataFragment = gql`
|
||||
parent_task_id
|
||||
agent_task_id
|
||||
tasking_location
|
||||
apitokens_id
|
||||
callback {
|
||||
display_id
|
||||
id
|
||||
@@ -164,6 +165,7 @@ export const taskingDataFragment = gql`
|
||||
id
|
||||
comment
|
||||
agent_task_id
|
||||
apitokens_id
|
||||
commentOperator{
|
||||
username
|
||||
}
|
||||
@@ -192,6 +194,9 @@ export const taskingDataFragment = gql`
|
||||
cmd
|
||||
supported_ui_features
|
||||
id
|
||||
payloadtype {
|
||||
name
|
||||
}
|
||||
}
|
||||
command_name
|
||||
response_count
|
||||
@@ -223,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
|
||||
@@ -232,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);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {reorder} from "../../MythicComponents/MythicDraggableList";
|
||||
import Paper from '@mui/material/Paper';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
|
||||
export const getCallbackIdFromClickedTab = (tabId) => {
|
||||
if(tabId === null || tabId === undefined){return 0}
|
||||
@@ -32,6 +33,7 @@ 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({});
|
||||
@@ -215,9 +217,22 @@ export function Callbacks({me}) {
|
||||
minSize={[0,0]}
|
||||
onDragEnd={(sizes) => localStorage.setItem('callbackTableSplitSizes', JSON.stringify(sizes))}
|
||||
style={{ height: "100%" }}>
|
||||
<div style={{display: "flex", flexDirection: "row-reverse"}}>
|
||||
<Paper elevation={5} style={{width: "30px", display: "flex", flexDirection: "column", alignItems: "center", overflow: "hidden",
|
||||
backgroundColor: "transparent"}}>
|
||||
<div style={{display: "flex", flexDirection: "row-reverse", height: "100%", minWidth: 0, overflow: "hidden"}}>
|
||||
<Paper elevation={0} style={{
|
||||
width: "34px",
|
||||
flex: "0 0 34px",
|
||||
display: "flex",
|
||||
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' &&
|
||||
<MythicStyledTooltip title={"Table View"}>
|
||||
<IconButton onClick={() =>setTopDisplay("table")}>
|
||||
@@ -270,4 +285,4 @@ export function Callbacks({me}) {
|
||||
</Split>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
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 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 +16,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 +57,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 +80,104 @@ 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">
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-options-toggle"
|
||||
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">
|
||||
<div className="mythic-callback-graph-options-actions">
|
||||
<Button
|
||||
size="small"
|
||||
className={`mythic-callback-graph-option-button${!viewConfig["include_disconnected"] ? " mythic-action-tone mythic-tone-success" : ""}`}
|
||||
onClick={() => toggleViewConfig("include_disconnected")}
|
||||
>
|
||||
{viewConfig["include_disconnected"] ? "All Edges" : "Active Edges"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className={`mythic-callback-graph-option-button${viewConfig["show_all_nodes"] ? " mythic-action-tone mythic-tone-warning" : ""}`}
|
||||
onClick={() => toggleViewConfig("show_all_nodes")}
|
||||
>
|
||||
{viewConfig["show_all_nodes"] ? "All Callbacks" : "Connected Callbacks"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button"
|
||||
onClick={toggleRankDirection}
|
||||
>
|
||||
{viewConfig["rankDir"] === "LR" ? "Left to Right" : "Top to Bottom"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="mythic-callback-graph-option-button"
|
||||
onClick={() => toggleViewConfig("packet_flow_view")}
|
||||
>
|
||||
{viewConfig["packet_flow_view"] ? "Egress Routes" : "Connection Directions"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mythic-callback-graph-options-fields">
|
||||
<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">
|
||||
Showing all callbacks can be slow in large operations.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -249,17 +228,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 +294,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 +329,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 +339,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 +372,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 +394,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 +411,3 @@ export function CallbacksGraph({onOpenTab}){
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useContext} from 'react';
|
||||
import {MythicDialog, MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
|
||||
import {MythicDialog} from '../../MythicComponents/MythicDialog';
|
||||
import {
|
||||
exportCallbackConfigQuery,
|
||||
hideCallbackMutation, lockCallbackMutation, unlockCallbackMutation, updateCallbackTriggerMutation,
|
||||
@@ -23,7 +23,6 @@ import {CallbacksTabsHideMultipleDialog, CallbacksTabsSelectMultipleDialog} from
|
||||
import {CallbacksTabsTaskMultipleDialog} from "./CallbacksTabsTaskMultipleDialog";
|
||||
import ip6 from 'ip6';
|
||||
import {CallbacksContext, OnOpenTabContext, OnOpenTabsContext} from "./CallbacksTop";
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {GetMythicSetting, useSetMythicSetting, GetComputedFontSize} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {DetailedCallbackTable} from "./DetailedCallbackTable";
|
||||
import {ModifyCallbackMythicTreeGroupsDialog} from "./ModifyCallbackMythicTreeGroupsDialog";
|
||||
@@ -34,6 +33,7 @@ import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport';
|
||||
import KeyboardIcon from '@mui/icons-material/Keyboard';
|
||||
import {downloadFileFromMemory} from '../../utilities/Clipboard';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
|
||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||
@@ -53,16 +53,24 @@ import NotificationsOffTwoToneIcon from '@mui/icons-material/NotificationsOffTwo
|
||||
import {CallbacksTableEditTriggerOnCheckinDialog} from "./CallbacksTableEditTriggerOnCheckinDialog";
|
||||
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
|
||||
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {EventTriggerContextSelectDialog} from "../Eventing/EventTriggerContextSelect";
|
||||
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
|
||||
import {faFilter} from '@fortawesome/free-solid-svg-icons';
|
||||
import {getReadableTextColor, isValidHexColor} from "../../MythicComponents/MythicColorInput";
|
||||
import {
|
||||
GridColumnFilterDialog,
|
||||
getUpdatedGridFilterOptions,
|
||||
gridValuePassesFilter,
|
||||
isGridColumnFilterActive
|
||||
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
|
||||
import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
|
||||
|
||||
export const getCustomBrowsersQuery = gql(`
|
||||
query getCustomBrowsersQuery{
|
||||
custombrowser(where: {deleted: {_eq: false}}, order_by: {name: asc}){
|
||||
id
|
||||
name
|
||||
display_name
|
||||
type
|
||||
separator
|
||||
columns
|
||||
@@ -114,7 +122,7 @@ export const ipCompare = (a, b) => {
|
||||
}
|
||||
}
|
||||
const callbackTableInitialColumns = [
|
||||
{key: "id", type: 'number', name: "Interact", width: 150, disableDoubleClick: true},
|
||||
{key: "id", type: 'number', name: "Interact", width: 175, disableDoubleClick: true},
|
||||
{key: "mythictree_groups", type: 'array', name: "Groups", width: 150},
|
||||
{key: "ip", type: 'ip', name: "IP", width: 150},
|
||||
{key: "external_ip",type: 'string', name: "External IP", width: 150},
|
||||
@@ -132,12 +140,20 @@ const callbackTableInitialColumns = [
|
||||
{key: "process_short_name", type: 'string', name: "Process Name", fillWidth: true},
|
||||
{key: "tags", type: 'tags', name: "Tags", fillWidth: false, disableDoubleClick: true, width: 150}
|
||||
];
|
||||
const getCallbackRowStyle = (row) => {
|
||||
if(!isValidHexColor(row.color)){
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
backgroundColor: row.color,
|
||||
color: getReadableTextColor(row.color),
|
||||
};
|
||||
}
|
||||
function CallbacksTablePreMemo(props){
|
||||
const callbacks = useContext(CallbacksContext);
|
||||
const onOpenTab = useContext(OnOpenTabContext);
|
||||
const onOpenTabs = useContext(OnOpenTabsContext);
|
||||
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact", output: "string"});
|
||||
const theme = useTheme();
|
||||
const [loadingSettings, setLoadingSettings] = React.useState(true);
|
||||
const [columnOrder, setColumnOrder] = React.useState(callbackTableInitialColumns);
|
||||
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
|
||||
@@ -155,6 +171,7 @@ function CallbacksTablePreMemo(props){
|
||||
setOpenEditMythicTreeGroupsDialog(true);
|
||||
}
|
||||
const [openEditDescriptionDialog, setOpenEditDescriptionDialog] = React.useState(false);
|
||||
const [callbackLocalUpdates, setCallbackLocalUpdates] = React.useState({});
|
||||
const [updateDescriptionMutation] = useMutation(updateDescriptionCallbackMutation, {
|
||||
update: (cache, {data}) => {
|
||||
if(data.updateCallback.status === "success"){
|
||||
@@ -170,6 +187,16 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
});
|
||||
const updateDescriptionRef = React.useRef({payload_description: "", callback_display_id: 0, description: "", color: ""});
|
||||
const applyCallbackLocalUpdate = React.useCallback(({callback_display_id, description, color}) => {
|
||||
setCallbackLocalUpdates((currentUpdates) => ({
|
||||
...currentUpdates,
|
||||
[String(callback_display_id)]: {description, color},
|
||||
}));
|
||||
const callbackIndex = callbacks.findIndex((callback) => callback.display_id === callback_display_id);
|
||||
if(callbackIndex !== -1){
|
||||
callbacks[callbackIndex] = {...callbacks[callbackIndex], description, color};
|
||||
}
|
||||
}, [callbacks]);
|
||||
const updateDescription = ({payload_description, callback_display_id, description, color}) => {
|
||||
updateDescriptionRef.current = {
|
||||
payload_description: payload_description,
|
||||
@@ -196,8 +223,26 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
}
|
||||
const updateDescriptionSubmit = React.useCallback( ({callback_display_id, description, color}) => {
|
||||
updateDescriptionMutation({variables: {callback_display_id: callback_display_id, description, color}})
|
||||
}, []);
|
||||
const previousCallback = callbacks.find((callback) => callback.display_id === callback_display_id);
|
||||
applyCallbackLocalUpdate({callback_display_id, description, color});
|
||||
updateDescriptionMutation({variables: {callback_display_id: callback_display_id, description, color}}).then(({data}) => {
|
||||
if(data?.updateCallback?.status !== "success" && previousCallback){
|
||||
applyCallbackLocalUpdate({
|
||||
callback_display_id,
|
||||
description: previousCallback.description,
|
||||
color: previousCallback.color,
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
if(previousCallback){
|
||||
applyCallbackLocalUpdate({
|
||||
callback_display_id,
|
||||
description: previousCallback.description,
|
||||
color: previousCallback.color,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [applyCallbackLocalUpdate, callbacks, updateDescriptionMutation]);
|
||||
const [openCallbackDropdown, setOpenCallbackDropdown] = React.useState(false);
|
||||
const callbackDropdownRef = React.useRef({options: [], callback: {}});
|
||||
const [clickedCallbackID, setClickedCallbackId] = React.useState(0);
|
||||
@@ -219,6 +264,7 @@ function CallbacksTablePreMemo(props){
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
const [openHideMultipleDialog, setOpenHideMultipleDialog] = React.useState(false);
|
||||
const [openTriggerDialog, setOpenTriggerDialog] = React.useState({open: false, trigger_on_checkin_after_time: 0, display_id: 0});
|
||||
const [openUnlockDialog, setOpenUnlockDialog] = React.useState({open: false, display_id: 0, lockOwner: ""});
|
||||
const [openTaskMultipleDialog, setOpenTaskMultipleDialog] = React.useState({open: false, data: {}});
|
||||
const [filterOptions, setFilterOptions] = React.useState({});
|
||||
const [selectedColumn, setSelectedColumn] = React.useState({});
|
||||
@@ -312,20 +358,7 @@ function CallbacksTablePreMemo(props){
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
if(data.exportCallbackConfig.status === "success"){
|
||||
const dataBlob = new Blob([data.exportCallbackConfig.config], {type: 'text/plain'});
|
||||
const ele = document.getElementById("download_config");
|
||||
if(ele !== null){
|
||||
ele.href = URL.createObjectURL(dataBlob);
|
||||
ele.download = "exported_callback.json";
|
||||
ele.click();
|
||||
}else{
|
||||
const element = document.createElement("a");
|
||||
element.id = "download_config";
|
||||
element.href = URL.createObjectURL(dataBlob);
|
||||
element.download = "exported_callback.json";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
}
|
||||
downloadFileFromMemory(data.exportCallbackConfig.config, "exported_callback.json", "text/plain");
|
||||
}else{
|
||||
snackActions.error("Failed to export configuration: " + data.exportCallbackConfig.error);
|
||||
}
|
||||
@@ -341,27 +374,71 @@ function CallbacksTablePreMemo(props){
|
||||
const eventingDataRef = React.useRef({});
|
||||
const [openEventingDialog, setOpenEventingDialog] = React.useState(false);
|
||||
const [openEventingMultipleDialog, setOpenEventingMultipleDialog] = React.useState(false);
|
||||
const [updateSetting] = useSetMythicSetting();
|
||||
const [updateSetting, updateSettings] = useSetMythicSetting();
|
||||
const onUpdateTrigger = (newTriggerValue) => {
|
||||
updateTrigger({variables: {callback_display_id: openTriggerDialog.display_id, trigger_on_checkin_after_time: newTriggerValue}})
|
||||
setOpenTriggerDialog({...openTriggerDialog, open: false});
|
||||
}
|
||||
const onOpenTriggerDialog = React.useCallback((callback) => {
|
||||
setOpenTriggerDialog({
|
||||
open: true,
|
||||
trigger_on_checkin_after_time: callback.trigger_on_checkin_after_time,
|
||||
display_id: callback.display_id,
|
||||
});
|
||||
}, []);
|
||||
const onOpenUnlockDialog = React.useCallback((callback) => {
|
||||
setOpenUnlockDialog({
|
||||
open: true,
|
||||
display_id: callback.display_id,
|
||||
lockOwner: callback.locked_operator?.username || "unknown operator",
|
||||
});
|
||||
}, []);
|
||||
const onCloseUnlockDialog = React.useCallback(() => {
|
||||
setOpenUnlockDialog({open: false, display_id: 0, lockOwner: ""});
|
||||
}, []);
|
||||
const onSubmitUnlockDialog = React.useCallback(() => {
|
||||
if(openUnlockDialog.display_id > 0){
|
||||
unlockCallback({variables: {callback_display_id: openUnlockDialog.display_id}});
|
||||
}
|
||||
}, [openUnlockDialog.display_id, unlockCallback]);
|
||||
async function getCustomBrowsersForContextMenu(){
|
||||
return await getCustomBrowsers({}).then(result => {return result.data?.custombrowser});
|
||||
}
|
||||
const callbackMenuIcon = (icon, intent = "neutral") => (
|
||||
<span className={`mythic-callback-action-menu-icon mythic-tone-${intent}`}>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
const callbackMenuLabel = (primary, secondary) => (
|
||||
<span className="mythic-callback-action-menu-label">
|
||||
<span className="mythic-callback-action-menu-label-primary">{primary}</span>
|
||||
{secondary &&
|
||||
<span className="mythic-callback-action-menu-label-secondary">{secondary}</span>
|
||||
}
|
||||
</span>
|
||||
);
|
||||
const callbackMenuSection = (label) => ({
|
||||
name: <span className="mythic-callback-action-menu-section-label">{label}</span>,
|
||||
icon: null,
|
||||
click: ({event}) => {},
|
||||
type: "item",
|
||||
disabled: true,
|
||||
className: "mythic-callback-action-menu-section"
|
||||
});
|
||||
async function onRowContextClick({rowDataStatic}) {
|
||||
// based on row, return updated options array?
|
||||
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"/>;
|
||||
}
|
||||
let customBrowsers = await getCustomBrowsersForContextMenu();
|
||||
customBrowsers = customBrowsers.map(b => {
|
||||
return {
|
||||
name: b.name,
|
||||
icon: b.type === 'file' ? <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} /> : <AccountTreeIcon style={{paddingRight: "5px"}}/>,
|
||||
name: b.display_name === "" ? b.name : b.display_name,
|
||||
icon: callbackMenuIcon(b.type === 'file' ? <FontAwesomeIcon icon={faFolderOpen} /> : <AccountTreeIcon fontSize="small" />),
|
||||
className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -379,20 +456,15 @@ function CallbacksTablePreMemo(props){
|
||||
})
|
||||
return [
|
||||
{
|
||||
name: <div style={{display: "flex", flexDirection: "column"}}>
|
||||
<Typography>
|
||||
{"Callback: " + rowDataStatic.display_id}
|
||||
</Typography>
|
||||
<Typography style={{fontSize: theme?.typography.pxToRem(12),}}>
|
||||
{rowDataStatic.user + "@" + rowDataStatic.host}
|
||||
</Typography>
|
||||
</div>,
|
||||
name: callbackMenuLabel("Callback " + rowDataStatic.display_id, rowDataStatic.user + "@" + rowDataStatic.host),
|
||||
icon: null, click: ({event}) => {},
|
||||
type: "item",
|
||||
disabled: true
|
||||
disabled: true,
|
||||
className: "mythic-callback-action-menu-header"
|
||||
},
|
||||
callbackMenuSection("Tasking"),
|
||||
{
|
||||
name: "Interact", icon: defaultInteractIcon, click: ({event}) => {
|
||||
name: "Interact", icon: callbackMenuIcon(defaultInteractIcon, "primary"), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = interactType;
|
||||
onOpenTab({
|
||||
@@ -404,7 +476,55 @@ function CallbacksTablePreMemo(props){
|
||||
}, type: "item"
|
||||
},
|
||||
{
|
||||
name: "Edit Description and Color", icon: <EditIcon style={{paddingRight: "5px"}} />, click:({event}) => {
|
||||
name: "Tasking Views", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: 'Default Tasking', icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interact";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Split Tasking', icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interactSplit";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Console View", icon: callbackMenuIcon(<TerminalIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interactConsole";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Expand Callback", icon: callbackMenuIcon(<OpenInNewIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
window.open("/new/callbacks/" + rowDataStatic.display_id, "_blank").focus();
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
callbackMenuSection("Callback State"),
|
||||
{
|
||||
name: "Edit Description and Color", icon: callbackMenuIcon(<EditIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click:({event}) => {
|
||||
event.stopPropagation();
|
||||
updateDescription({payload_description: rowDataStatic.payload.description,
|
||||
callback_display_id: rowDataStatic.display_id,
|
||||
@@ -414,13 +534,36 @@ function CallbacksTablePreMemo(props){
|
||||
}, type: "item"
|
||||
},
|
||||
{
|
||||
name: 'Hide Callback', icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
name: rowDataStatic.locked ? 'Unlock (Locked by ' + (rowDataStatic.locked_operator?.username || "unknown operator") + ')' : 'Lock Callback',
|
||||
icon: callbackMenuIcon(rowDataStatic.locked ? <LockIcon fontSize="small"/> : <LockOpenIcon fontSize="small" />, rowDataStatic.locked ? "warning" : "neutral"),
|
||||
className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
if(rowDataStatic.locked){
|
||||
onOpenUnlockDialog(rowDataStatic);
|
||||
}else{
|
||||
lockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
|
||||
}
|
||||
}, type: "item"
|
||||
},
|
||||
{
|
||||
name: rowDataStatic.trigger_on_checkin_after_time > 0 ? "Adjust Alert Trigger" : "Add New Alert Trigger",
|
||||
type: "item",
|
||||
icon: callbackMenuIcon(rowDataStatic.trigger_on_checkin_after_time > 0 ? <NotificationsOffTwoToneIcon fontSize="small" /> : <NotificationsActiveTwoToneIcon fontSize="small"/>, rowDataStatic.trigger_on_checkin_after_time > 0 ? "warning" : "neutral"),
|
||||
className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
onOpenTriggerDialog(rowDataStatic);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Hide Callback', icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
hideCallback({variables: {callback_display_id: rowDataStatic.display_id}});
|
||||
}, type: "item"
|
||||
},
|
||||
{
|
||||
name: "Exit Callback", icon: <FontAwesomeIcon icon={faSkullCrossbones} style={{color: theme.palette.error.main, cursor: "pointer", marginRight: "8px"}} />,
|
||||
name: "Exit Callback", icon: callbackMenuIcon(<FontAwesomeIcon icon={faSkullCrossbones} />, "error"), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
taskingData.current = {
|
||||
"parameters": "",
|
||||
@@ -433,36 +576,16 @@ function CallbacksTablePreMemo(props){
|
||||
setOpenTaskingButton(true);
|
||||
}, type: "item"
|
||||
},
|
||||
callbackMenuSection("Workflows and Browsers"),
|
||||
{
|
||||
name: rowDataStatic.locked ? 'Unlock (Locked by ' + rowDataStatic.locked_operator.username + ')' : 'Lock Callback',
|
||||
icon: rowDataStatic.locked ? (<LockIcon color={"error"} style={{paddingRight: "5px"}}/>) : (<LockOpenIcon color={"success"} style={{paddingRight: "5px"}} />),
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
if(rowDataStatic.locked){
|
||||
unlockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
|
||||
}else{
|
||||
lockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
|
||||
}
|
||||
}, type: "item"
|
||||
},
|
||||
{
|
||||
name: rowDataStatic.trigger_on_checkin_after_time > 0 ? "Adjust Alert Trigger" : "Add New Alert Trigger",
|
||||
type: "item",
|
||||
icon: rowDataStatic.trigger_on_checkin_after_time > 0 ? (<NotificationsOffTwoToneIcon color={"warning"} style={{paddingRight: "5px"}} />) : (<NotificationsActiveTwoToneIcon color={"success"} style={{paddingRight: "5px"}}/>),
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
setOpenTriggerDialog({open: true, trigger_on_checkin_after_time: rowDataStatic.trigger_on_checkin_after_time, display_id: rowDataStatic.display_id})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Eventing", icon: <PlayCircleFilledTwoToneIcon />, click: (event) => {}, type: "menu",
|
||||
name: "Eventing", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), click: (event) => {}, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: "Start Eventing Workflow", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}} />,
|
||||
name: "Start Eventing Workflow", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
eventingDataRef.current = {
|
||||
name: "callback_id",
|
||||
value: rowDataStatic.id
|
||||
name: "callback_display_id",
|
||||
value: rowDataStatic.display_id
|
||||
};
|
||||
setOpenEventingDialog(true);
|
||||
}
|
||||
@@ -470,10 +593,10 @@ function CallbacksTablePreMemo(props){
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Browsers", icon: null, click: () => {}, type: "menu",
|
||||
name: "Browsers", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: 'File Browser', icon: <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} />,
|
||||
name: 'File Browser', icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -487,7 +610,7 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Process Browser', icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>,
|
||||
name: 'Process Browser', icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -501,105 +624,63 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
},
|
||||
{
|
||||
name: <div style={{display: "flex", flexDirection: "column", width: "100%"}}>
|
||||
<Typography>
|
||||
{"Custom Agent Browsers:"}
|
||||
</Typography>
|
||||
</div>,
|
||||
name: <span className="mythic-callback-action-menu-section-label">Custom Agent Browsers</span>,
|
||||
icon: null, click: ({event}) => {},
|
||||
type: "item",
|
||||
disabled: true
|
||||
disabled: true,
|
||||
className: "mythic-callback-action-menu-section"
|
||||
},
|
||||
...customBrowsers
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Bulk Actions", icon: null, click: (event) => { }, type: "menu",
|
||||
name: "Other Callbacks", icon: callbackMenuIcon(<OpenInNewIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: "Hide Multiple", icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>,
|
||||
click: ({event}) => {
|
||||
setOpenHideMultipleDialog(true);
|
||||
name: "Interact", icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Task Multiple", icon: <FontAwesomeIcon icon={faList} style={{cursor: "pointer", marginRight: "10px"}} />,
|
||||
click: ({event}) => {
|
||||
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
|
||||
name: "Split Tasking", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Start Multiple Eventing Workflows", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}}/>,
|
||||
click: ({event}) => {
|
||||
setOpenEventingMultipleDialog(true);
|
||||
name: "Console View", icon: callbackMenuIcon(<TerminalIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interactConsole"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "File Browser", icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "fileBrowser"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Process Browser", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "processBrowser"});
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
callbackMenuSection("Metadata"),
|
||||
{
|
||||
name: "Tasking Views", icon: null, click: () => {}, type: "menu",
|
||||
name: "Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: 'Default Tasking', icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
name: "Export Callback", icon: callbackMenuIcon(<ImportExportIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interact";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
exportConfig({variables: {callback_display_id: rowDataStatic.display_id}});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Split Tasking', icon: <VerticalSplitIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interactSplit";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Console View", icon: <TerminalIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
const tabType = "interactConsole";
|
||||
onOpenTab({
|
||||
tabType: tabType,
|
||||
tabID: rowDataStatic.id + tabType,
|
||||
callbackID: rowDataStatic.id,
|
||||
color: rowDataStatic.color,
|
||||
displayID: rowDataStatic.display_id});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Expand Callback", icon: <OpenInNewIcon style={{paddingRight: "5px"}} />, click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
window.open("/new/callbacks/" + rowDataStatic.display_id, "_blank").focus();
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Metadata", icon: null, click: () => {}, type: "menu",
|
||||
menuItems: [
|
||||
{
|
||||
name: "Export Callback", icon: <ImportExportIcon style={{paddingRight: "5px"}} />, click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
exportConfig({variables: {agent_callback_id: rowDataStatic.agent_callback_id}});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "View Metadata", icon: <InfoIcon color={"info"} style={{paddingRight: "5px"}} />, click: ({event}) => {
|
||||
name: "View Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
metaDialog(rowDataStatic.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Modify Groupings", icon: <WidgetsIcon color={"info"} style={{paddingRight: "5px"}} />, click: ({event}) => {
|
||||
name: "Modify Groupings", icon: callbackMenuIcon(<WidgetsIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
editMythicTreeGroupsDialog(rowDataStatic.id);
|
||||
}
|
||||
@@ -607,31 +688,24 @@ function CallbacksTablePreMemo(props){
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Other Callbacks", icon: null, click: () => {}, type: "menu",
|
||||
name: "Bulk Actions", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), click: (event) => { }, type: "menu", className: "mythic-callback-action-menu-item",
|
||||
menuItems: [
|
||||
{
|
||||
name: "Interact", icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
|
||||
name: "Hide Multiple", icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
setOpenHideMultipleDialog(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Split Tasking", icon: <VerticalSplitIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
|
||||
name: "Task Multiple", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Console View", icon: <TerminalIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "interactConsole"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "File Browser", icon: <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} />, click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "fileBrowser"});
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "Process Browser", icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
|
||||
setOpenMultipleTabsDialog({open: true, tabType: "processBrowser"});
|
||||
name: "Start Multiple Eventing Workflows", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small"/>), className: "mythic-callback-action-menu-item",
|
||||
click: ({event}) => {
|
||||
setOpenEventingMultipleDialog(true);
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -694,7 +768,7 @@ function CallbacksTablePreMemo(props){
|
||||
() =>
|
||||
columnOrder.reduce( (prev, cur) => {
|
||||
if(columnVisibility.visible.includes(cur.name) || cur.name === "Interact"){
|
||||
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
|
||||
if(isGridColumnFilterActive(filterOptions[cur.key])){
|
||||
return [...prev, {...cur, filtered: true}];
|
||||
}else{
|
||||
return [...prev, {...cur}];
|
||||
@@ -765,21 +839,27 @@ function CallbacksTablePreMemo(props){
|
||||
}, [])
|
||||
const filterRow = (row) => {
|
||||
for(const [key,value] of Object.entries(filterOptions)){
|
||||
if(key === "agent"){
|
||||
if(!String(row.payload.payloadtype.name).toLowerCase().includes(String(value).toLowerCase())){
|
||||
return true;
|
||||
}
|
||||
if(key === "agent") {
|
||||
if (!gridValuePassesFilter(row.payload.payloadtype.name, value)) {
|
||||
return true;
|
||||
}
|
||||
}else if(key === "id"){
|
||||
if (!gridValuePassesFilter(row.display_id, value)) {
|
||||
return true;
|
||||
}
|
||||
}else{
|
||||
if(!String(row[key]).toLowerCase().includes(String(value).toLowerCase())){
|
||||
if(!gridValuePassesFilter(row[key], value)){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const sortedData = React.useMemo(() => {
|
||||
const tempData = [...callbacks];
|
||||
const tempData = callbacks.map((callback) => {
|
||||
const localUpdate = callbackLocalUpdates[String(callback.display_id)];
|
||||
return localUpdate ? {...callback, ...localUpdate} : callback;
|
||||
});
|
||||
|
||||
if (sortData.sortType === 'number' || sortData.sortType === 'size' || sortData.sortType === 'date') {
|
||||
tempData.sort((a, b) => (parseInt(a[sortData.sortKey]) > parseInt(b[sortData.sortKey]) ? 1 : -1));
|
||||
@@ -823,10 +903,12 @@ function CallbacksTablePreMemo(props){
|
||||
return <CallbacksTableIDCell
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
key={`callback${row.id}_${c.name}`}
|
||||
callbackDropdown={callbackDropdown}
|
||||
onOpenTriggerDialog={onOpenTriggerDialog}
|
||||
onOpenUnlockDialog={onOpenUnlockDialog}
|
||||
/>;
|
||||
case "Groups":
|
||||
return <CallbacksTableStringCell
|
||||
@@ -834,7 +916,7 @@ function CallbacksTablePreMemo(props){
|
||||
cellData={row.mythictree_groups.join(", ")}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
/>;
|
||||
case "IP":
|
||||
@@ -843,109 +925,110 @@ function CallbacksTablePreMemo(props){
|
||||
cellData={row.ip}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
callback_id={row.id} />;
|
||||
case "External IP":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.external_ip}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "Host":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.host}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "User":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.user + (row.impersonation_context === "" ? "" : ` [${row.impersonation_context}]`)}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "Domain":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.domain}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "OS":
|
||||
return <CallbacksTableOSCell key={`callback${row.id}_${c.name}`}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
cellData={row.os} />;
|
||||
case "Arch":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
cellData={row.architecture} />;
|
||||
case "PID":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.pid}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "Last Checkin":
|
||||
return <CallbacksTableLastCheckinCell key={`callback${row.id}_${c.name}`} me={props.me}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
cellData={row.last_checkin} />;
|
||||
case "Description":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.description}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "Sleep":
|
||||
return <CallbacksTableSleepCell key={`callback${row.id}_${c.name}`}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
cellData={row.sleep_info} updateSleepInfo={updateSleepInfo} />;
|
||||
case "Agent":
|
||||
return <CallbacksTablePayloadTypeCell key={`callback${row.id}_${c.name}`}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}}
|
||||
cellData={row.payload.payloadtype.name}/>;
|
||||
case "C2":
|
||||
return <CallbacksTableC2Cell key={`callback${row.id}_c2`}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />
|
||||
case "Process Name":
|
||||
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
|
||||
cellData={row.process_short_name}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
case "Tags":
|
||||
return <CallbacksTableTagsCell key={`callback${row.id}_${c.name}`}
|
||||
cellData={row.tags}
|
||||
rowData={{...row,
|
||||
selected: row.id === clickedCallbackID,
|
||||
rowStyle: {backgroundColor: `${row.color}`},
|
||||
rowStyle: getCallbackRowStyle(row),
|
||||
}} />;
|
||||
}
|
||||
})];
|
||||
}
|
||||
}, [])
|
||||
}, [callbacks, sortData, filterOptions, columnVisibility, clickedCallbackID]);
|
||||
}, [callbacks, callbackLocalUpdates, sortData, filterOptions, columnVisibility, clickedCallbackID, onOpenTriggerDialog, onOpenUnlockDialog]);
|
||||
const onSubmitFilterOptions = (value) => {
|
||||
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
|
||||
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
|
||||
setFilterOptions(nextFilterOptions);
|
||||
try{
|
||||
updateSetting({setting_name: "callbacks_table_filter_options", value: {...filterOptions, [selectedColumn.key]: value }});
|
||||
updateSetting({setting_name: "callbacks_table_filter_options", value: nextFilterOptions, broadcast: false});
|
||||
}catch(error){
|
||||
console.log("failed to save filter options");
|
||||
}
|
||||
@@ -989,10 +1072,15 @@ function CallbacksTablePreMemo(props){
|
||||
newHidden.push(newOrder[i].name);
|
||||
}
|
||||
}
|
||||
updateSetting({setting_name: "callbacks_table_column_order", value: newOrder.map(c => c.name)});
|
||||
setColumnOrder(newOrder);
|
||||
setColumnVisibility({visible: newVisible, hidden: newHidden});
|
||||
updateSetting({setting_name: "callbacks_table_columns", value: newVisible});
|
||||
updateSettings({
|
||||
settings: {
|
||||
callbacks_table_column_order: newOrder.map(c => c.name),
|
||||
callbacks_table_columns: newVisible,
|
||||
},
|
||||
broadcast: false,
|
||||
});
|
||||
setOpenReorderDialog(false);
|
||||
}
|
||||
const onResetColumnReorder = () => {
|
||||
@@ -1000,8 +1088,8 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
const onSubmitSelectMultiple = (callbacks) => {
|
||||
eventingDataRef.current = {
|
||||
trigger_context_type: "callback_id",
|
||||
trigger_context_ids: callbacks.map(c => c.id)
|
||||
trigger_context_type: "callback_display_id",
|
||||
trigger_context_ids: callbacks.map(c => c.display_id)
|
||||
};
|
||||
setOpenEventingDialog(true);
|
||||
}
|
||||
@@ -1014,7 +1102,7 @@ function CallbacksTablePreMemo(props){
|
||||
sortIndicatorIndex={sortColumn}
|
||||
sortDirection={sortData.sortDirection}
|
||||
items={sortedData}
|
||||
rowHeight={GetComputedFontSize() + 7}
|
||||
rowHeight={GetComputedFontSize() + 15}
|
||||
onClickHeader={onClickHeader}
|
||||
onDoubleClickRow={onRowDoubleClick}
|
||||
contextMenuOptions={contextMenuOptions}
|
||||
@@ -1023,10 +1111,10 @@ function CallbacksTablePreMemo(props){
|
||||
{openContextMenu &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
|
||||
onClose={()=>{setOpenContextMenu(false);}}
|
||||
innerDialog={<MythicModifyStringDialog
|
||||
title='Filter Column'
|
||||
innerDialog={<GridColumnFilterDialog
|
||||
onSubmit={onSubmitFilterOptions}
|
||||
value={filterOptions[selectedColumn.key]}
|
||||
filterValue={filterOptions[selectedColumn.key]}
|
||||
selectedColumn={selectedColumn}
|
||||
onClose={() => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
@@ -1087,11 +1175,13 @@ function CallbacksTablePreMemo(props){
|
||||
isOpen={callbackDropdownRef.current.dropdownAnchorRef}
|
||||
onOpen={setOpenCallbackDropdown}
|
||||
externallyOpen={openCallbackDropdown}
|
||||
minWidth={285}
|
||||
menu={
|
||||
callbackDropdownRef.current.options.map((option, index) => (
|
||||
option.type === 'item' ? (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
key={"callback-action-" + index}
|
||||
className={option.className}
|
||||
disabled={option.disabled}
|
||||
onClick={(event) => handleMenuItemClick(event, option.click)}
|
||||
>
|
||||
@@ -1099,12 +1189,15 @@ function CallbacksTablePreMemo(props){
|
||||
</DropdownMenuItem>
|
||||
) : option.type === 'menu' ? (
|
||||
<DropdownNestedMenuItem
|
||||
label={option.name}
|
||||
key={"callback-action-" + index}
|
||||
className={option.className}
|
||||
label={<span className="mythic-callback-action-menu-nested-label">{option.icon}{option.name}</span>}
|
||||
disabled={option.disabled}
|
||||
menu={
|
||||
option.menuItems.map((menuOption, indx) => (
|
||||
<DropdownMenuItem
|
||||
key={menuOption.name}
|
||||
key={"callback-action-" + index + "-" + indx}
|
||||
className={menuOption.className}
|
||||
disabled={menuOption.disabled}
|
||||
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
|
||||
>
|
||||
@@ -1144,6 +1237,17 @@ function CallbacksTablePreMemo(props){
|
||||
}
|
||||
/>
|
||||
}
|
||||
{openUnlockDialog.open &&
|
||||
<MythicConfirmDialog
|
||||
open={openUnlockDialog.open}
|
||||
title={"Unlock Callback?"}
|
||||
dialogText={`Unlock callback ${openUnlockDialog.display_id} currently locked by ${openUnlockDialog.lockOwner}?`}
|
||||
acceptText={"Unlock"}
|
||||
acceptColor={"warning"}
|
||||
onClose={onCloseUnlockDialog}
|
||||
onSubmit={onSubmitUnlockDialog}
|
||||
/>
|
||||
}
|
||||
{openReorderDialog &&
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openReorderDialog}
|
||||
onClose={()=>{setOpenReorderDialog(false);}}
|
||||
@@ -1213,4 +1317,4 @@ function CallbacksTablePreMemo(props){
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
|
||||
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
|
||||
+65
-49
@@ -1,24 +1,25 @@
|
||||
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" 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${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
|
||||
<DragHandleIcon fontSize="small" />
|
||||
</span>
|
||||
<div className="mythic-reorder-row-main">
|
||||
<span className="mythic-reorder-row-title">{item.name}</span>
|
||||
</div>
|
||||
<div className="mythic-reorder-row-actions">
|
||||
<IconButton
|
||||
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
|
||||
className={`mythic-compact-icon-action ${item.visible ? "mythic-action-tone-hover mythic-tone-error" : "mythic-action-tone-hover mythic-tone-info"}`}
|
||||
size="small"
|
||||
onClick={() => onToggleVisibility(index)}
|
||||
>
|
||||
{item.visible ? (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<MythicDraggablePortal isDragging={snapshot.isDragging}>
|
||||
{row}
|
||||
</MythicDraggablePortal>
|
||||
);
|
||||
}}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
||||
|
||||
+66
-11
@@ -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>
|
||||
@@ -61,14 +68,62 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
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}
|
||||
/>
|
||||
<Button onClick={() => setColor("")} color="success" variant="outlined" size="small">
|
||||
Clear
|
||||
</Button>
|
||||
</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>
|
||||
@@ -83,4 +138,4 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
|
||||
</DialogActions>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
+73
-30
@@ -1,18 +1,17 @@
|
||||
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);
|
||||
@@ -31,29 +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 <b>not</b> checkin before finally checking in to trigger an <b>eventing workflow</b> (trigger is callback_checkin).
|
||||
</Typography>
|
||||
<Typography >
|
||||
A zero value means never trigger.
|
||||
</Typography>
|
||||
<Typography >
|
||||
If no eventing workflow for <b>callback_checkin</b> that matches the right payload_types and supported_os restrictions, then nothing will happen.
|
||||
</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">
|
||||
<span className={`mythic-callback-trigger-summary-icon ${comment > 0 ? "mythic-callback-trigger-summary-icon-active" : ""}`}>
|
||||
<NotificationsActiveTwoToneIcon fontSize="small" />
|
||||
</span>
|
||||
<div className="mythic-callback-trigger-summary-copy">
|
||||
<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">
|
||||
<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">
|
||||
<div className="mythic-callback-trigger-rule">
|
||||
Trigger name: <strong>callback_checkin</strong>
|
||||
</div>
|
||||
<div className="mythic-callback-trigger-rule">
|
||||
Matching workflow filters still apply, including payload type and supported OS restrictions.
|
||||
</div>
|
||||
<div className="mythic-callback-trigger-rule">
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,8 @@ 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';
|
||||
@@ -30,11 +29,11 @@ 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 WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import {faSocks} from '@fortawesome/free-solid-svg-icons';
|
||||
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
|
||||
|
||||
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"})
|
||||
@@ -101,44 +100,73 @@ 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";
|
||||
|
||||
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}
|
||||
<div id={`callbacksTableID${rowDataStatic.id}`} className="mythic-callback-interactCell">
|
||||
<MythicStyledTooltip title={`Open ${interactType === "interactSplit" ? "split" : interactType === "interactConsole" ? "console" : "tasking"} view${highIntegrity ? " - high integrity callback" : ""}`}>
|
||||
<IconButton
|
||||
className={`mythic-callback-iconButton mythic-callback-interactButton${highIntegrity ? " mythic-action-tone mythic-tone-error" : ""}`}
|
||||
onClick={(evt) => {evt.stopPropagation();localOnOpenTab()}}
|
||||
>
|
||||
{defaultInteractIcon}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<span className="mythic-callback-displayId">{rowDataStatic.display_id}</span>
|
||||
<IconButton
|
||||
style={{margin: 0, padding: 0}}
|
||||
color={rowDataStatic.integrity_level > 2 ? "error" : ""}
|
||||
className={`mythic-callback-iconButton mythic-callback-menuButton${highIntegrity ? " mythic-icon-tone mythic-action-tone-hover mythic-tone-error" : ""}`}
|
||||
aria-haspopup="menu"
|
||||
onClick={handleDropdownToggle}
|
||||
ref={dropdownAnchorRef}
|
||||
>
|
||||
<ArrowDropDownIcon/>
|
||||
<ArrowDropDownIcon fontSize="small" />
|
||||
</IconButton>
|
||||
{rowDataStatic.locked &&
|
||||
<MythicStyledTooltip title={`Locked by ${lockOwner}`}>
|
||||
<button type="button"
|
||||
className="mythic-callback-statusBadge mythic-callback-statusBadgeButton mythic-action-tone mythic-tone-warning"
|
||||
aria-label={`Unlock callback ${rowDataStatic.display_id}`}
|
||||
onClick={handleOpenUnlockDialog}>
|
||||
<LockIcon fontSize="inherit" />
|
||||
</button>
|
||||
</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"}/>
|
||||
<button type="button"
|
||||
className="mythic-callback-statusBadge mythic-callback-statusBadgeButton mythic-action-tone mythic-tone-warning"
|
||||
aria-label={`Adjust alert trigger for callback ${rowDataStatic.display_id}`}
|
||||
onClick={handleOpenTriggerDialog}>
|
||||
<NotificationsActiveTwoToneIcon fontSize="inherit" />
|
||||
</button>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{rowDataStatic.callbackports.length > 0 &&
|
||||
<MythicStyledTooltip title={proxyMessage()}>
|
||||
<FontAwesomeIcon icon={faSocks} size="lg" style={{color: theme.palette.success.main}}/>
|
||||
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-info">
|
||||
<FontAwesomeIcon icon={faSocks} />
|
||||
</span>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
areEqual)
|
||||
@@ -157,32 +185,34 @@ export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me
|
||||
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">
|
||||
{rowData.dead &&
|
||||
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
|
||||
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-warning">
|
||||
<WarningAmberIcon fontSize="inherit" />
|
||||
</span>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
<span className="mythic-callback-cellText">Streaming Now</span>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
<div className="mythic-callback-cellInline">
|
||||
{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",}} />
|
||||
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-warning">
|
||||
<WarningAmberIcon fontSize="inherit" />
|
||||
</span>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
<span className="mythic-callback-cellText">
|
||||
<Moment filter={adjustOutput} interval={1000} parse={"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"}
|
||||
withTitle
|
||||
titleFormat={"YYYY-MM-DD HH:mm:ss"}
|
||||
@@ -190,6 +220,7 @@ export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me
|
||||
>
|
||||
{rowData.last_checkin + "Z"}
|
||||
</Moment>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
)
|
||||
@@ -220,7 +251,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) => {
|
||||
@@ -240,13 +272,18 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
|
||||
}, [cellData]);
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "inline-flex", alignItems: "center", height: "100%"}}>
|
||||
<div className="mythic-callback-cellInline">
|
||||
{options.length > 1 &&
|
||||
<MythicStyledTooltip title={"Adjust Displayed"}>
|
||||
<UnfoldMoreIcon onClick={onClick} style={{paddingTop: "5px", cursor: "pointer", width: "unset"}} />
|
||||
<IconButton
|
||||
className="mythic-callback-iconButton mythic-callback-cellIconButton"
|
||||
onClick={onClick}
|
||||
>
|
||||
<UnfoldMoreIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
{displayIP}
|
||||
<span className="mythic-callback-cellText">{displayIP}</span>
|
||||
</div>
|
||||
{openPickIP &&
|
||||
<MythicDialog fullWidth={true} open={openPickIP} onClose={() => {setOpenPickIP(false);}}
|
||||
@@ -263,13 +300,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([]);
|
||||
@@ -277,19 +311,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
|
||||
@@ -342,21 +363,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);
|
||||
@@ -365,12 +371,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 c2ButtonClass = `mythic-callback-iconButton mythic-callback-cellIconButton ${c2RouteActive ? "mythic-action-tone-hover mythic-tone-success" : "mythic-action-tone mythic-tone-error"}`;
|
||||
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${centered ? " mythic-callback-cellInlineCenter" : ""}`}>
|
||||
<MythicStyledTooltip title={c2Tooltip}>
|
||||
<IconButton className={c2ButtonClass} onClick={onOpenC2Dialog}>
|
||||
{hasDirectEgressRoute ?
|
||||
<WifiIcon fontSize="small" /> :
|
||||
<InsertLinkTwoToneIcon fontSize="small" />
|
||||
}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{openC2Dialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
@@ -397,34 +422,42 @@ 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 mythic-callback-cellInlineCenter">
|
||||
<MythicStyledTooltip title={"View operating system information"}>
|
||||
<IconButton
|
||||
className="mythic-callback-iconButton mythic-callback-cellIconButton mythic-action-tone-hover mythic-tone-primary"
|
||||
onClick={displayOSInfo}
|
||||
>
|
||||
{getOSIcon()}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{ openOSDialog &&
|
||||
<MythicDisplayTextDialog
|
||||
onClose={()=>{setOpenOSDialog(false);}}
|
||||
@@ -440,8 +473,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});
|
||||
@@ -454,10 +486,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${centered ? " mythic-callback-cellInlineCenter" : ""}`}>
|
||||
<MythicStyledTooltip title={cellData === "" ? "No sleep information set" : "View or edit sleep information"}>
|
||||
<IconButton
|
||||
className={`mythic-callback-iconButton mythic-callback-cellIconButton ${cellData === "" ? "mythic-action-tone mythic-tone-warning" : "mythic-action-tone-hover mythic-tone-info"}`}
|
||||
onClick={onOpenSleepDialog}
|
||||
>
|
||||
<SnoozeIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{ openSleepDialog &&
|
||||
<MythicDialog fullWidth={true} open={openSleepDialog} maxWidth={"md"}
|
||||
style={{height: "100%"}}
|
||||
@@ -478,9 +515,14 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
|
||||
}, areEqual)
|
||||
export const CallbacksTableTagsCell = React.memo(({rowData, cellData}) => {
|
||||
return (
|
||||
<div>
|
||||
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} />
|
||||
<TagsDisplay tags={cellData} expand={false} />
|
||||
<div className="mythic-tag-cell mythic-tag-cell-fill">
|
||||
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} target_object_display_id={rowData.display_id} />
|
||||
<div className="mythic-tag-list mythic-callback-tag-list">
|
||||
{cellData?.length > 0 ?
|
||||
<TagsDisplay tags={cellData} expand={false} /> :
|
||||
<span className="mythic-callback-tagsEmpty">No tags</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, areEqual)
|
||||
}, areEqual)
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
CallbacksTabsCustomFileBasedBrowserLabel,
|
||||
CallbacksTabsCustomFileBasedBrowserPanel
|
||||
} from "./CallbacksTabsCustomFileBasedBrowser";
|
||||
import {CallbackBrowserTreeProvider} from "./CallbackBrowserTreeStore";
|
||||
|
||||
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clickedTabId, setClickedTabId, onEditTabDescription, contextMenuOptions, me, newDataForTab, setNewDataForTab}) {
|
||||
|
||||
@@ -74,8 +75,9 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
},]
|
||||
|
||||
return (
|
||||
<CallbackBrowserTreeProvider openTabs={openTabs}>
|
||||
<div style={{width: "100%", maxWidth: "100%", display: 'flex', flexDirection: 'column', flexGrow: 1, height: "100%" }}>
|
||||
<AppBar color='default' position={"static"} style={{backgroundColor: "transparent"}} >
|
||||
<AppBar color='default' position={"static"} className="no-box-shadow mythic-table-toolbar">
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="callback-tabs-list" direction={"horizontal"}>
|
||||
{(provided) => (
|
||||
@@ -88,7 +90,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
textColor='primary'
|
||||
variant='fullWidth'
|
||||
scrollButtons={true}
|
||||
style={{ }}
|
||||
TabIndicatorProps={{style: {display: "none"}}}
|
||||
aria-label='scrollable tabs'>
|
||||
{openTabs.map((tab, index) => {
|
||||
@@ -319,5 +320,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</CallbackBrowserTreeProvider>
|
||||
);
|
||||
}
|
||||
+149
-83
@@ -24,7 +24,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
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";
|
||||
@@ -32,9 +32,10 @@ import {CallbacksTabsCustomFileBasedBrowserTable} from "./CallbacksTabsCustomFil
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import SettingsInputComponentRoundedIcon from '@mui/icons-material/SettingsInputComponentRounded';
|
||||
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
|
||||
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
|
||||
|
||||
const fileDataFragment = gql`
|
||||
fragment fileObjData on mythictree {
|
||||
const fileBrowserDataFragment = gql`
|
||||
fragment fileBrowserObjData on mythictree {
|
||||
comment
|
||||
deleted
|
||||
task_id
|
||||
@@ -65,45 +66,45 @@ const fileDataFragment = gql`
|
||||
}
|
||||
`;
|
||||
const rootFileQuery = gql`
|
||||
${fileDataFragment}
|
||||
${fileBrowserDataFragment}
|
||||
query myRootCustomFolderQuery($tree_type: String!) {
|
||||
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: $tree_type} }) {
|
||||
...fileObjData
|
||||
...fileBrowserObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const folderQuery = gql`
|
||||
${fileDataFragment}
|
||||
${fileBrowserDataFragment}
|
||||
query myFolderQuery($parent_path_text: String!, $parents: [String], $tree_type: String!) {
|
||||
children: mythictree(
|
||||
where: { parent_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
|
||||
order_by: { can_have_children: asc, name: asc }
|
||||
) {
|
||||
...fileObjData
|
||||
...fileBrowserObjData
|
||||
}
|
||||
parents: mythictree(
|
||||
where: { full_path_text: { _in: $parents }, tree_type: {_eq: $tree_type} }
|
||||
order_by: { can_have_children: asc, name: asc }
|
||||
) {
|
||||
...fileObjData
|
||||
...fileBrowserObjData
|
||||
}
|
||||
self: mythictree(
|
||||
where: { full_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
|
||||
order_by: { can_have_children: asc, name: asc }
|
||||
) {
|
||||
...fileObjData
|
||||
...fileBrowserObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const fileDataSubscription = gql`
|
||||
${fileDataFragment}
|
||||
${fileBrowserDataFragment}
|
||||
subscription liveData($now: timestamp!, $tree_type: String!) {
|
||||
mythictree_stream(
|
||||
batch_size: 1000,
|
||||
cursor: {initial_value: {timestamp: $now}},
|
||||
where: {tree_type: {_eq: $tree_type} }
|
||||
) {
|
||||
...fileObjData
|
||||
...fileBrowserObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -199,6 +200,25 @@ export function CallbacksTabsCustomFileBasedBrowserLabel(props) {
|
||||
export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo, me, setNewDataForTab }) => {
|
||||
const fromNow = React.useRef(getSkewedNow());
|
||||
const [treeType, setTreeType] = React.useState(tabInfo.customBrowser.name);
|
||||
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,
|
||||
mode: "custom",
|
||||
active,
|
||||
onInactiveChange: markInactiveChange,
|
||||
});
|
||||
const [treeConfig, setTreeConfig] = React.useState({
|
||||
name: tabInfo.customBrowser.name,
|
||||
table: {
|
||||
@@ -216,9 +236,6 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
export_function: tabInfo.customBrowser?.export_function || "",
|
||||
});
|
||||
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,
|
||||
@@ -247,6 +264,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
const loadingCommandsRef = React.useRef(false);
|
||||
useQuery(rootFileQuery, {
|
||||
variables: {tree_type: treeType},
|
||||
skip: true,
|
||||
onCompleted: (data) => {
|
||||
// use an adjacency matrix but only for full_path_text -> children, not both directions
|
||||
// create the top level data in the treeRootDataRef
|
||||
@@ -317,6 +335,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
});
|
||||
useSubscription(fileDataSubscription, {
|
||||
variables: {now: fromNow.current, tree_type:treeType},
|
||||
skip: true,
|
||||
fetchPolicy: "no-cache",
|
||||
onData: ({data}) => {
|
||||
for(let i = 0; i < data.data.mythictree_stream.length; i++){
|
||||
@@ -385,9 +404,37 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
}
|
||||
}
|
||||
})
|
||||
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: "",
|
||||
tree_type: treeType,
|
||||
display_path_text: "",
|
||||
});
|
||||
initializedSelectionRef.current = true;
|
||||
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeType, 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, tree_type: treeType + "_browser"},
|
||||
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){
|
||||
@@ -681,7 +728,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
return (
|
||||
<MythicTabPanel index={index} value={value}>
|
||||
<Split direction="horizontal" style={{width: "100%", height: "100%", display: "flex", overflow: "hidden"}} minSize={[0,0]} sizes={[30, 70]} >
|
||||
<div className="bg-gray-base" style={{display: "inline-flex"}}>
|
||||
<div className="bg-gray-base" style={{display: "inline-flex", height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden"}}>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
@@ -701,10 +748,11 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
/>
|
||||
|
||||
</div>
|
||||
<div className="bg-gray-light" style={{display: "inline-flex"}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
|
||||
<div className="bg-gray-light" style={{display: "inline-flex", height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden"}}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, minHeight: 0, minWidth: 0, overflow: "hidden" }}>
|
||||
<div style={{ flexGrow: 0 }}>
|
||||
<FileBrowserTableTop
|
||||
active={active}
|
||||
tabInfo={tabInfo}
|
||||
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
|
||||
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
|
||||
@@ -720,11 +768,13 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
treeConfig={treeConfig}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "hidden" }}>
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
<CallbacksTabsCustomFileBasedBrowserTable
|
||||
active={active}
|
||||
dataVersion={treeVersion}
|
||||
tabInfo={tabInfo}
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
onRowDoubleClick={fetchFolderData}
|
||||
@@ -760,6 +810,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
|
||||
);
|
||||
};
|
||||
const FileBrowserTableTop = ({
|
||||
active,
|
||||
selectedFolderData,
|
||||
taskingTableTopTypedDataRef,
|
||||
autoTaskLsOnEmptyDirectoriesRef,
|
||||
@@ -772,9 +823,9 @@ const FileBrowserTableTop = ({
|
||||
tabInfo,
|
||||
treeConfig
|
||||
}) => {
|
||||
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
|
||||
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
|
||||
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
|
||||
});
|
||||
}));
|
||||
const [updateMythicSetting] = useSetMythicSetting();
|
||||
const [openEditHostDialog, setOpenEditHostDialog] = React.useState(false);
|
||||
const [fullPath, setFullPath] = React.useState('');
|
||||
@@ -848,6 +899,8 @@ const FileBrowserTableTop = ({
|
||||
}
|
||||
useSubscription(subscriptionCallbackTokens, {
|
||||
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
|
||||
skip: !active,
|
||||
ignoreResults: true,
|
||||
shouldResubscribe: true,
|
||||
onData: ({data}) => {
|
||||
setTokenOptions(data.data.callbacktoken);
|
||||
@@ -948,8 +1001,15 @@ const FileBrowserTableTop = ({
|
||||
}});
|
||||
};
|
||||
const onToggleAutoTaskLsOnEmptyDirectories = () => {
|
||||
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
|
||||
if(autoTaskLsOnEmptyDirectories){
|
||||
const nextAutoTaskLsOnEmptyDirectories = !autoTaskLsOnEmptyDirectories;
|
||||
setAutoTaskLsOnEmptyDirectories(nextAutoTaskLsOnEmptyDirectories);
|
||||
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskLsOnEmptyDirectories;
|
||||
updateMythicSetting({
|
||||
setting_name: "autoTaskLsOnEmptyDirectories",
|
||||
value: nextAutoTaskLsOnEmptyDirectories,
|
||||
broadcast: false,
|
||||
});
|
||||
if(!nextAutoTaskLsOnEmptyDirectories){
|
||||
snackActions.info("No longer auto issuing listings for empty paths");
|
||||
} else {
|
||||
snackActions.success("Now starting to auto issue listings for empty paths");
|
||||
@@ -987,7 +1047,7 @@ const FileBrowserTableTop = ({
|
||||
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
|
||||
}, [autoTaskLsOnEmptyDirectories]);
|
||||
return (
|
||||
<Grid container spacing={0} style={{ paddingTop: '5px' }}>
|
||||
<Grid container spacing={0} className="mythic-file-browser-tableTop">
|
||||
<Grid size={12}>
|
||||
{openEditHostDialog && (
|
||||
<MythicDialog
|
||||
@@ -1017,62 +1077,69 @@ const FileBrowserTableTop = ({
|
||||
name={<>
|
||||
{placeHolder}
|
||||
<MythicStyledTooltip title={`Edit the supplied Host field`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
onClick={() => {setOpenEditHostDialog(true);}}
|
||||
size="large">
|
||||
<EditIcon color='info' />
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-file-browser-labelButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={() => {setOpenEditHostDialog(true);}}
|
||||
size="small">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{placeHolderGroups}
|
||||
</>}
|
||||
marginTop="0"
|
||||
marginBottom="0"
|
||||
InputProps={{
|
||||
className: "mythic-file-browser-pathInput",
|
||||
endAdornment: (
|
||||
<React.Fragment>
|
||||
<div className="mythic-file-browser-toolbarGroup 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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalListFilesButton}
|
||||
size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{autoTaskLsOnEmptyDirectories ? (
|
||||
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
disabled={!treeConfig.show_current_path}
|
||||
size={"large"}>
|
||||
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone mythic-tone-success mythic-action-tone-hover mythic-tone-success"
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
disabled={!treeConfig.show_current_path}
|
||||
size="small">
|
||||
<PlaylistAddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
) : (
|
||||
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
disabled={!treeConfig.show_current_path}
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size={"large"}>
|
||||
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning"
|
||||
disabled={!treeConfig.show_current_path}
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size="small">
|
||||
<PlaylistRemoveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
)}
|
||||
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Entries' : 'Show Deleted Entries'}>
|
||||
<IconButton
|
||||
style={{ padding: '3px' }}
|
||||
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
|
||||
onClick={onLocalToggleShowDeletedFiles}
|
||||
size="large">
|
||||
size="small">
|
||||
{showDeletedFiles ? (
|
||||
<VisibilityIcon color="success" />
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon color={"secondary"} />
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Export Current Path and Children`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
disabled={treeConfig.export_function === ""}
|
||||
onClick={onLocalExportButton}
|
||||
color="info"
|
||||
size="large">
|
||||
<IosShareIcon/>
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
disabled={treeConfig.export_function === ""}
|
||||
onClick={onLocalExportButton}
|
||||
size="small">
|
||||
<IosShareIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{treeConfig.extra_table_inputs?.length > 0 &&
|
||||
@@ -1081,12 +1148,13 @@ const FileBrowserTableTop = ({
|
||||
showExtraInputs ?
|
||||
`Hide Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}` :
|
||||
`Show Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
size="large">
|
||||
<SettingsInputComponentRoundedIcon color={extraDataRequired ? "error" : extraDataSet ? "warning" : "info"} />
|
||||
<IconButton
|
||||
className={`mythic-file-browser-iconButton mythic-file-browser-iconButtonCompound ${extraDataRequired ? "mythic-action-tone mythic-tone-error mythic-action-tone-hover mythic-tone-error" : extraDataSet ? "mythic-action-tone mythic-tone-warning mythic-action-tone-hover mythic-tone-warning" : "mythic-action-tone-hover mythic-tone-info"}`}
|
||||
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
|
||||
disableFocusRipple={true}
|
||||
disableRipple={true}
|
||||
size="small">
|
||||
<SettingsInputComponentRoundedIcon fontSize="small" />
|
||||
{showExtraInputs ? (
|
||||
<KeyboardArrowDownRoundedIcon style={{rotate: "180deg"}} />
|
||||
) : (
|
||||
@@ -1095,47 +1163,45 @@ const FileBrowserTableTop = ({
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
),
|
||||
startAdornment: (
|
||||
<div style={{display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRight: "1px solid grey",
|
||||
marginRight: "10px",
|
||||
padding: "0 5px 0 0"}}>
|
||||
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupStart">
|
||||
{tokenOptions.length > 0 &&
|
||||
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
|
||||
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
|
||||
<div className="mythic-file-browser-tokenSelect">
|
||||
<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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
disabled={historyIndex >= history.length -1 }
|
||||
onClick={moveIndexToPreviousListing}
|
||||
size="small">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Move to next listing`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="large"
|
||||
color='info'>
|
||||
<ArrowForwardIcon />
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="small">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</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 === ""}
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalMoveUpDirectoryButton}
|
||||
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
|
||||
>
|
||||
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
|
||||
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
|
||||
</div>),
|
||||
style: { },
|
||||
}}
|
||||
/>
|
||||
{showExtraInputs &&
|
||||
|
||||
+51
-35
@@ -36,6 +36,12 @@ import {
|
||||
} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {getIconColor, getIconName} from "./ResponseDisplayTable";
|
||||
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
|
||||
import {
|
||||
GridColumnFilterDialog,
|
||||
getUpdatedGridFilterOptions,
|
||||
gridValuePassesFilter,
|
||||
isGridColumnFilterActive
|
||||
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
|
||||
|
||||
const updateFileComment = gql`
|
||||
mutation updateCommentMutation($mythictree_id: Int!, $comment: String!) {
|
||||
@@ -48,7 +54,7 @@ const updateFileComment = gql`
|
||||
|
||||
export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
const theme = useTheme();
|
||||
const [updateSetting] = useSetMythicSetting();
|
||||
const [updateSetting, updateSettings] = useSetMythicSetting();
|
||||
const [allData, setAllData] = React.useState([]);
|
||||
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
@@ -85,7 +91,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
() =>
|
||||
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}];
|
||||
@@ -176,9 +182,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
return tempData;
|
||||
}, [allData, sortData]);
|
||||
const onSubmitFilterOptions = (value) => {
|
||||
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
|
||||
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
|
||||
setFilterOptions(nextFilterOptions);
|
||||
try{
|
||||
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: {...filterOptions, [selectedColumn.key]: value }});
|
||||
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: nextFilterOptions, broadcast: false});
|
||||
}catch(error){
|
||||
console.log("failed to save filter options");
|
||||
}
|
||||
@@ -194,11 +201,11 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
}
|
||||
for(const [key,value] of Object.entries(filterOptions)){
|
||||
if(["name_text", "comment"].includes(key)){
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key]).toLowerCase().includes(value)){
|
||||
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key], value)){
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -206,14 +213,14 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
}
|
||||
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.type) {
|
||||
case "name":
|
||||
return <FileBrowserTableRowNameCell
|
||||
@@ -250,12 +257,13 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
cellData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.metadata?.[c.key] || ""}
|
||||
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";
|
||||
@@ -273,6 +281,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (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;
|
||||
@@ -282,7 +291,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (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;
|
||||
@@ -476,7 +485,8 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
{
|
||||
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({
|
||||
@@ -546,7 +556,8 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
{
|
||||
name: `Remove All Selected`, type: "item",
|
||||
disabled: removeCommand === undefined,
|
||||
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
|
||||
danger: true,
|
||||
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
|
||||
click: ({event}) => {
|
||||
event.stopPropagation();
|
||||
let newTasks = [];
|
||||
@@ -678,10 +689,15 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
snackActions.error("Can't update to show no fields");
|
||||
return;
|
||||
}
|
||||
updateSetting({setting_name: `${props.treeConfig.name}_browser_column_order`, value: newOrder.map(c => c.name)});
|
||||
setColumnOrder(newOrder);
|
||||
setColumnVisibility({visible: newVisible, hidden: newHidden});
|
||||
updateSetting({setting_name: `${props.treeConfig.name}_browser_table_columns`, value: newVisible});
|
||||
updateSettings({
|
||||
settings: {
|
||||
[`${props.treeConfig.name}_browser_column_order`]: newOrder.map(c => c.name),
|
||||
[`${props.treeConfig.name}_browser_table_columns`]: newVisible,
|
||||
},
|
||||
broadcast: false,
|
||||
});
|
||||
setOpenReorderDialog(false);
|
||||
}
|
||||
const onResetColumnReorder = () => {
|
||||
@@ -782,9 +798,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (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"}
|
||||
@@ -803,9 +819,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (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/>
|
||||
@@ -820,9 +836,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (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"}}>
|
||||
@@ -844,10 +860,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
innerDialog={
|
||||
<MythicModifyStringDialog
|
||||
title='Filter Column'
|
||||
<GridColumnFilterDialog
|
||||
onSubmit={onSubmitFilterOptions}
|
||||
value={filterOptions[selectedColumn.key]}
|
||||
filterValue={filterOptions[selectedColumn.key]}
|
||||
selectedColumn={selectedColumn}
|
||||
onClose={() => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
@@ -973,4 +989,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
|
||||
}
|
||||
};
|
||||
return getStringSize(cellData);
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -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 {
|
||||
@@ -67,14 +68,6 @@ const fileDataFragment = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
const rootFileQuery = gql`
|
||||
${fileDataFragment}
|
||||
query myRootFolderQuery {
|
||||
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: "file"} }, order_by: {id: desc}) {
|
||||
...fileObjData
|
||||
}
|
||||
}
|
||||
`;
|
||||
const folderQuery = gql`
|
||||
${fileDataFragment}
|
||||
query myFolderQuery($parent_path_text: String!, $parents: [String]) {
|
||||
@@ -98,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(
|
||||
@@ -144,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);
|
||||
@@ -200,10 +190,26 @@ export function CallbacksTabsFileBrowserLabel(props) {
|
||||
}
|
||||
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,
|
||||
@@ -226,164 +232,35 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
});
|
||||
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]}
|
||||
}
|
||||
}
|
||||
if(data.mythictree[i].id > treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].id){
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] |= data.mythictree[i].has_children;
|
||||
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].success |= data.mythictree[i].success;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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.task_id <= data.data.mythictree_stream[i].task_id){
|
||||
existingData.deleted = data.data.mythictree_stream[i].deleted;
|
||||
}
|
||||
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];
|
||||
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
|
||||
existingData.metadata.size = data.data.mythictree_stream[i].metadata.size;
|
||||
existingData.metadata.access_time = data.data.mythictree_stream[i].metadata.access_time;
|
||||
existingData.metadata.modify_time = data.data.mythictree_stream[i].metadata.modify_time;
|
||||
if(data.data.mythictree_stream[i].metadata.permissions !== undefined && data.data.mythictree_stream[i].metadata.permissions !== null){
|
||||
if(Array.isArray(existingData.metadata.permissions) && Array.isArray(data.data.mythictree_stream[i].metadata.permissions)){
|
||||
existingData.metadata.permissions = [...existingData.metadata.permissions, ...data.data.mythictree_stream[i].metadata.permissions];
|
||||
} else if(Array.isArray(existingData.metadata.permissions) !== Array.isArray(data.data.mythictree_stream[i].metadata.permissions)) {
|
||||
// had array/dict and got mismatched array/dict, so just take newer one
|
||||
existingData.metadata.permissions = data.data.mythictree_stream[i].metadata.permissions;
|
||||
} else {
|
||||
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);
|
||||
if(index !== value){
|
||||
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
|
||||
}
|
||||
}
|
||||
})
|
||||
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){
|
||||
@@ -470,7 +347,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
existingData.task_id = mythictree[i].task_id;
|
||||
}
|
||||
existingData.comment += mythictree[i].comment;
|
||||
existingData.tags = [...existingData.tags, ...mythictree[i].tags];
|
||||
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;
|
||||
@@ -478,7 +355,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
//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 = [...existingData.metadata.permissions, ...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;
|
||||
@@ -489,7 +366,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
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"] &&
|
||||
@@ -682,8 +559,8 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
}
|
||||
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" sizes={[30, 70]} >
|
||||
<div className="mythic-eventing-sidebar">
|
||||
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
|
||||
<CircularProgress color="inherit" />
|
||||
</Backdrop>
|
||||
@@ -702,10 +579,11 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
/>
|
||||
|
||||
</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">
|
||||
<div className="mythic-process-browser-table-shell">
|
||||
<div>
|
||||
<FileBrowserTableTop
|
||||
active={active}
|
||||
tabInfo={tabInfo}
|
||||
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
|
||||
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
|
||||
@@ -720,11 +598,13 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
baseUIFeature={baseUIFeature}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<div className="mythic-process-browser-grid-shell">
|
||||
<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}
|
||||
@@ -759,6 +639,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
|
||||
);
|
||||
};
|
||||
const FileBrowserTableTop = ({
|
||||
active,
|
||||
selectedFolderData,
|
||||
taskingTableTopTypedDataRef,
|
||||
autoTaskLsOnEmptyDirectoriesRef,
|
||||
@@ -772,9 +653,9 @@ const FileBrowserTableTop = ({
|
||||
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");
|
||||
@@ -803,6 +684,8 @@ const FileBrowserTableTop = ({
|
||||
}
|
||||
useSubscription(subscriptionCallbackTokens, {
|
||||
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
|
||||
skip: !active,
|
||||
ignoreResults: true,
|
||||
shouldResubscribe: true,
|
||||
onData: ({data}) => {
|
||||
setTokenOptions(data.data.callbacktoken);
|
||||
@@ -831,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){
|
||||
@@ -886,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 {
|
||||
@@ -917,7 +806,7 @@ const FileBrowserTableTop = ({
|
||||
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
|
||||
}, [autoTaskLsOnEmptyDirectories]);
|
||||
return (
|
||||
<Grid container spacing={0} style={{ paddingTop: '10px' }}>
|
||||
<Grid container spacing={0} className="mythic-file-browser-tableTop">
|
||||
<Grid size={12}>
|
||||
<MythicTextField
|
||||
placeholder={selectedFolderData.host}
|
||||
@@ -925,93 +814,98 @@ const FileBrowserTableTop = ({
|
||||
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 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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalListFilesButton}
|
||||
size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Upload file to folder via current callback (${tabInfo["displayID"]})`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
onClick={onLocalUploadFileButton}
|
||||
size="large">
|
||||
<CloudUploadIcon color="info" />
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalUploadFileButton}
|
||||
size="small">
|
||||
<CloudUploadIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone mythic-tone-success mythic-action-tone-hover mythic-tone-success"
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size="small">
|
||||
<PlaylistAddIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning"
|
||||
onClick={onToggleAutoTaskLsOnEmptyDirectories}
|
||||
size="small">
|
||||
<PlaylistRemoveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
)}
|
||||
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Files' : 'Show Deleted Files'}>
|
||||
<IconButton
|
||||
style={{ padding: '3px' }}
|
||||
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
|
||||
onClick={onLocalToggleShowDeletedFiles}
|
||||
size="large">
|
||||
size="small">
|
||||
{showDeletedFiles ? (
|
||||
<VisibilityIcon color="success" />
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon color="error" />
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
</React.Fragment>
|
||||
</div>
|
||||
),
|
||||
startAdornment: (
|
||||
<div style={{display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRight: "1px solid grey",
|
||||
marginRight: "10px",
|
||||
padding: "0 5px 0 0"}}>
|
||||
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupStart">
|
||||
{tokenOptions.length > 0 &&
|
||||
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
|
||||
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
|
||||
<div className="mythic-file-browser-tokenSelect">
|
||||
<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
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
disabled={historyIndex >= history.length -1 }
|
||||
onClick={moveIndexToPreviousListing}
|
||||
size="small">
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={`Move to next listing`}>
|
||||
<IconButton style={{ padding: '3px' }}
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="large"
|
||||
color='info'>
|
||||
<ArrowForwardIcon />
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
disabled={historyIndex <= 0}
|
||||
onClick={moveIndexToNextListing}
|
||||
size="small">
|
||||
<ArrowForwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</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 === ""}
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalMoveUpDirectoryButton}
|
||||
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
|
||||
>
|
||||
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
|
||||
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
|
||||
</div>),
|
||||
style: { },
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
@@ -31,7 +31,7 @@ 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";
|
||||
@@ -41,6 +41,12 @@ import {
|
||||
useSetMythicSetting
|
||||
} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
|
||||
import {
|
||||
GridColumnFilterDialog,
|
||||
getUpdatedGridFilterOptions,
|
||||
gridValuePassesFilter,
|
||||
isGridColumnFilterActive
|
||||
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
|
||||
|
||||
const getFileDownloadHistory = gql`
|
||||
query getFileDownloadHistory($full_path_text: String!, $host: String!, $group: [String!]) {
|
||||
@@ -89,7 +95,8 @@ const columnDefaults = [
|
||||
{ name: 'Comment', type: 'string', key: 'comment', width: 200, visible: false },
|
||||
];
|
||||
export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
const [updateSetting] = useSetMythicSetting();
|
||||
const theme = useTheme();
|
||||
const [updateSetting, updateSettings] = useSetMythicSetting();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [allData, setAllData] = React.useState([]);
|
||||
const [openContextMenu, setOpenContextMenu] = React.useState(false);
|
||||
@@ -107,7 +114,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
() =>
|
||||
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}];
|
||||
@@ -197,13 +204,12 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
return tempData;
|
||||
}, [allData, sortData]);
|
||||
const onSubmitFilterOptions = (value) => {
|
||||
|
||||
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
|
||||
setFilterOptions(nextFilterOptions);
|
||||
try{
|
||||
let newValue = value ? value : "";
|
||||
setFilterOptions({...filterOptions, [selectedColumn.key]: newValue });
|
||||
updateSetting({setting_name: `file_browser_filter_options`, value: {...filterOptions,[selectedColumn.key]: newValue }});
|
||||
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions, broadcast: false});
|
||||
}catch(error){
|
||||
console.log("failed to save filter options", error);
|
||||
console.log("failed to save filter options");
|
||||
}
|
||||
}
|
||||
const filterRow = (row) => {
|
||||
@@ -216,20 +222,20 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
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
|
||||
@@ -263,12 +269,13 @@ 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";
|
||||
@@ -286,6 +293,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;
|
||||
@@ -295,7 +303,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;
|
||||
@@ -408,11 +416,15 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
snackActions.error("Can't update to show no fields");
|
||||
return;
|
||||
}
|
||||
console.log("newOrder", newOrder)
|
||||
updateSetting({setting_name: `file_browser_column_order`, value: newOrder.map(c => c.name)});
|
||||
setColumnOrder(newOrder);
|
||||
setColumnVisibility({visible: newVisible, hidden: newHidden});
|
||||
updateSetting({setting_name: `file_browser_table_columns`, value: newVisible});
|
||||
updateSettings({
|
||||
settings: {
|
||||
file_browser_column_order: newOrder.map(c => c.name),
|
||||
file_browser_table_columns: newVisible,
|
||||
},
|
||||
broadcast: false,
|
||||
});
|
||||
setOpenReorderDialog(false);
|
||||
}
|
||||
const onResetColumnReorder = () => {
|
||||
@@ -541,7 +553,8 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
},
|
||||
{
|
||||
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({
|
||||
@@ -584,7 +597,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 = [];
|
||||
@@ -675,9 +689,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,
|
||||
}}>
|
||||
{"Only PARTIAL data has been collected for this path. "}<br/>
|
||||
{"Task this callback to list the contents"}
|
||||
@@ -696,9 +710,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/>
|
||||
@@ -713,9 +727,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,
|
||||
}}>
|
||||
{"No data has been collected for this path. "}
|
||||
<div style={{display: "flex", alignItems: "center"}}>
|
||||
@@ -735,10 +749,10 @@ export const CallbacksTabsFileBrowserTable = (props) => {
|
||||
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
|
||||
onClose={()=>{setOpenContextMenu(false);}}
|
||||
innerDialog={
|
||||
<MythicModifyStringDialog
|
||||
title='Filter Column'
|
||||
<GridColumnFilterDialog
|
||||
onSubmit={onSubmitFilterOptions}
|
||||
value={filterOptions[selectedColumn.key]}
|
||||
filterValue={filterOptions[selectedColumn.key]}
|
||||
selectedColumn={selectedColumn}
|
||||
onClose={() => {
|
||||
setOpenContextMenu(false);
|
||||
}}
|
||||
@@ -1018,6 +1032,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}
|
||||
@@ -1031,6 +1046,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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -6,9 +6,8 @@ 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`
|
||||
@@ -35,107 +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: (value, row) => {
|
||||
try{
|
||||
return JSON.parse(row.ip)[0];
|
||||
}catch(error){
|
||||
return row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (value, row) => new Date(row.last_checkin),
|
||||
renderCell: (params) =>
|
||||
<CallbacksTableLastCheckinCell rowData={params.row} />,
|
||||
},
|
||||
{
|
||||
field: "payload.payloadtype.name",
|
||||
headerName: "Agent",
|
||||
flex: 0.5,
|
||||
valueGetter: (value, row) => row.payload.payloadtype.name,
|
||||
renderCell: (params) => <CallbacksTablePayloadTypeCell rowData={params.row} />
|
||||
},
|
||||
{
|
||||
field: "mythictree_groups_string",
|
||||
headerName: "Groups",
|
||||
flex: 0.5,
|
||||
}
|
||||
];
|
||||
const CustomSelectTable = ({initialData, selectedData, sortModel}) => {
|
||||
const [data, setData] = React.useState([]);
|
||||
const [rowSelectionModel, setRowSelectionModel] = React.useState({
|
||||
type: 'include',
|
||||
ids: new Set([]),
|
||||
});
|
||||
React.useEffect( () => {
|
||||
selectedData.current = data.reduce( (prev, cur) => {
|
||||
if(rowSelectionModel.ids.has(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: [sortModel],
|
||||
},
|
||||
}}
|
||||
autoPageSize
|
||||
checkboxSelection
|
||||
onRowSelectionModelChange={(newRowSelectionModel) => {
|
||||
setRowSelectionModel(newRowSelectionModel);
|
||||
}}
|
||||
rowSelectionModel={rowSelectionModel}
|
||||
density={"compact"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
|
||||
const selectedData = React.useRef([]);
|
||||
@@ -151,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 => {
|
||||
@@ -179,9 +77,12 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'last_checkin', sort: 'asc' }}
|
||||
<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">
|
||||
@@ -198,7 +99,7 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
|
||||
|
||||
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 => {
|
||||
@@ -224,9 +125,12 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'display_id', sort: 'desc' }}
|
||||
<CallbacksTabsSelectTable initialData={initialData}
|
||||
loading={loading}
|
||||
selectedData={selectedData}
|
||||
sortModel={{ field: 'display_id', sort: 'desc' }}
|
||||
tableId="select-multiple-callbacks-table"
|
||||
tableLabel="Select multiple callbacks"
|
||||
/>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} variant="contained" color="primary">
|
||||
@@ -239,4 +143,3 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, {useContext} from 'react';
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
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`
|
||||
@@ -32,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: (value, row) => {
|
||||
try{
|
||||
return JSON.parse(row.ip)[0];
|
||||
}catch(error){
|
||||
return row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (value, row) => new Date(row.last_checkin),
|
||||
renderCell: (params) =>
|
||||
<CallbacksTableLastCheckinCell rowData={params.row} />,
|
||||
},
|
||||
{
|
||||
field: "payload.payloadtype.name",
|
||||
headerName: "Agent",
|
||||
flex: 0.5,
|
||||
valueGetter: (value, row) => 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 => {
|
||||
@@ -134,7 +45,7 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
|
||||
}
|
||||
});
|
||||
const onRowClick = (rowData) => {
|
||||
onOpenTabs([rowData.row]);
|
||||
onOpenTabs([rowData]);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,9 +55,13 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData}
|
||||
onRowClick={onRowClick}
|
||||
<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">
|
||||
@@ -156,4 +71,3 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 {CallbacksTabsProcessBrowserTable} from './CallbacksTabsProcessBrowserTable';
|
||||
@@ -13,15 +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 {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 {
|
||||
@@ -82,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}}){
|
||||
@@ -142,17 +149,38 @@ export function CallbacksTabsProcessBrowserLabel(props){
|
||||
)
|
||||
}
|
||||
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 = () => {
|
||||
@@ -172,6 +200,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
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";
|
||||
@@ -250,6 +279,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
});
|
||||
useSubscription(treeSubscription, {
|
||||
variables: {now: fromNow.current},
|
||||
skip: true,
|
||||
fetchPolicy: "no-cache",
|
||||
onData: ({data}) => {
|
||||
for(let i = 0; i < data.data.mythictree_stream.length; i++){
|
||||
@@ -277,9 +307,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
}
|
||||
}
|
||||
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
|
||||
existingData.callbacks.push(data.data.mythictree_stream[i].callback)
|
||||
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];
|
||||
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 {
|
||||
@@ -296,6 +326,19 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
}
|
||||
}
|
||||
});
|
||||
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
|
||||
@@ -321,8 +364,8 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
}
|
||||
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];
|
||||
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 {
|
||||
@@ -336,6 +379,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
const newMatrix = getNewMatrix();
|
||||
//console.log(treeRootDataRef.current);
|
||||
setTreeAdjMtx(newMatrix);
|
||||
markScopeLoaded(`host:${selectedHost}`);
|
||||
//console.log("just set treeAdjMtx, about to close backdrop")
|
||||
setBackdropOpen(false);
|
||||
}
|
||||
@@ -349,9 +393,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
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) => {
|
||||
@@ -392,10 +436,11 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
return undefined;
|
||||
}
|
||||
React.useEffect( () => {
|
||||
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;
|
||||
@@ -410,9 +455,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
<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" />
|
||||
@@ -433,8 +478,12 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
hostOptions={treeRootDataRef.current[selectedGroup] || {}}
|
||||
expandOrCollapseAll={expandOrCollapseAll}
|
||||
setExpandOrCollapseAll={setExpandOrCollapseAll}
|
||||
quickFilter={quickFilter}
|
||||
setQuickFilter={setQuickFilter}
|
||||
/>
|
||||
<CallbacksTabsProcessBrowserTable
|
||||
active={active}
|
||||
dataVersion={treeVersion}
|
||||
showDeletedFiles={showDeletedFiles}
|
||||
tabInfo={tabInfo}
|
||||
onRowDoubleClick={() => {}}
|
||||
@@ -442,6 +491,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
|
||||
treeAdjMatrix={treeAdjMtx}
|
||||
host={selectedHost}
|
||||
group={selectedGroup}
|
||||
quickFilter={quickFilter}
|
||||
expandOrCollapseAll={expandOrCollapseAll}
|
||||
onTaskRowAction={onTaskRowAction}
|
||||
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
|
||||
@@ -472,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()
|
||||
}
|
||||
@@ -495,58 +544,104 @@ const ProcessBrowserTableTop = ({
|
||||
const onLocalExpandOrCollapseAllButton = () => {
|
||||
setExpandOrCollapseAll(!expandOrCollapseAll);
|
||||
}
|
||||
const onClearQuickFilter = () => {
|
||||
setQuickFilter("");
|
||||
}
|
||||
return (
|
||||
<Grid container spacing={0} style={{paddingTop: "10px"}}>
|
||||
<Grid size={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>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
|
||||
<IconButton style={{padding: "3px"}}
|
||||
onClick={onLocalExpandOrCollapseAllButton} size={"large"}>
|
||||
<ExpandIcon color={expandOrCollapseAll ? "info" : "success"} />
|
||||
<div className="mythic-file-browser-tableTop mythic-process-browser-tableTop">
|
||||
<div className="mythic-process-browser-toolbar">
|
||||
<div className="mythic-process-browser-control mythic-process-browser-controlGroup">
|
||||
<span className="mythic-process-browser-controlLabel">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">
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={() => {setOpenViewGroupDialog(true);}}
|
||||
size="small">
|
||||
<WidgetsIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<div style={{paddingRight: "20px"}} />
|
||||
</React.Fragment>
|
||||
}
|
||||
>
|
||||
{Object.keys(hostOptions).sort().map( (opt) => (
|
||||
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="mythic-process-browser-control mythic-process-browser-controlHost">
|
||||
<span className="mythic-process-browser-controlLabel">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">
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-error"
|
||||
onClick={onClearQuickFilter}
|
||||
size="small">
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
) : null
|
||||
}}
|
||||
/>
|
||||
<div className="mythic-file-browser-toolbarGroup mythic-process-browser-actions">
|
||||
<MythicStyledTooltip title="Task current callback to list processes">
|
||||
<IconButton
|
||||
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
|
||||
onClick={onLocalListFilesButton}
|
||||
size="small">
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
|
||||
<IconButton
|
||||
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info ${expandOrCollapseAll ? "mythic-action-tone mythic-tone-success" : ""}`}
|
||||
onClick={onLocalExpandOrCollapseAllButton}
|
||||
size="small">
|
||||
<ExpandIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={showDeletedFiles ? 'Hide deleted processes' : 'Show deleted processes'}>
|
||||
<IconButton
|
||||
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
|
||||
onClick={onLocalToggleShowDeletedFiles}
|
||||
size="small">
|
||||
{showDeletedFiles ? (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOffIcon fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
</div>
|
||||
{openViewGroupsDialog &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
@@ -559,7 +654,7 @@ const ProcessBrowserTableTop = ({
|
||||
}
|
||||
/>
|
||||
}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+780
-391
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="mythicElement 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,4 +1,4 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import React from 'react';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -6,11 +6,10 @@ 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`
|
||||
@@ -24,6 +23,7 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
|
||||
integrity_level
|
||||
pid
|
||||
display_id
|
||||
operation_id
|
||||
last_checkin
|
||||
ip
|
||||
dead
|
||||
@@ -36,100 +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: (value, row) => {
|
||||
try{
|
||||
return JSON.parse(row.ip)[0];
|
||||
}catch(error){
|
||||
return row.ip;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
field: "last_checkin",
|
||||
headerName: "Checkin",
|
||||
width: 100,
|
||||
valueGetter: (value, row) => new Date(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({
|
||||
type: 'include',
|
||||
ids: new Set([]),
|
||||
});
|
||||
React.useEffect( () => {
|
||||
selectedData.current = data.reduce( (prev, cur) => {
|
||||
if(rowSelectionModel.ids.has(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({});
|
||||
@@ -138,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 => {
|
||||
@@ -157,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{
|
||||
@@ -172,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();
|
||||
@@ -199,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;
|
||||
@@ -245,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;
|
||||
@@ -257,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();
|
||||
@@ -275,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");
|
||||
@@ -295,22 +224,32 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
<DialogContentText style={{textAlign: "center"}}>
|
||||
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
|
||||
</DialogContentText>
|
||||
<CustomSelectTable initialData={initialData}
|
||||
selectedData={selectedData} />
|
||||
<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}
|
||||
<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_os={callback.payload.os} parentMountedRef={mountedRef} />
|
||||
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_ids={taskingData?.current?.callback_ids || undefined}
|
||||
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}/>
|
||||
@@ -323,4 +262,3 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -11,9 +11,10 @@ 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){
|
||||
@@ -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, setNewDataForTab}) =>{
|
||||
const [taskLimit, setTaskLimit] = React.useState(20);
|
||||
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,158 +91,39 @@ 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);
|
||||
}
|
||||
if(index !== value && fetched){
|
||||
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: 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) => {
|
||||
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];
|
||||
@@ -285,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
|
||||
@@ -325,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);
|
||||
@@ -384,6 +287,7 @@ 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">
|
||||
@@ -398,7 +302,8 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
<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}/>
|
||||
@@ -407,16 +312,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);}}
|
||||
/>}
|
||||
@@ -425,4 +334,4 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
|
||||
|
||||
</MythicTabPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
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';
|
||||
@@ -11,9 +11,10 @@ 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){
|
||||
@@ -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, setNewDataForTab}) =>{
|
||||
const [taskLimit, setTaskLimit] = React.useState(10);
|
||||
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,159 +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);
|
||||
}
|
||||
if(index !== value && fetched){
|
||||
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: 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];
|
||||
@@ -286,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
|
||||
@@ -326,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);
|
||||
@@ -380,6 +282,7 @@ 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%"}}>
|
||||
@@ -394,20 +297,24 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
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);}}
|
||||
/>}
|
||||
@@ -416,4 +323,4 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
|
||||
|
||||
</MythicTabPanel>
|
||||
);
|
||||
}
|
||||
}
|
||||
+254
-152
@@ -1,63 +1,40 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Checkbox from '@mui/material/Checkbox';
|
||||
import Chip from '@mui/material/Chip';
|
||||
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 {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}) => (
|
||||
<Chip
|
||||
className={`mythic-status-chip mythic-tone-${muted ? "neutral" : "primary"}`}
|
||||
icon={icon}
|
||||
label={label}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
|
||||
const MultiSelectField = ({label, value, options, onChange, emptyLabel}) => {
|
||||
const renderValue = (selected) => {
|
||||
if(selected.length === 0){
|
||||
return <span className="mythic-tasking-filter-select-empty">{emptyLabel}</span>
|
||||
}
|
||||
return (
|
||||
<Box className="mythic-tasking-filter-select-chips">
|
||||
{selected.map((selectedValue) => (
|
||||
<Chip key={selectedValue} label={selectedValue} size="small" className="mythic-tasking-filter-selected-chip" />
|
||||
))}
|
||||
</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,178 @@ 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">
|
||||
<DialogTitle id="mythic-draggable-title" className="mythic-accent-dialog-title">
|
||||
<Box className="mythic-accent-dialog-title-row">
|
||||
<Box className="mythic-accent-dialog-title-icon">
|
||||
<FilterAltIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box sx={{minWidth: 0}}>
|
||||
<Typography component="div" className="mythic-tasking-filter-title-main">
|
||||
Task visibility filters
|
||||
</Typography>
|
||||
<Typography component="div" className="mythic-accent-dialog-title-subtitle">
|
||||
Control which tasks are shown for this callback.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true} className="mythic-tasking-filter-dialog-content">
|
||||
<MythicDialogBody compact={true}>
|
||||
<Box className="mythic-tasking-filter-summary">
|
||||
{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">
|
||||
<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">
|
||||
<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 &&
|
||||
<Button
|
||||
className="mythic-tasking-filter-clear-button mythic-action-tone-hover mythic-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
|
||||
</Button>
|
||||
}
|
||||
</MythicFormField>
|
||||
<Box className="mythic-tasking-filter-choice-divider">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 &&
|
||||
<Button
|
||||
className="mythic-tasking-filter-clear-button mythic-action-tone-hover mythic-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
|
||||
</Button>
|
||||
}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+4
-4
@@ -42,15 +42,16 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<FormControl style={{width: props.width ? props.width : "20%"}}>
|
||||
<FormControl className={props.className || ""} size={props.modern ? "small" : undefined} style={{width: props.width ? props.width : "20%"}}>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label"
|
||||
id="demo-dialog-select"
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
variant="filled"
|
||||
variant={props.modern ? "outlined" : "filled"}
|
||||
size={props.modern ? "small" : undefined}
|
||||
renderValue={renderValue}
|
||||
input={<Input style={{width: "100%"}}/>}
|
||||
input={props.modern ? undefined : <Input style={{width: "100%"}}/>}
|
||||
>
|
||||
<MenuItem value={"Default Token"} key={0}>Default Token</MenuItem>
|
||||
{options.map( (opt) => (
|
||||
@@ -60,4 +61,3 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { TaskDisplayFlat } from './TaskDisplay';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import { MythicDialog } from '../../MythicComponents/MythicDialog';
|
||||
@@ -11,11 +11,12 @@ 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.js';
|
||||
import {createTaskingMutation} from './CallbackMutations.js';
|
||||
import Split from 'react-split';
|
||||
import {TaskDisplayContainerFlat} from "./TaskDisplayContainer";
|
||||
import { validate as uuidValidate } from 'uuid';
|
||||
import {getSkewedNow} from "../../utilities/Time";
|
||||
import {useTaskReferenceSubmitter} from "./taskingReferences";
|
||||
import {useCallbackTaskingData} from "./useCallbackTaskingData";
|
||||
|
||||
|
||||
export function CallbacksTabsTaskingSplitLabel(props){
|
||||
@@ -61,35 +62,11 @@ export function CallbacksTabsTaskingSplitLabel(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
`;
|
||||
export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTab, me, setNewDataForTab}) =>{
|
||||
const [taskLimit, setTaskLimit] = React.useState(30);
|
||||
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": [],
|
||||
@@ -103,8 +80,6 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
const mountedRef = React.useRef(true);
|
||||
const newlyIssuedTasks = useRef(null);
|
||||
|
||||
const [fetched, setFetched] = React.useState(false);
|
||||
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const [createTask] = useMutation(createTaskingMutation, {
|
||||
@@ -120,161 +95,41 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
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(!fetched){
|
||||
setFetched(true);
|
||||
}
|
||||
if(index !== value && fetched){
|
||||
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: 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
|
||||
prev[index] = {...cur}
|
||||
if(prev[index].id === selectedTask.id){
|
||||
prev[index].selected = true;
|
||||
}
|
||||
return [...prev];
|
||||
}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);
|
||||
}
|
||||
if(selectedTask.id){
|
||||
let matchedTask = mergedData.filter(d => d.id === selectedTask.id);
|
||||
if(matchedTask.length > 0){
|
||||
setSelectedTask(matchedTask[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
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){
|
||||
if(selectedTask.id === cur.id){
|
||||
return {...cur, selected: true}
|
||||
}
|
||||
return {...cur, selected: false};
|
||||
}else{
|
||||
return {...element, selected: false};
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}else{
|
||||
foundNew = true;
|
||||
return [...prev, {...cur, selected: false}];
|
||||
}
|
||||
}, [...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.map((task) => ({...task, selected: task.id === selectedTask.id})),
|
||||
}), [selectedTask.id, tasks]);
|
||||
useEffect( () => {
|
||||
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
|
||||
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];
|
||||
@@ -287,12 +142,13 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
}
|
||||
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
|
||||
@@ -327,42 +183,93 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
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,48 +284,38 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
}
|
||||
}
|
||||
const changeSelectedTask = (task) => {
|
||||
let updated = {...taskingDataRef.current};
|
||||
for(let i = 0; i < updated.task.length; i++){
|
||||
if(updated.task[i].id === task.id){
|
||||
updated.task[i].selected = true;
|
||||
} else {
|
||||
updated.task[i].selected = false;
|
||||
for(let j = 0; j < updated.task[i].tasks.length; j++){
|
||||
if(updated.task[i].tasks[j].id === task.id){
|
||||
updated.task[i].tasks[j].selected = true;
|
||||
} else {
|
||||
updated.task[i].tasks[j].selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setTaskingData(updated)
|
||||
setSelectedTask(task);
|
||||
}
|
||||
useEffect( () => {
|
||||
taskingDataRef.current = taskingData;
|
||||
if(selectedTask.id){
|
||||
const updatedSelectedTask = tasks.find((task) => task.id === selectedTask.id);
|
||||
if(updatedSelectedTask && updatedSelectedTask !== selectedTask){
|
||||
setSelectedTask(updatedSelectedTask);
|
||||
}
|
||||
}
|
||||
if(newlyIssuedTasks.current){
|
||||
// we just issued a task, check if we have data on it to set it as the current one
|
||||
let index = taskingDataRef.current.task.findIndex(e => e.id === newlyIssuedTasks.current)
|
||||
let index = tasks.findIndex(e => e.id === newlyIssuedTasks.current)
|
||||
if(index > -1){
|
||||
newlyIssuedTasks.current = null;
|
||||
if(taskingDataRef.current.task[index]?.operator?.username === (me?.user?.username || "")){
|
||||
if(tasks[index]?.operator?.username === (me?.user?.username || "")){
|
||||
let el = document.getElementById(`taskingPanelSplit${tabInfo.callbackID}`);
|
||||
if(el && el.scrollHeight - el.scrollTop - el.clientHeight < 100){
|
||||
changeSelectedTask(taskingDataRef.current.task[index]);
|
||||
changeSelectedTask(tasks[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [taskingData, newlyIssuedTasks]);
|
||||
}, [tasks, newlyIssuedTasks, selectedTask]);
|
||||
return (
|
||||
<MythicTabPanel index={index} value={value} >
|
||||
{taskReferenceSubmitDialog}
|
||||
<Split direction="horizontal" minSize={[0,0]} sizes={[30, 70]}
|
||||
style={{width: "100%", height: "100%", overflow: "auto", display: "flex", justifyContent: "flex-end"}} >
|
||||
<div className="bg-gray-base" style={{display: "inline-flex", flexDirection: "column"}} >
|
||||
style={{width: "100%", height: "100%", minWidth: 0, overflow: "hidden", display: "flex"}} >
|
||||
<div className="bg-gray-base" style={{display: "inline-flex", flexDirection: "column", minWidth: 0, overflow: "hidden"}} >
|
||||
|
||||
<div style={{overflowY: "auto", flexGrow: 1}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
|
||||
<div style={{overflowY: "auto", overflowX: "hidden", flexGrow: 1, minWidth: 0}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
|
||||
|
||||
{!fetchedAllTasks &&
|
||||
<MythicStyledTooltip title="Fetch Older Tasks">
|
||||
@@ -440,6 +337,7 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
changeSelectedTask(tsk)
|
||||
}}
|
||||
showOnSelectTask={true} selectedTask={selectedTask}
|
||||
taskChildrenStore={taskChildrenStore} active={active}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -447,28 +345,32 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column", height: "100%", width: "100%"}}>
|
||||
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column", height: "100%", minWidth: 0, overflow: "hidden", width: "100%"}}>
|
||||
{selectedTask.id > 0 && <TaskDisplayFlat key={"taskinteractdisplaysplit" + selectedTask.id} me={me} task={selectedTask}
|
||||
command_id={selectedTask.command == null ? 0 : selectedTask.command.id}
|
||||
filterOptions={filterOptions}
|
||||
onSelectTask={(tsk) => {
|
||||
}}
|
||||
selectedTask={selectedTask}
|
||||
taskChildrenStore={taskChildrenStore} active={active}
|
||||
/>}
|
||||
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions}
|
||||
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions} active={active}
|
||||
onSelectTask={(tsk) => {changeSelectedTask(tsk)}} changeSelectedTask={changeSelectedTask}
|
||||
/>
|
||||
</div>
|
||||
</Split>
|
||||
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter}
|
||||
payloadtype_name={tabInfo.payloadtype} focus={index === value}
|
||||
onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
|
||||
onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} changeSelectedToken={changeSelectedToken}
|
||||
filterOptions={filterOptions} callback_id={tabInfo.callbackID}
|
||||
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
|
||||
callback_display_id={tabInfo.displayID}
|
||||
operation_id={tabInfo.operation_id}
|
||||
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);}}
|
||||
/>}
|
||||
@@ -478,12 +380,12 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
|
||||
</MythicTabPanel>
|
||||
);
|
||||
}
|
||||
const CallbacksTabsTaskingSplitTable = ({selectedTask, me}) => {
|
||||
const CallbacksTabsTaskingSplitTable = ({selectedTask, me, active=true}) => {
|
||||
return (
|
||||
<div style={{width: "100%", height: "100%", overflowY: "auto", display: "flex", flexDirection: "column"}} >
|
||||
{selectedTask.id > 0 &&
|
||||
<div style={{width: "100%", flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "hidden", display: "flex", flexDirection: "column"}} >
|
||||
{active && selectedTask.id > 0 &&
|
||||
<TaskDisplayContainerFlat key={selectedTask.id} me={me} task={selectedTask} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,6 @@ callback(where: {active: {_eq: true}}) {
|
||||
active
|
||||
id
|
||||
display_id
|
||||
operation_id
|
||||
user
|
||||
host
|
||||
ip
|
||||
@@ -150,34 +149,17 @@ callback(where: {active: {_eq: true}}) {
|
||||
os
|
||||
process_name
|
||||
integrity_level
|
||||
extra_info
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
payload {
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
}
|
||||
}
|
||||
callbackc2profiles {
|
||||
c2profile {
|
||||
name
|
||||
has_logo
|
||||
is_p2p
|
||||
}
|
||||
}
|
||||
}
|
||||
source {
|
||||
active
|
||||
id
|
||||
display_id
|
||||
operation_id
|
||||
user
|
||||
host
|
||||
ip
|
||||
@@ -185,28 +167,12 @@ callback(where: {active: {_eq: true}}) {
|
||||
os
|
||||
process_name
|
||||
integrity_level
|
||||
extra_info
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
payload {
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
}
|
||||
}
|
||||
callbackc2profiles {
|
||||
c2profile {
|
||||
name
|
||||
has_logo
|
||||
is_p2p
|
||||
}
|
||||
}
|
||||
}
|
||||
c2profile {
|
||||
id
|
||||
@@ -225,9 +191,7 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
|
||||
destination {
|
||||
active
|
||||
id
|
||||
color
|
||||
display_id
|
||||
operation_id
|
||||
user
|
||||
host
|
||||
ip
|
||||
@@ -235,35 +199,17 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
|
||||
os
|
||||
process_name
|
||||
integrity_level
|
||||
extra_info
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
payload {
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
}
|
||||
}
|
||||
callbackc2profiles {
|
||||
c2profile {
|
||||
name
|
||||
has_logo
|
||||
is_p2p
|
||||
}
|
||||
}
|
||||
}
|
||||
source {
|
||||
active
|
||||
id
|
||||
color
|
||||
display_id
|
||||
operation_id
|
||||
user
|
||||
host
|
||||
ip
|
||||
@@ -271,28 +217,12 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
|
||||
os
|
||||
process_name
|
||||
integrity_level
|
||||
extra_info
|
||||
tags {
|
||||
tagtype {
|
||||
name
|
||||
color
|
||||
id
|
||||
}
|
||||
id
|
||||
}
|
||||
payload {
|
||||
payloadtype {
|
||||
name
|
||||
id
|
||||
}
|
||||
}
|
||||
callbackc2profiles {
|
||||
c2profile {
|
||||
name
|
||||
has_logo
|
||||
is_p2p
|
||||
}
|
||||
}
|
||||
}
|
||||
c2profile {
|
||||
id
|
||||
@@ -436,7 +366,7 @@ export function CallbacksTop(props){
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div style={{height: "100%", width: "100%"}}>
|
||||
<div style={{height: "100%", flex: "1 1 auto", minWidth: 0, overflow: "hidden"}}>
|
||||
<CallbackGraphEdgesContext.Provider value={callbackEdges.current}>
|
||||
<OnOpenTabContext.Provider value={onOpenTabLocal}>
|
||||
<OnOpenTabsContext.Provider value={onOpenTabsLocal}>
|
||||
@@ -456,4 +386,4 @@ export function CallbacksTop(props){
|
||||
</CallbackGraphEdgesContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,11 @@ 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 TableHead from '@mui/material/TableHead';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {useQuery, gql, useMutation} from '@apollo/client';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
@@ -27,12 +26,16 @@ import {DetailedPayloadTable, ParseForDisplay} from "../Payloads/DetailedPayload
|
||||
import {Button, Link, IconButton} from '@mui/material';
|
||||
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
|
||||
import MenuBookIcon from '@mui/icons-material/MenuBook';
|
||||
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
|
||||
import InfoIconOutline from '@mui/icons-material/InfoOutlined';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {HostFileDialog} from "../Payloads/HostFileDialog";
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import {payloadsCallbackAllowed} from "../Payloads/Payloads";
|
||||
import Switch from '@mui/material/Switch';
|
||||
import {MythicSectionHeader} from "../../MythicComponents/MythicPageHeader";
|
||||
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
|
||||
import {ScriptingCommandDialog} from "../PayloadTypesC2Profiles/PayloadTypeCommandsDialog";
|
||||
|
||||
const GET_Payload_Details = gql`
|
||||
query GetCallbackDetails($callback_id: Int!) {
|
||||
@@ -183,11 +186,15 @@ mutation removeLoadedCommand($id: Int!){
|
||||
}
|
||||
`;
|
||||
export function DetailedCallbackTable(props){
|
||||
const theme = useTheme();
|
||||
const me = useReactiveVar(meState);
|
||||
const [openHostDialog, setOpenHostDialog] = React.useState(false);
|
||||
const [openDetailedView, setOpenDetailedView] = React.useState(false);
|
||||
const [openAddRemoveCommandsDialog, setOpenAddRemoveCommandsDialog] = React.useState(false);
|
||||
const [openScriptDialog, setOpenScriptDialog] = React.useState({
|
||||
open: false,
|
||||
command_id: 0,
|
||||
command_name: "",
|
||||
});
|
||||
const [commands, setCommands] = React.useState([]);
|
||||
const [buildParameters, setBuildParameters] = React.useState([]);
|
||||
const [c2Profiles, setC2Profiles] = React.useState([]);
|
||||
@@ -264,7 +271,13 @@ export function DetailedCallbackTable(props){
|
||||
onCompleted: data => {
|
||||
const commandState = data.callback_by_pk.loadedcommands.map( (c) =>
|
||||
{
|
||||
return {cmd: c.command.cmd, mythic: c.command.version, payload: c.version, payload_type: c.command.payloadtype.name}
|
||||
return {
|
||||
cmd: c.command.cmd,
|
||||
command_id: c.command.id,
|
||||
mythic: c.command.version,
|
||||
payload: c.version,
|
||||
payload_type: c.command.payloadtype.name,
|
||||
}
|
||||
}).sort((a,b) => (a.cmd > b.cmd) ? 1: ((b.cmd > a.cmd) ? -1 : 0));
|
||||
setCommands(commandState);
|
||||
const buildParametersState = data.callback_by_pk.payload.buildparameterinstances.map( (b) =>
|
||||
@@ -334,18 +347,11 @@ export function DetailedCallbackTable(props){
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Callback Configuration</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
Callback Information
|
||||
</Typography>
|
||||
</Paper>
|
||||
<MythicSectionHeader title="Callback Information" sx={{mt: 0}} />
|
||||
<ExpandedCallbackSideDetailsTable {...data.callback_by_pk} />
|
||||
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
Payload Information
|
||||
</Typography>
|
||||
</Paper>
|
||||
<MythicSectionHeader title="Payload Information" />
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
<TableRow hover>
|
||||
@@ -391,9 +397,9 @@ export function DetailedCallbackTable(props){
|
||||
<TableRow hover>
|
||||
<TableCell>Download URL</TableCell>
|
||||
<TableCell style={{display: "flex", alignItems: "center"}}>
|
||||
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
|
||||
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
|
||||
{window.location.origin + "/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}
|
||||
</Link>
|
||||
</FileDownloadLinkWithAuth>
|
||||
<MythicStyledTooltip title={"Host Payload Through C2"} >
|
||||
<PublicIcon color={"info"} style={{marginLeft: "20px", cursor: "pointer"}} onClick={()=>{setOpenHostDialog(true);}} />
|
||||
</MythicStyledTooltip>
|
||||
@@ -451,11 +457,9 @@ export function DetailedCallbackTable(props){
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
Build Parameters
|
||||
</Typography>
|
||||
</Paper>
|
||||
</TableContainer>
|
||||
<MythicSectionHeader title="Build Parameters" />
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -491,11 +495,9 @@ export function DetailedCallbackTable(props){
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
Build Steps
|
||||
</Typography>
|
||||
</Paper>
|
||||
</TableContainer>
|
||||
<MythicSectionHeader title="Build Steps" />
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -521,13 +523,11 @@ export function DetailedCallbackTable(props){
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{ c2Profiles.map( (c2) => (
|
||||
<React.Fragment key={"c2frag" + data.callback_by_pk.payload.id + c2.c2_profile}>
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
{c2.c2_profile}
|
||||
</Typography>
|
||||
</Paper>
|
||||
<MythicSectionHeader title={c2.c2_profile} />
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -554,15 +554,16 @@ export function DetailedCallbackTable(props){
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
|
||||
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
|
||||
Loaded Commands
|
||||
|
||||
</Typography>
|
||||
<Button style={{float: "right"}} variant="contained" size="small" onClick={()=>{setOpenAddRemoveCommandsDialog(true)}} >Add/Remove Commands</Button>
|
||||
</Paper>
|
||||
<MythicSectionHeader
|
||||
title="Loaded Commands"
|
||||
actions={
|
||||
<Button variant="outlined" size="small" onClick={()=>{setOpenAddRemoveCommandsDialog(true)}}>Add/Remove Commands</Button>
|
||||
}
|
||||
/>
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -570,7 +571,7 @@ export function DetailedCallbackTable(props){
|
||||
<TableCell>Command Name</TableCell>
|
||||
<TableCell>Mythic Version</TableCell>
|
||||
<TableCell>Loaded Version</TableCell>
|
||||
<TableCell style={{width: "5rem"}}>Documentation</TableCell>
|
||||
<TableCell style={{width: "6.5rem"}}>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
@@ -584,10 +585,31 @@ export function DetailedCallbackTable(props){
|
||||
<TableCell>{cmd.mythic}</TableCell>
|
||||
<TableCell>{cmd.payload}</TableCell>
|
||||
<TableCell>
|
||||
<IconButton variant="contained" target="_blank"
|
||||
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}>
|
||||
<MenuBookIcon />
|
||||
</IconButton>
|
||||
<div className="mythic-compact-actions mythic-compact-actions-nowrap">
|
||||
<MythicStyledTooltip title="Open command documentation">
|
||||
<IconButton
|
||||
className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info"
|
||||
size="small"
|
||||
target="_blank"
|
||||
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}
|
||||
>
|
||||
<MenuBookIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title="Scripting parameters">
|
||||
<IconButton
|
||||
className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info"
|
||||
size="small"
|
||||
onClick={() => setOpenScriptDialog({
|
||||
open: true,
|
||||
command_id: cmd.command_id,
|
||||
command_name: cmd.cmd,
|
||||
})}
|
||||
>
|
||||
<PlayCircleFilledTwoToneIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -637,7 +659,23 @@ export function DetailedCallbackTable(props){
|
||||
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{openScriptDialog.open &&
|
||||
<MythicDialog
|
||||
fullWidth={true}
|
||||
maxWidth="lg"
|
||||
open={openScriptDialog.open}
|
||||
onClose={() => setOpenScriptDialog({open: false, command_id: 0, command_name: ""})}
|
||||
innerDialog={
|
||||
<ScriptingCommandDialog
|
||||
command_id={openScriptDialog.command_id}
|
||||
command_name={openScriptDialog.command_name}
|
||||
onClose={() => setOpenScriptDialog({open: false, command_id: 0, command_name: ""})}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
@@ -647,4 +685,3 @@ export function DetailedCallbackTable(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import DialogTitle from '@mui/material/DialogTitle';
|
||||
import {Button, Link, Typography} from '@mui/material';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import {MythicDialog} from "../../MythicComponents/MythicDialog";
|
||||
import {PreviewFileMediaDialog} from "../Search/PreviewFileMedia";
|
||||
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
|
||||
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
|
||||
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
|
||||
|
||||
export function DownloadHistoryDialog(props){
|
||||
const [history, setHistory] = React.useState([]);
|
||||
@@ -59,7 +60,7 @@ export function DownloadHistoryDialog(props){
|
||||
{hist.deleted ? (
|
||||
<Typography variant="body2" style={{wordBreak: "break-all"}}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Typography>
|
||||
) : (
|
||||
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Link>
|
||||
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</FileDownloadLinkWithAuth>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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 {gql, useMutation} from '@apollo/client';
|
||||
import { snackActions } from '../../utilities/Snackbar';
|
||||
import {DragAndDropFileUpload} from './TaskParametersDialogRow';
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogSection
|
||||
} from '../../MythicComponents/MythicDialogLayout';
|
||||
|
||||
const import_callback = gql`
|
||||
mutation importCallbackConfigMutation($config: jsonb!) {
|
||||
@@ -35,35 +40,36 @@ export function ImportCallbackConfigDialog(props) {
|
||||
snackActions.error("Failed to parse configuration")
|
||||
}
|
||||
}
|
||||
const onFileChange = (evt) => {
|
||||
const onFileChange = (newFile) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const contents = e.target.result;
|
||||
setFileValue({name: evt.target.files[0].name, contents: contents});
|
||||
setFileValue({name: newFile.name, contents: contents});
|
||||
}
|
||||
reader.readAsBinaryString(evt.target.files[0]);
|
||||
reader.readAsBinaryString(newFile);
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DialogTitle id="form-dialog-title">Import Callback Config From Other Mythic Server</DialogTitle>
|
||||
|
||||
<DialogContent dividers={true}>
|
||||
Export a callback config from another Mythic server and import it here to interact with that callback from this server.
|
||||
<br/>
|
||||
<Button variant="contained" component="label">
|
||||
{ fileValue.name === "" ? "Select File" : fileValue.name }
|
||||
<input onChange={onFileChange} type="file" hidden />
|
||||
</Button>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Callback Config File"
|
||||
description="Upload an exported callback configuration from another Mythic server."
|
||||
>
|
||||
<DragAndDropFileUpload value={fileValue} multiple={false} onChange={onFileChange} />
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={props.onClose} color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="contained" onClick={onCommitSubmit} color="success">
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
|
||||
Submit
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,24 @@
|
||||
import React, {useRef} from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Button from '@mui/material/Button';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
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 OutlinedInput from '@mui/material/OutlinedInput';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import {useQuery, gql } from '@apollo/client';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
|
||||
const PREFIX = 'ManuallyAddEdgeDialog';
|
||||
|
||||
const classes = {
|
||||
formControl: `${PREFIX}-formControl`,
|
||||
selectEmpty: `${PREFIX}-selectEmpty`
|
||||
};
|
||||
|
||||
const Root = styled('div')((
|
||||
{
|
||||
theme
|
||||
}
|
||||
) => ({
|
||||
[`& .${classes.formControl}`]: {
|
||||
margin: theme.spacing(1),
|
||||
minWidth: 120,
|
||||
width: "97%"
|
||||
},
|
||||
|
||||
[`& .${classes.selectEmpty}`]: {
|
||||
marginTop: theme.spacing(2),
|
||||
}
|
||||
}));
|
||||
import {
|
||||
MythicDialogBody,
|
||||
MythicDialogButton,
|
||||
MythicDialogFooter,
|
||||
MythicDialogGrid,
|
||||
MythicDialogSection,
|
||||
MythicFormField
|
||||
} from "../../MythicComponents/MythicDialogLayout";
|
||||
|
||||
const getP2PProfilesAndCallbacks = gql`
|
||||
query getP2PProfilesAndCallbacks{
|
||||
@@ -52,112 +36,201 @@ query getP2PProfilesAndCallbacks{
|
||||
}
|
||||
}
|
||||
`;
|
||||
const CallbackSummary = ({callback, label}) => (
|
||||
<Box className="mythic-c2-edge-callback-summary">
|
||||
{label &&
|
||||
<Typography component="div" className="mythic-c2-edge-summary-label">
|
||||
{label}
|
||||
</Typography>
|
||||
}
|
||||
<Box className="mythic-c2-edge-summary-main">
|
||||
<span className="mythic-c2-edge-callback-id">#{callback?.display_id}</span>
|
||||
<span className="mythic-c2-edge-summary-description">
|
||||
{callback?.description || "No description"}
|
||||
</span>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
const getDestinationOptions = (profiles, sourceId) => {
|
||||
const destinations = new Map();
|
||||
profiles.forEach((profile) => {
|
||||
profile.callbackc2profiles.forEach((callbackProfile) => {
|
||||
if(callbackProfile.callback.id === sourceId){return}
|
||||
const key = callbackProfile.callback.id;
|
||||
const current = destinations.get(key) || {
|
||||
callback: callbackProfile.callback,
|
||||
profiles: [],
|
||||
};
|
||||
current.profiles.push(profile);
|
||||
destinations.set(key, current);
|
||||
});
|
||||
});
|
||||
return Array.from(destinations.values()).sort((a, b) => b.callback.display_id - a.callback.display_id);
|
||||
};
|
||||
export function ManuallyAddEdgeDialog(props) {
|
||||
|
||||
const [callbackOptions, setCallbackOptions] = React.useState([]);
|
||||
const [profileOptions, setProfileOptions] = React.useState([]);
|
||||
const [selectedDestination, setSelectedDestination] = React.useState('');
|
||||
const [selectedProfile, setSelectedProfile] = React.useState('');
|
||||
const inputRefC2 = useRef(null);
|
||||
const inputRefDestination = useRef(null);
|
||||
const handleChangeProfile = (event) => {
|
||||
setSelectedProfile(event.target.value);
|
||||
if(event.target.value === ""){
|
||||
setCallbackOptions([]);
|
||||
setSelectedDestination("");
|
||||
}else{
|
||||
const cbopts = event.target.value["callbackc2profiles"].filter( (cb) => cb.callback.id !== props.source.id );
|
||||
setCallbackOptions(cbopts);
|
||||
if(cbopts.length > 0){
|
||||
setSelectedDestination(cbopts[0]);
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedProfile(event.target.value);
|
||||
};
|
||||
const handleChangeDestination = (event) => {
|
||||
setSelectedDestination(event.target.value);
|
||||
const destination = event.target.value;
|
||||
setSelectedDestination(destination);
|
||||
if(destination === ""){
|
||||
setSelectedProfile("");
|
||||
return;
|
||||
}
|
||||
const availableProfiles = destination.profiles || [];
|
||||
const profileStillValid = availableProfiles.some((profile) => profile.id === selectedProfile?.id);
|
||||
if(!profileStillValid){
|
||||
setSelectedProfile(availableProfiles[0] || "");
|
||||
}
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if(selectedDestination === ""){
|
||||
snackActions.error("Must select a valid destination");
|
||||
return;
|
||||
}
|
||||
if(selectedProfile === ""){
|
||||
snackActions.error("Must select a valid P2P C2 profile");
|
||||
return;
|
||||
}
|
||||
props.onSubmit(props.source.display_id, selectedProfile, selectedDestination.callback);
|
||||
props.onClose();
|
||||
}
|
||||
const { loading, error } = useQuery(getP2PProfilesAndCallbacks, {
|
||||
onCompleted: data => {
|
||||
setProfileOptions([...data.c2profile]);
|
||||
if(data.c2profile.length > 0){
|
||||
setSelectedProfile(data.c2profile[0]);
|
||||
const cbopts = data.c2profile[0]["callbackc2profiles"].filter( (cb) => cb.callback.id !== props.source.id );
|
||||
setCallbackOptions(cbopts);
|
||||
if(cbopts.length > 0){
|
||||
setSelectedDestination(cbopts[0]);
|
||||
}
|
||||
const profiles = [...data.c2profile];
|
||||
const destinations = getDestinationOptions(profiles, props.source.id);
|
||||
setProfileOptions(profiles);
|
||||
setCallbackOptions(destinations);
|
||||
if(destinations.length > 0){
|
||||
setSelectedDestination(destinations[0]);
|
||||
setSelectedProfile(destinations[0].profiles[0] || "");
|
||||
}else{
|
||||
setSelectedDestination("");
|
||||
setSelectedProfile("");
|
||||
}
|
||||
},
|
||||
fetchPolicy: "network-only"
|
||||
});
|
||||
const availableProfileOptions = selectedDestination === "" ? profileOptions : selectedDestination.profiles || [];
|
||||
if (loading) {
|
||||
return <LinearProgress style={{marginTop: "10px"}} />;
|
||||
return (
|
||||
<>
|
||||
<DialogTitle className="mythic-accent-dialog-title">Add P2P Edge</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<LinearProgress />
|
||||
</DialogContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return <div>Error! {error.message}</div>;
|
||||
return (
|
||||
<>
|
||||
<DialogTitle className="mythic-accent-dialog-title">Add P2P Edge</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection title="Unable to load edge options" description={error.message} />
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>Close</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Root>
|
||||
<DialogTitle >Manually Add Edge From Callback {props.source.display_id}</DialogTitle>
|
||||
<>
|
||||
<DialogTitle className="mythic-accent-dialog-title">
|
||||
<div className="mythic-dialog-title-row">
|
||||
<div>
|
||||
<Typography component="div" className="mythic-c2-action-title-text">
|
||||
Add P2P Edge
|
||||
</Typography>
|
||||
<Typography component="div" className="mythic-c2-action-title-subtitle">
|
||||
Create a manual route from callback {props.source.display_id} through a peer-to-peer C2 profile.
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers={true}>
|
||||
<React.Fragment>
|
||||
Manually add an edge from Callback {props.source.display_id} to another callback via a P2P C2 Profile.<br/>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel ref={inputRefC2}>Profile</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label-profile"
|
||||
id="demo-dialog-select"
|
||||
value={selectedProfile}
|
||||
onChange={handleChangeProfile}
|
||||
style={{minWidth: "30%"}}
|
||||
input={<Input />}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{profileOptions.map( (opt) => (
|
||||
<MenuItem value={opt} key={"profile:" + opt.id}>{opt.name}</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl><br/>
|
||||
<FormControl className={classes.formControl}>
|
||||
<InputLabel ref={inputRefDestination}>Destination</InputLabel>
|
||||
<Select
|
||||
labelId="demo-dialog-select-label-destination"
|
||||
id="demo-dialog-select-destination"
|
||||
value={selectedDestination}
|
||||
onChange={handleChangeDestination}
|
||||
input={<Input />}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{callbackOptions.map( (opt) => (
|
||||
<MenuItem value={opt} key={"callback:" + opt.callback.id}>{opt.callback.display_id} ({opt.callback.description})</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</React.Fragment>
|
||||
<MythicDialogBody>
|
||||
<MythicDialogSection
|
||||
title="Route"
|
||||
description="Choose the destination callback first, then the P2P C2 profile for that route."
|
||||
>
|
||||
<MythicDialogGrid minWidth="15rem" className="mythic-c2-edge-endpoints-grid">
|
||||
<MythicFormField label="Source callback" className="mythic-c2-edge-source-field">
|
||||
<CallbackSummary callback={props.source} />
|
||||
</MythicFormField>
|
||||
<MythicFormField
|
||||
label="Destination callback"
|
||||
required
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="manual-c2-edge-destination-label">Destination</InputLabel>
|
||||
<Select
|
||||
labelId="manual-c2-edge-destination-label"
|
||||
id="manual-c2-edge-destination"
|
||||
value={selectedDestination}
|
||||
onChange={handleChangeDestination}
|
||||
input={<OutlinedInput label="Destination" />}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{callbackOptions.map( (opt) => (
|
||||
<MenuItem value={opt} key={"callback:" + opt.callback.id}>
|
||||
#{opt.callback.display_id} {opt.callback.description ? `- ${opt.callback.description}` : ""}
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</MythicFormField>
|
||||
</MythicDialogGrid>
|
||||
<Box className="mythic-c2-edge-profile-row">
|
||||
<MythicFormField
|
||||
label="P2P C2 profile"
|
||||
description="Only profiles available for the selected destination are shown."
|
||||
required
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="manual-c2-edge-profile-label">Profile</InputLabel>
|
||||
<Select
|
||||
labelId="manual-c2-edge-profile-label"
|
||||
id="manual-c2-edge-profile"
|
||||
value={selectedProfile}
|
||||
onChange={handleChangeProfile}
|
||||
input={<OutlinedInput label="Profile" />}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>None</em>
|
||||
</MenuItem>
|
||||
{availableProfileOptions.map( (opt) => (
|
||||
<MenuItem value={opt} key={"profile:" + opt.id}>
|
||||
{opt.name}
|
||||
</MenuItem>
|
||||
) )}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</MythicFormField>
|
||||
</Box>
|
||||
</MythicDialogSection>
|
||||
</MythicDialogBody>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
<MythicDialogFooter>
|
||||
<MythicDialogButton onClick={props.onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button disabled={selectedDestination === ""} onClick={handleSubmit} variant="contained" color="success">
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Root>
|
||||
</MythicDialogButton>
|
||||
<MythicDialogButton disabled={selectedDestination === "" || selectedProfile === ""} onClick={handleSubmit} intent="primary">
|
||||
Add Edge
|
||||
</MythicDialogButton>
|
||||
</MythicDialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-2
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
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 TableHead from '@mui/material/TableHead';
|
||||
import {useQuery, gql, useMutation} from '@apollo/client';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
@@ -122,6 +123,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
|
||||
<b>Note:</b> Having <b>no</b> group entries will hide all information from this callback from your FileBrowser and ProcessBrowser views.
|
||||
</div>
|
||||
<DialogContent dividers={true}>
|
||||
<TableContainer className="mythicElement">
|
||||
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
|
||||
<TableHead>
|
||||
</TableHead>
|
||||
@@ -149,7 +151,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
|
||||
{groups.map( (a, i) => (
|
||||
<TableRow key={'array' + props.name + i} >
|
||||
<MythicStyledTableCell style={{width: "2rem", paddingLeft:"0"}}>
|
||||
<IconButton onClick={(e) => {removeArrayValue(i)}} size="large"><DeleteIcon color="error" /> </IconButton>
|
||||
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-error" size="small" onClick={(e) => {removeArrayValue(i)}}><DeleteIcon fontSize="small" /> </IconButton>
|
||||
</MythicStyledTableCell>
|
||||
<MythicStyledTableCell>
|
||||
<MythicTextField required={props.required} fullWidth={true} placeholder={""} value={a} multiline={true} autoFocus={ i > 0}
|
||||
@@ -167,6 +169,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
|
||||
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} variant="contained" color="primary">
|
||||
@@ -190,4 +193,3 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React, {useEffect} from 'react';
|
||||
import {gql, useLazyQuery, useSubscription} from '@apollo/client';
|
||||
import {snackActions} from '../../utilities/Snackbar';
|
||||
import {ResponseDisplayScreenshot} from './ResponseDisplayScreenshot';
|
||||
import {ResponseDisplayPlaintext} from './ResponseDisplayPlaintext';
|
||||
import {ResponseDisplayTable} from './ResponseDisplayTable';
|
||||
import {ResponseDisplayDownload} from './ResponseDisplayDownload';
|
||||
import {ResponseDisplaySearch} from './ResponseDisplaySearch';
|
||||
import MythicTextField from '../../MythicComponents/MythicTextField';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
@@ -18,6 +15,7 @@ import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import {ResponseDisplayGraph} from "./ResponseDisplayGraph";
|
||||
import {operatorSettingDefaults} from "../../../cache";
|
||||
import {ResponseDisplayTabs} from "./ResponseDisplayTabs";
|
||||
import {MythicEmptyState, MythicLoadingState, MythicSearchEmptyState} from "../../MythicComponents/MythicStateDisplay";
|
||||
|
||||
const subResponsesStream = gql`
|
||||
subscription subResponsesStream($task_id: Int!){
|
||||
@@ -33,32 +31,38 @@ subscription subResponsesStream($task_id: Int!){
|
||||
}
|
||||
`;
|
||||
const getResponsesLazyQuery = gql`
|
||||
query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $search: String!) {
|
||||
response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) {
|
||||
query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $where: response_bool_exp!) {
|
||||
response(where: $where, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) {
|
||||
id
|
||||
response: response_text
|
||||
timestamp
|
||||
is_error
|
||||
}
|
||||
response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){
|
||||
response_aggregate(where: $where){
|
||||
aggregate{
|
||||
count
|
||||
}
|
||||
}
|
||||
latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) {
|
||||
id
|
||||
}
|
||||
}`;
|
||||
const getAllResponsesLazyQuery = gql`
|
||||
query subResponsesQuery($task_id: Int!, $search: String!) {
|
||||
response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, order_by: {id: asc}) {
|
||||
query subResponsesQuery($task_id: Int!, $where: response_bool_exp!, $fetchLimit: Int) {
|
||||
response(where: $where, limit: $fetchLimit, order_by: {id: asc}) {
|
||||
id
|
||||
response: response_text
|
||||
timestamp
|
||||
is_error
|
||||
}
|
||||
response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){
|
||||
response_aggregate(where: $where){
|
||||
aggregate{
|
||||
count
|
||||
}
|
||||
}
|
||||
latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) {
|
||||
id
|
||||
}
|
||||
}`;
|
||||
const taskScript = gql`
|
||||
query getBrowserScriptsQuery($command_id: Int!){
|
||||
@@ -93,6 +97,45 @@ export function b64DecodeUnicode(str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const MAX_SELECT_ALL_RESPONSES = 5000;
|
||||
const DEFAULT_RESPONSE_PAGE_SIZE = operatorSettingDefaults["experiment-responseStreamLimit"] || 200;
|
||||
const normalizeResponsePageSize = (pageSize) => {
|
||||
const parsed = Number(pageSize);
|
||||
if(!Number.isFinite(parsed) || parsed <= 0){
|
||||
return DEFAULT_RESPONSE_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(parsed, MAX_SELECT_ALL_RESPONSES);
|
||||
}
|
||||
const responseWhereClause = (taskID, search) => {
|
||||
if(search === undefined || search === ""){
|
||||
return {task_id: {_eq: taskID}};
|
||||
}
|
||||
return {task_id: {_eq: taskID}, response_escape: {_ilike: "%" + search + "%"}};
|
||||
}
|
||||
const decodeResponse = (response) => {
|
||||
return {...response, response: b64DecodeUnicode(response.response)};
|
||||
}
|
||||
const responsesToOutput = (responses) => {
|
||||
return responses.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, "");
|
||||
}
|
||||
const mergeResponsesById = ({existingResponses, incomingResponses, limit, keepNewest=false}) => {
|
||||
const responseMap = new Map();
|
||||
existingResponses.forEach( (response) => responseMap.set(response.id, response));
|
||||
incomingResponses.forEach( (response) => responseMap.set(response.id, response));
|
||||
const mergedResponses = Array.from(responseMap.values()).sort( (a,b) => a.id > b.id ? 1 : -1);
|
||||
if(limit === undefined || mergedResponses.length <= limit){
|
||||
return mergedResponses;
|
||||
}
|
||||
return keepNewest ? mergedResponses.slice(-limit) : mergedResponses.slice(0, limit);
|
||||
}
|
||||
const getAggregateCount = (data) => {
|
||||
return data?.response_aggregate?.aggregate?.count || 0;
|
||||
}
|
||||
const getLatestResponseID = (data) => {
|
||||
return data?.latest_response?.[0]?.id || 0;
|
||||
}
|
||||
export const ResponseDisplay = (props) =>{
|
||||
const interactive = props?.task?.command?.supported_ui_features.includes("task_response:interactive") || false;
|
||||
return (
|
||||
@@ -105,34 +148,50 @@ export const ResponseDisplay = (props) =>{
|
||||
}
|
||||
const NonInteractiveResponseDisplay = (props) => {
|
||||
const theme = useTheme();
|
||||
const [output, setOutput] = React.useState("");
|
||||
const [rawResponses, setRawResponses] = React.useState([]);
|
||||
const seenResponseIDs = React.useRef([]);
|
||||
const search = React.useRef("");
|
||||
const rawResponsesRef = React.useRef([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [totalCount, setTotalCount] = React.useState(0);
|
||||
const totalCountRef = React.useRef(0);
|
||||
const [openBackdrop, setOpenBackdrop] = React.useState(true);
|
||||
const togglingAllOutputToPaginated = React.useRef(false);
|
||||
const initialResponseStreamLimit = GetMythicSetting({setting_name: "experiment-responseStreamLimit", default_value: operatorSettingDefaults["experiment-responseStreamLimit"]});
|
||||
const pageSize = normalizeResponsePageSize(initialResponseStreamLimit);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const currentPageRef = React.useRef(1);
|
||||
const highestKnownResponseID = React.useRef(0);
|
||||
const pageDataLoaded = React.useRef(false);
|
||||
const taskResponseCountRef = React.useRef(props.task.response_count || 0);
|
||||
const selectAllWarningShown = React.useRef(false);
|
||||
const output = React.useMemo(() => responsesToOutput(rawResponses), [rawResponses]);
|
||||
React.useEffect( () => {
|
||||
rawResponsesRef.current = rawResponses;
|
||||
}, [rawResponses]);
|
||||
const setTrackedTotalCount = React.useCallback( (newTotalCount) => {
|
||||
totalCountRef.current = newTotalCount;
|
||||
setTotalCount(newTotalCount);
|
||||
}, []);
|
||||
const warnSelectAllCapped = React.useCallback( (loadedCount, aggregateCount) => {
|
||||
if(aggregateCount > loadedCount && !selectAllWarningShown.current){
|
||||
snackActions.warning(`Task output is too large to load all at once. Showing the first ${loadedCount} responses; use pagination or download to view the rest.`);
|
||||
selectAllWarningShown.current = true;
|
||||
}
|
||||
}, []);
|
||||
const [fetchMoreResponses] = useLazyQuery(getResponsesLazyQuery, {
|
||||
fetchPolicy: "network-only",
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
data.response.forEach( (r) => {
|
||||
if(!seenResponseIDs.current.includes(r.id)){
|
||||
seenResponseIDs.current.push(r.id);
|
||||
}
|
||||
})
|
||||
// set raw responses to be what we just manually fetched
|
||||
const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
|
||||
setRawResponses(responseArray);
|
||||
|
||||
const responses = responseArray.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
setOutput(responses);
|
||||
// update maxID
|
||||
if(!props.selectAllOutput){
|
||||
setTotalCount(data.response_aggregate.aggregate.count);
|
||||
}
|
||||
const responseArray = data.response.map(decodeResponse);
|
||||
const shouldPreserveStreamedResponses = search === "" && currentPageRef.current === 1;
|
||||
const nextResponses = shouldPreserveStreamedResponses ? mergeResponsesById({
|
||||
existingResponses: rawResponsesRef.current,
|
||||
incomingResponses: responseArray,
|
||||
limit: pageSize,
|
||||
}) : responseArray;
|
||||
rawResponsesRef.current = nextResponses;
|
||||
setRawResponses(nextResponses);
|
||||
const aggregateCount = Math.max(getAggregateCount(data), shouldPreserveStreamedResponses ? nextResponses.length : 0);
|
||||
setTrackedTotalCount(aggregateCount);
|
||||
highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data));
|
||||
pageDataLoaded.current = true;
|
||||
setOpenBackdrop(false);
|
||||
},
|
||||
onError: (data) => {
|
||||
@@ -140,185 +199,203 @@ const NonInteractiveResponseDisplay = (props) => {
|
||||
}
|
||||
});
|
||||
const [fetchAllResponses] = useLazyQuery(getAllResponsesLazyQuery, {
|
||||
fetchPolicy: "network-only",
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
|
||||
setRawResponses(responseArray);
|
||||
|
||||
const responses = responseArray.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
setOutput(responses);
|
||||
|
||||
setTotalCount(1);
|
||||
const responseArray = data.response.map(decodeResponse);
|
||||
const nextResponses = search === "" ? mergeResponsesById({
|
||||
existingResponses: rawResponsesRef.current,
|
||||
incomingResponses: responseArray,
|
||||
limit: MAX_SELECT_ALL_RESPONSES,
|
||||
}) : responseArray;
|
||||
rawResponsesRef.current = nextResponses;
|
||||
setRawResponses(nextResponses);
|
||||
const aggregateCount = Math.max(getAggregateCount(data), search === "" ? nextResponses.length : 0);
|
||||
setTrackedTotalCount(aggregateCount);
|
||||
highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data));
|
||||
pageDataLoaded.current = true;
|
||||
currentPageRef.current = 1;
|
||||
setCurrentPage(1);
|
||||
warnSelectAllCapped(nextResponses.length, aggregateCount);
|
||||
setOpenBackdrop(false);
|
||||
togglingAllOutputToPaginated.current = true;
|
||||
},
|
||||
onError: (data) => {
|
||||
|
||||
snackActions.error("Failed to fetch all responses: " + data)
|
||||
}
|
||||
});
|
||||
React.useEffect( () => {
|
||||
//if(props.selectAllOutput !== oldSelectAllOutput.current){
|
||||
//oldSelectAllOutput.current = props.selectAllOutput;
|
||||
if(props.selectAllOutput){
|
||||
setOpenBackdrop(true);
|
||||
if(search.current === ""){
|
||||
fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}})
|
||||
}else{
|
||||
fetchAllResponses({variables: {task_id: props.task.id, search: "%" + search.current + "%"}})
|
||||
}
|
||||
}else if(togglingAllOutputToPaginated.current){
|
||||
// going from select all output to not select all output
|
||||
// don't fetch this on first load
|
||||
onSubmitPageChange(1);
|
||||
togglingAllOutputToPaginated.current = false;
|
||||
}
|
||||
//}
|
||||
}, [props.selectAllOutput, togglingAllOutputToPaginated.current]);
|
||||
const fetchResponsePage = React.useCallback( (requestedPage, showBackdrop=true) => {
|
||||
const nextPage = Math.max(1, requestedPage || 1);
|
||||
currentPageRef.current = nextPage;
|
||||
setCurrentPage(nextPage);
|
||||
if(showBackdrop){
|
||||
setOpenBackdrop(true);
|
||||
}
|
||||
fetchMoreResponses({variables: {task_id: props.task.id,
|
||||
fetchLimit: pageSize,
|
||||
offset: pageSize * (nextPage - 1),
|
||||
where: responseWhereClause(props.task.id, search)
|
||||
}})
|
||||
}, [fetchMoreResponses, pageSize, props.task.id, search]);
|
||||
const fetchSelectAllResponses = React.useCallback( () => {
|
||||
currentPageRef.current = 1;
|
||||
setCurrentPage(1);
|
||||
setOpenBackdrop(true);
|
||||
fetchAllResponses({variables: {
|
||||
task_id: props.task.id,
|
||||
where: responseWhereClause(props.task.id, search),
|
||||
fetchLimit: MAX_SELECT_ALL_RESPONSES
|
||||
}});
|
||||
}, [fetchAllResponses, props.task.id, search]);
|
||||
React.useEffect( () => {
|
||||
setOpenBackdrop(true);
|
||||
setOutput("");
|
||||
rawResponsesRef.current = [];
|
||||
setRawResponses([]);
|
||||
setTotalCount(0);
|
||||
//onSubmitPageChange(1);
|
||||
setTrackedTotalCount(0);
|
||||
currentPageRef.current = 1;
|
||||
setCurrentPage(1);
|
||||
highestKnownResponseID.current = 0;
|
||||
pageDataLoaded.current = false;
|
||||
taskResponseCountRef.current = props.task.response_count || 0;
|
||||
selectAllWarningShown.current = false;
|
||||
}, [props.task.id]);
|
||||
setTimeout(() => {
|
||||
// close the backdrop after 2 seconds in case there's no data to fetch
|
||||
setOpenBackdrop(false);
|
||||
}, 1000);
|
||||
const subscriptionDataCallback = ({data}) => {
|
||||
//console.log("fetchLimit", fetchLimit, "totalCount", totalCount);
|
||||
if(rawResponses.length >= initialResponseStreamLimit && initialResponseStreamLimit > 0 && !props.selectAllOutput){
|
||||
// we won't display it
|
||||
console.log("got more than we can see currently", totalCount);
|
||||
setOpenBackdrop(false);
|
||||
let newTotal = totalCount;
|
||||
data.data.response_stream.forEach( (r) => {
|
||||
if(!seenResponseIDs.current.includes(r.id)){
|
||||
newTotal += 1;
|
||||
seenResponseIDs.current.push(r.id);
|
||||
}
|
||||
})
|
||||
setTotalCount(newTotal);
|
||||
React.useEffect( () => {
|
||||
if(props.selectAllOutput){
|
||||
fetchSelectAllResponses();
|
||||
}else{
|
||||
fetchResponsePage(1);
|
||||
}
|
||||
}, [props.selectAllOutput, props.task.id, search, fetchResponsePage, fetchSelectAllResponses]);
|
||||
React.useEffect( () => {
|
||||
if(!openBackdrop){
|
||||
return;
|
||||
}
|
||||
const timeoutID = setTimeout(() => {
|
||||
setOpenBackdrop(false);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timeoutID);
|
||||
}, [openBackdrop]);
|
||||
React.useEffect( () => {
|
||||
const nextResponseCount = props.task.response_count || 0;
|
||||
const previousTaskResponseCount = taskResponseCountRef.current;
|
||||
const previousKnownResponseCount = Math.max(totalCountRef.current, previousTaskResponseCount);
|
||||
taskResponseCountRef.current = nextResponseCount;
|
||||
if(search !== ""){
|
||||
return;
|
||||
}
|
||||
if(nextResponseCount > totalCountRef.current){
|
||||
setTrackedTotalCount(nextResponseCount);
|
||||
}
|
||||
if(nextResponseCount <= previousKnownResponseCount){
|
||||
return;
|
||||
}
|
||||
const pageStartIndex = (currentPageRef.current - 1) * pageSize;
|
||||
const pageEndIndex = currentPageRef.current * pageSize;
|
||||
const currentPageCouldContainNewResponses = previousKnownResponseCount < pageEndIndex &&
|
||||
nextResponseCount > pageStartIndex;
|
||||
if(props.selectAllOutput){
|
||||
fetchSelectAllResponses();
|
||||
}else if(rawResponsesRef.current.length === 0 || currentPageCouldContainNewResponses){
|
||||
fetchResponsePage(currentPageRef.current, false);
|
||||
}
|
||||
}, [fetchResponsePage, fetchSelectAllResponses, pageSize, props.selectAllOutput, props.task.response_count, search, setTrackedTotalCount]);
|
||||
const subscriptionDataCallback = React.useCallback( ({data}) => {
|
||||
const streamedResponses = data?.data?.response_stream || [];
|
||||
if(streamedResponses.length === 0){
|
||||
return;
|
||||
}
|
||||
const previousHighestID = highestKnownResponseID.current;
|
||||
const newStreamedResponses = streamedResponses.filter( (response) => response.id > previousHighestID);
|
||||
if(newStreamedResponses.length === 0){
|
||||
return;
|
||||
}
|
||||
highestKnownResponseID.current = Math.max(previousHighestID, ...newStreamedResponses.map( (response) => response.id));
|
||||
if(search !== ""){
|
||||
const lowerCaseSearch = search.toLowerCase();
|
||||
const matchingResponseStreamed = newStreamedResponses.some( (response) => {
|
||||
return b64DecodeUnicode(response.response).toLowerCase().includes(lowerCaseSearch);
|
||||
});
|
||||
if(matchingResponseStreamed){
|
||||
fetchResponsePage(currentPageRef.current, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const currentTotalCount = Math.max(totalCountRef.current, taskResponseCountRef.current);
|
||||
if(props.selectAllOutput){
|
||||
const previousResponses = rawResponsesRef.current;
|
||||
if(previousResponses.length >= MAX_SELECT_ALL_RESPONSES){
|
||||
warnSelectAllCapped(previousResponses.length, currentTotalCount);
|
||||
return;
|
||||
}
|
||||
// we still have some room to view more, but only room for initialResponseStreamLimit - totalFetched.current
|
||||
let newTotal = totalCount;
|
||||
const newerResponses = data.data.response_stream.reduce( (prev, cur) => {
|
||||
if(!seenResponseIDs.current.includes(cur.id)){
|
||||
newTotal += 1;
|
||||
seenResponseIDs.current.push(cur.id);
|
||||
}
|
||||
let prevIndex = prev.findIndex( (v,i,a) => v.id === cur.id);
|
||||
if(prevIndex >= 0){
|
||||
prev[prevIndex] = {...cur, response: b64DecodeUnicode(cur.response)};
|
||||
return prev;
|
||||
}
|
||||
return [...prev, {...cur, response: b64DecodeUnicode(cur.response)}]
|
||||
}, rawResponses);
|
||||
// sort them to make sure we're still in order
|
||||
newerResponses.sort( (a,b) => a.id > b.id ? 1 : -1);
|
||||
setTotalCount(newTotal);
|
||||
// newerResponses is everything we've seen plus everything new
|
||||
if(initialResponseStreamLimit > 0 && !props.selectAllOutput){
|
||||
// take just the responses that make up our stream limit
|
||||
const finalRawResponses = newerResponses.slice(0, initialResponseStreamLimit);
|
||||
const outputResponses = finalRawResponses.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
if(finalRawResponses.length !== rawResponses.length){
|
||||
setRawResponses(finalRawResponses);
|
||||
setOutput(outputResponses);
|
||||
}
|
||||
} else {
|
||||
setRawResponses(newerResponses);
|
||||
const outputResponses = newerResponses.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
setOutput(outputResponses);
|
||||
const remainingResponseSlots = MAX_SELECT_ALL_RESPONSES - previousResponses.length;
|
||||
const decodedResponses = newStreamedResponses.slice(0, remainingResponseSlots).map(decodeResponse);
|
||||
const mergedResponses = mergeResponsesById({
|
||||
existingResponses: previousResponses,
|
||||
incomingResponses: decodedResponses,
|
||||
limit: MAX_SELECT_ALL_RESPONSES,
|
||||
});
|
||||
rawResponsesRef.current = mergedResponses;
|
||||
setRawResponses(mergedResponses);
|
||||
warnSelectAllCapped(mergedResponses.length, currentTotalCount);
|
||||
return;
|
||||
}
|
||||
const pageStartIndex = (currentPageRef.current - 1) * pageSize;
|
||||
const pageEndIndex = currentPageRef.current * pageSize;
|
||||
const streamedStartIndex = Math.max(0, currentTotalCount - newStreamedResponses.length);
|
||||
if(streamedStartIndex >= pageEndIndex || currentTotalCount <= pageStartIndex){
|
||||
return;
|
||||
}
|
||||
const visibleStreamedResponses = newStreamedResponses.reduce( (responses, response, index) => {
|
||||
const responseIndex = streamedStartIndex + index;
|
||||
if(responseIndex >= pageStartIndex && responseIndex < pageEndIndex){
|
||||
return [...responses, decodeResponse(response)];
|
||||
}
|
||||
setOpenBackdrop(false);
|
||||
|
||||
};
|
||||
return responses;
|
||||
}, []);
|
||||
if(visibleStreamedResponses.length === 0){
|
||||
return;
|
||||
}
|
||||
const mergedResponses = mergeResponsesById({
|
||||
existingResponses: rawResponsesRef.current,
|
||||
incomingResponses: visibleStreamedResponses,
|
||||
limit: pageSize,
|
||||
});
|
||||
rawResponsesRef.current = mergedResponses;
|
||||
setRawResponses(mergedResponses);
|
||||
}, [fetchResponsePage, pageSize, props.selectAllOutput, search, warnSelectAllCapped]);
|
||||
useSubscription(subResponsesStream, {
|
||||
variables: {task_id: props.task.id},
|
||||
fetchPolicy: "network_only",
|
||||
fetchPolicy: "no-cache",
|
||||
onData: subscriptionDataCallback
|
||||
});
|
||||
const onSubmitPageChange = (currentPage) => {
|
||||
//console.log("onSubmitPageChange")
|
||||
if(search.current === undefined || search.current === ""){
|
||||
fetchMoreResponses({variables: {task_id: props.task.id,
|
||||
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
|
||||
offset: initialResponseStreamLimit * (currentPage - 1),
|
||||
search: "%_%"
|
||||
}})
|
||||
}else{
|
||||
fetchMoreResponses({variables: {task_id: props.task.id,
|
||||
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
|
||||
offset: initialResponseStreamLimit * (currentPage - 1),
|
||||
search: "%" + search.current + "%"
|
||||
}})
|
||||
}
|
||||
const onSubmitPageChange = (nextPage) => {
|
||||
fetchResponsePage(nextPage);
|
||||
}
|
||||
const onSubmitSearch = React.useCallback( (newSearch) => {
|
||||
search.current = newSearch;
|
||||
//console.log("onSubmitSearch")
|
||||
//setOpenBackdrop(true);
|
||||
if(newSearch === undefined || newSearch === ""){
|
||||
if(props.selectAllOutput){
|
||||
fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}})
|
||||
}else{
|
||||
fetchMoreResponses({variables: {task_id: props.task.id,
|
||||
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
|
||||
offset: 0,
|
||||
search: "%_%"
|
||||
}})
|
||||
}
|
||||
|
||||
}else{
|
||||
if(props.selectAllOutput){
|
||||
fetchAllResponses({variables: {task_id: props.task.id, search: "%" + newSearch + "%"}})
|
||||
}else{
|
||||
fetchMoreResponses({variables: {task_id: props.task.id,
|
||||
fetchLimit: initialResponseStreamLimit,
|
||||
offset: 0,
|
||||
search: "%" + newSearch + "%"
|
||||
}})
|
||||
}
|
||||
|
||||
}
|
||||
}, [search.current]);
|
||||
selectAllWarningShown.current = false;
|
||||
currentPageRef.current = 1;
|
||||
setCurrentPage(1);
|
||||
setSearch(newSearch || "");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "flex", flexDirection: "column", height: "100%", width: "100%", position: "relative",
|
||||
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "D0"),
|
||||
color: theme.outputTextColor,
|
||||
}}>
|
||||
<Backdrop open={openBackdrop} onClick={()=>{setOpenBackdrop(false);}} style={{zIndex: 2, position: "absolute"}}>
|
||||
<CircularProgress color="inherit" disableShrink />
|
||||
</Backdrop>
|
||||
|
||||
{!openBackdrop &&
|
||||
<div style={{display: "flex", flexDirection: "column", height: "100%", width: "100%",
|
||||
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "D0"),
|
||||
color: theme.outputTextColor,
|
||||
}}>
|
||||
{props.searchOutput &&
|
||||
<SearchBar onSubmitSearch={onSubmitSearch} />
|
||||
}
|
||||
<div style={{overflowY: "auto", flexGrow: 1, width: "100%", height: props.expand ? "100%": undefined, display: "flex", flexDirection: "column"}} ref={props.responseRef}>
|
||||
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
|
||||
output={output} command_id={props.command_id} displayType={"accordion"}
|
||||
task={props.task} search={search.current} expand={props.expand}/>
|
||||
|
||||
</div>
|
||||
|
||||
<PaginationBar selectAllOutput={props.selectAllOutput} totalCount={totalCount} pageSize={initialResponseStreamLimit}
|
||||
onSubmitPageChange={onSubmitPageChange} task={props.task} search={search.current} />
|
||||
|
||||
</div>
|
||||
|
||||
{props.searchOutput &&
|
||||
<SearchBar onSubmitSearch={onSubmitSearch} />
|
||||
}
|
||||
</>
|
||||
<div style={{overflowY: "auto", overflowX: "hidden", flexGrow: 1, minWidth: 0, width: "100%", height: props.expand ? "100%": undefined, display: "flex", flexDirection: "column"}} ref={props.responseRef}>
|
||||
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
|
||||
output={output} command_id={props.command_id} displayType={"accordion"}
|
||||
task={props.task} search={search} expand={props.expand}/>
|
||||
</div>
|
||||
<PaginationBar selectAllOutput={props.selectAllOutput} totalCount={totalCount} pageSize={pageSize}
|
||||
onSubmitPageChange={onSubmitPageChange} task={props.task} search={search}
|
||||
currentPage={currentPage} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const ResponseDisplayConsole = (props) => {
|
||||
@@ -332,47 +409,51 @@ export const ResponseDisplayConsole = (props) => {
|
||||
)
|
||||
}
|
||||
export const NonInteractiveResponseDisplayConsole = (props) => {
|
||||
const [output, setOutput] = React.useState("");
|
||||
const [rawResponses, setRawResponses] = React.useState([]);
|
||||
const taskID = React.useRef(props.task.id);
|
||||
const rawResponsesRef = React.useRef([]);
|
||||
const initialResponseStreamLimit = GetMythicSetting({setting_name: "experiment-responseStreamLimit", default_value: operatorSettingDefaults["experiment-responseStreamLimit"]});
|
||||
const responseWindowSize = normalizeResponsePageSize(initialResponseStreamLimit);
|
||||
const output = React.useMemo(() => responsesToOutput(rawResponses), [rawResponses]);
|
||||
const consoleWindowWarningShown = React.useRef(false);
|
||||
React.useEffect( () => {
|
||||
rawResponsesRef.current = rawResponses;
|
||||
}, [rawResponses]);
|
||||
React.useEffect( () => {
|
||||
rawResponsesRef.current = [];
|
||||
setRawResponses([]);
|
||||
consoleWindowWarningShown.current = false;
|
||||
}, [props.task.id]);
|
||||
const subscriptionDataCallback = React.useCallback( ({data}) => {
|
||||
//console.log("fetchLimit", fetchLimit, "totalCount", totalCount);
|
||||
if(props.task.id !== taskID.current){
|
||||
taskID.current = props.task.id;
|
||||
const responseArray = data.data.response_stream.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
|
||||
const responses = responseArray.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
setOutput(responses);
|
||||
setRawResponses(responseArray);
|
||||
} else {
|
||||
// we still have some room to view more, but only room for initialResponseStreamLimit - totalFetched.current
|
||||
const newerResponses = data.data.response_stream.reduce( (prev, cur) => {
|
||||
let prevIndex = prev.findIndex( (v,i,a) => v.id === cur.id);
|
||||
if(prevIndex >= 0){
|
||||
prev[prevIndex] = {...cur, response: b64DecodeUnicode(cur.response)};
|
||||
return prev;
|
||||
}
|
||||
return [...prev, {...cur, response: b64DecodeUnicode(cur.response)}]
|
||||
}, rawResponses);
|
||||
// sort them to make sure we're still in order
|
||||
newerResponses.sort( (a,b) => a.id > b.id ? 1 : -1);
|
||||
setRawResponses(newerResponses);
|
||||
const outputResponses = newerResponses.reduce( (prev, cur) => {
|
||||
return prev + cur.response;
|
||||
}, b64DecodeUnicode(""));
|
||||
setOutput(outputResponses);
|
||||
const streamedResponses = data?.data?.response_stream || [];
|
||||
if(streamedResponses.length === 0){
|
||||
return;
|
||||
}
|
||||
}, [output, rawResponses, props.task.id]);
|
||||
const decodedResponses = streamedResponses.map(decodeResponse);
|
||||
const previousResponses = rawResponsesRef.current;
|
||||
const mergedResponses = mergeResponsesById({
|
||||
existingResponses: previousResponses,
|
||||
incomingResponses: decodedResponses,
|
||||
limit: responseWindowSize,
|
||||
keepNewest: true,
|
||||
});
|
||||
rawResponsesRef.current = mergedResponses;
|
||||
setRawResponses(mergedResponses);
|
||||
if(mergedResponses.length === responseWindowSize &&
|
||||
previousResponses.length + decodedResponses.length > responseWindowSize &&
|
||||
!consoleWindowWarningShown.current){
|
||||
consoleWindowWarningShown.current = true;
|
||||
snackActions.warning(`Console output is showing the latest ${responseWindowSize} responses. Older output is available through paginated task output or download.`);
|
||||
}
|
||||
}, [responseWindowSize]);
|
||||
useSubscription(subResponsesStream, {
|
||||
variables: {task_id: props.task.id},
|
||||
fetchPolicy: "network_only",
|
||||
fetchPolicy: "no-cache",
|
||||
onData: subscriptionDataCallback
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div style={{overflowY: "auto", width: "100%", height: props.expand ? "100%" : undefined}}
|
||||
<div style={{overflowY: "auto", overflowX: "hidden", minWidth: 0, width: "100%", height: props.expand ? "100%" : undefined}}
|
||||
ref={props.responseRef}>
|
||||
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
|
||||
output={output} command_id={props.command_id} displayType={"console"}
|
||||
@@ -383,32 +464,17 @@ export const NonInteractiveResponseDisplayConsole = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export const PaginationBar = ({selectAllOutput, totalCount, onSubmitPageChange, task, search, pageSize}) => {
|
||||
export const PaginationBar = ({selectAllOutput, totalCount, onSubmitPageChange, task, search, pageSize, currentPage}) => {
|
||||
const [localTotalCount, setTotalcount] = React.useState(0);
|
||||
const [maxCount, setMaxCount] = React.useState(0);
|
||||
const [currentPage, setCurrentPage] = React.useState(1);
|
||||
const onChangePage = (event, value) => {
|
||||
setCurrentPage(value);
|
||||
onSubmitPageChange(value);
|
||||
};
|
||||
React.useEffect( () => {
|
||||
if(maxCount !== task.response_count){
|
||||
setMaxCount(task.response_count);
|
||||
}
|
||||
}, [task.response_count]);
|
||||
React.useEffect( () => {
|
||||
if(selectAllOutput){
|
||||
setTotalcount(1);
|
||||
setCurrentPage(1);
|
||||
}else if(totalCount === 0) {
|
||||
setTotalcount(maxCount);
|
||||
}else{
|
||||
setTotalcount(totalCount);
|
||||
}
|
||||
}, [totalCount, maxCount, search, selectAllOutput]);
|
||||
setTotalcount(totalCount);
|
||||
}, [totalCount, search, selectAllOutput]);
|
||||
const pageCount = Math.max(1, Math.ceil(localTotalCount / pageSize));
|
||||
// don't bother people with pagination information if they haven't even started paginating
|
||||
if(pageCount < 2 || pageCount === Infinity || isNaN(pageCount)){
|
||||
if(selectAllOutput || pageCount < 2 || pageCount === Infinity || isNaN(pageCount)){
|
||||
return (<div id={'scrolltotaskbottom' + task.id}></div>)
|
||||
}
|
||||
return (
|
||||
@@ -446,7 +512,7 @@ export const SearchBar = ({onSubmitSearch}) => {
|
||||
|
||||
const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, command_id, task, search, expand, displayType}) => {
|
||||
const [localViewBrowserScript, setViewBrowserScript] = React.useState(true);
|
||||
const [browserScriptData, setBrowserScriptData] = React.useState({});
|
||||
const [browserScriptData, setBrowserScriptData] = React.useState(undefined);
|
||||
const [loadingBrowserScript, setLoadingBrowserScript] = React.useState(true);
|
||||
const script = React.useRef(undefined);
|
||||
const filterOutput = (scriptData) => {
|
||||
@@ -492,6 +558,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
|
||||
}
|
||||
if(script.current === undefined){
|
||||
setViewBrowserScript(false);
|
||||
setBrowserScriptData({});
|
||||
return;
|
||||
}
|
||||
if(viewBrowserScript){
|
||||
@@ -499,8 +566,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
|
||||
const rawResponseData = rawResponses.map(c => c.response);
|
||||
let res = script.current(task, rawResponseData);
|
||||
if(Object.keys(res).length === 0){
|
||||
// if we run the browser script and there's no data, just display plaintext
|
||||
setViewBrowserScript(false);
|
||||
setBrowserScriptData({});
|
||||
return;
|
||||
}
|
||||
setViewBrowserScript(viewBrowserScript);
|
||||
@@ -513,7 +579,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setViewBrowserScript(false);
|
||||
return;
|
||||
}
|
||||
}, [rawResponses, task, loadingBrowserScript, viewBrowserScript]);
|
||||
const [fetchScripts] = useLazyQuery(taskScript, {
|
||||
@@ -522,8 +588,10 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
|
||||
if(data.browserscript.length > 0){
|
||||
try{
|
||||
script.current = Function(`"use strict";return(${data.browserscript[0]["script"]})`)();
|
||||
setBrowserScriptData(undefined);
|
||||
}catch(error){
|
||||
script.current = undefined;
|
||||
setBrowserScriptData({});
|
||||
console.log(error);
|
||||
}
|
||||
}else{
|
||||
@@ -539,33 +607,70 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
|
||||
useEffect( () => {
|
||||
if(command_id !== undefined){
|
||||
setLoadingBrowserScript(true);
|
||||
setBrowserScriptData(undefined);
|
||||
setViewBrowserScript(true);
|
||||
fetchScripts({variables: {command_id: command_id}});
|
||||
}
|
||||
}, [command_id, task.id]);
|
||||
const stateSx = {
|
||||
backgroundColor: "transparent",
|
||||
color: "inherit",
|
||||
height: expand ? "100%" : undefined,
|
||||
};
|
||||
if(loadingBrowserScript){
|
||||
return null
|
||||
return (
|
||||
<MythicLoadingState
|
||||
compact
|
||||
title="Preparing output"
|
||||
description="Loading the response renderer for this command."
|
||||
sx={stateSx}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if(rawResponses.length === 0 && output.length === 0){
|
||||
return search ? (
|
||||
<MythicSearchEmptyState
|
||||
compact
|
||||
description="No task output matched the current search."
|
||||
sx={stateSx}
|
||||
/>
|
||||
) : (
|
||||
<MythicEmptyState
|
||||
compact
|
||||
title="No output yet"
|
||||
description="Task output will appear here as responses arrive."
|
||||
sx={stateSx}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const shouldUseBrowserScript = viewBrowserScript && localViewBrowserScript;
|
||||
if(shouldUseBrowserScript && browserScriptData === undefined){
|
||||
return (
|
||||
<MythicLoadingState
|
||||
compact
|
||||
title="Rendering output"
|
||||
description="Preparing the browser script view."
|
||||
sx={stateSx}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
localViewBrowserScript ? (
|
||||
shouldUseBrowserScript ? (
|
||||
<ResponseDisplayBrowserScriptComponent expand={expand} displayType={displayType}
|
||||
task={task} browserScriptData={browserScriptData}
|
||||
output={output}/>
|
||||
output={output} allowPlaintextFallback={false}/>
|
||||
) : (
|
||||
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function ResponseDisplayBrowserScriptComponent({output, browserScriptData, task, expand, displayType}) {
|
||||
export function ResponseDisplayBrowserScriptComponent({output, browserScriptData, task, expand, displayType, allowPlaintextFallback=true}) {
|
||||
const hasBrowserScriptData = browserScriptData && Object.keys(browserScriptData).length > 0;
|
||||
return (
|
||||
<>
|
||||
{Object.keys(browserScriptData).length > 0 ? (
|
||||
<div className={`mythic-browser-script-response${expand ? " mythic-browser-script-response-expanded" : ""}`}>
|
||||
{hasBrowserScriptData ? (
|
||||
<>
|
||||
{browserScriptData?.screenshot?.map( (scr, index) => (
|
||||
<ResponseDisplayScreenshot key={"screenshot" + index + 'fortask' + task.id} task={task} {...scr}
|
||||
displayType={displayType} expand={expand} />
|
||||
))
|
||||
}
|
||||
{browserScriptData?.plaintext !== undefined &&
|
||||
<ResponseDisplayPlaintext plaintext={browserScriptData["plaintext"]} task={task}
|
||||
expand={expand} displayType={displayType} />
|
||||
@@ -577,16 +682,6 @@ export function ResponseDisplayBrowserScriptComponent({output, browserScriptData
|
||||
/>
|
||||
))
|
||||
}
|
||||
{browserScriptData?.download?.map( (dl, index) => (
|
||||
<ResponseDisplayDownload download={dl} task={task} displayType={displayType}
|
||||
key={"download" + index + "fortask" + task.id} />
|
||||
))
|
||||
}
|
||||
{browserScriptData?.search?.map( (s, index) => (
|
||||
<ResponseDisplaySearch search={s} task={task} displayType={displayType}
|
||||
key={"searchlink" + index + "fortask" + task.id} />
|
||||
))
|
||||
}
|
||||
{browserScriptData?.media?.map( (s, index) => (
|
||||
<ResponseDisplayMedia key={"searchmedia" + index + "fortask" + task.id}
|
||||
displayType={displayType}
|
||||
@@ -601,9 +696,11 @@ export function ResponseDisplayBrowserScriptComponent({output, browserScriptData
|
||||
}
|
||||
</>
|
||||
) : (
|
||||
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
|
||||
allowPlaintextFallback ? (
|
||||
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import GetAppIcon from '@mui/icons-material/GetApp';
|
||||
import { Button } from '@mui/material';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
|
||||
|
||||
export const ResponseDisplayDownload = (props) =>{
|
||||
const scrollContent = (node, isAppearing) => {
|
||||
// only auto-scroll if you issued the task
|
||||
document.getElementById(`scrolltotaskbottom${props.task.id}`)?.scrollIntoView({
|
||||
//behavior: "smooth",
|
||||
block: "end",
|
||||
inline: "nearest"
|
||||
})
|
||||
}
|
||||
React.useLayoutEffect( () => {
|
||||
scrollContent()
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<pre style={{display: "inline-block"}}>
|
||||
{props.download?.plaintext || ""}
|
||||
</pre>
|
||||
|
||||
<MythicStyledTooltip title={props?.download?.hoverText || "Download payload"} >
|
||||
<Button variant={props.download?.variant || "contained"} component="a" target="_blank" color="primary" href={"/api/v1.4/files/download/" + props.download.agent_file_id} download
|
||||
startIcon={<GetAppIcon />}>
|
||||
{props.download?.name || ""}
|
||||
</Button>
|
||||
</MythicStyledTooltip><br/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +78,15 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
|
||||
if(!showGraph){
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "flex", width: "100%", height: "100%", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
|
||||
<Typography variant={"h4"} >
|
||||
{`Graph Hidden by Default Due to Size: Nodes (${graph.nodes.length}), Edges (${graph.edges.length}) `}
|
||||
<div className="mythic-graph-empty-state">
|
||||
<Typography component="div" className="mythic-graph-empty-title">
|
||||
Large graph hidden
|
||||
</Typography>
|
||||
<Button variant={"contained"} color={"error"} onClick={() => {setShowGraph(!showGraph)}}>
|
||||
{"Show Graph"}
|
||||
<Typography component="div" className="mythic-graph-empty-description">
|
||||
{`This response contains ${graph.nodes.length} nodes and ${graph.edges.length} edges.`}
|
||||
</Typography>
|
||||
<Button className="mythic-graph-empty-action mythic-action-tone-hover mythic-tone-info" variant={"contained"} onClick={() => {setShowGraph(!showGraph)}}>
|
||||
Show Graph
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -92,9 +95,12 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
|
||||
if(graph.nodes.length === 0){
|
||||
return (
|
||||
<>
|
||||
<div style={{display: "flex", width: "100%", height: "100%", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
|
||||
<Typography variant={"h4"} >
|
||||
{`Empty Graph`}
|
||||
<div className="mythic-graph-empty-state">
|
||||
<Typography component="div" className="mythic-graph-empty-title">
|
||||
Empty graph
|
||||
</Typography>
|
||||
<Typography component="div" className="mythic-graph-empty-description">
|
||||
There are no nodes to render for this response.
|
||||
</Typography>
|
||||
</div>
|
||||
</>
|
||||
@@ -118,4 +124,4 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,13 +2,10 @@ import React from 'react';
|
||||
import {Typography, Link} from '@mui/material';
|
||||
import { Button, IconButton } from '@mui/material';
|
||||
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import AceEditor from 'react-ace';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import WrapTextIcon from '@mui/icons-material/WrapText';
|
||||
import {mythicFetch} from "../../utilities/MythicConnection";
|
||||
// https://github.com/ajaxorg/ace-builds/tree/master/src-min-noconflict
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/mode-csharp';
|
||||
@@ -44,12 +41,11 @@ import Tabs from '@mui/material/Tabs';
|
||||
import TabContext from '@mui/lab/TabContext';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import {previewFileQuery} from "../Search/FileMetaTable";
|
||||
import { useMutation, gql, useQuery } from '@apollo/client';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import { useMutation, gql } from '@apollo/client';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import {b64DecodeUnicode} from "./ResponseDisplay";
|
||||
import {MythicDialog, TableRowSizeCell} from "../../MythicComponents/MythicDialog";
|
||||
import {Table, TableHead, TableRow, TableBody, TableCell, Paper} from '@mui/material';
|
||||
import {Table, TableContainer, TableHead, TableRow, TableBody, TableCell, Paper} from '@mui/material';
|
||||
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
|
||||
import WarningOutlinedIcon from '@mui/icons-material/WarningOutlined';
|
||||
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
|
||||
@@ -63,6 +59,11 @@ import {Backdrop, CircularProgress} from '@mui/material';
|
||||
// eslint-disable-next-line import/no-webpack-loader-syntax
|
||||
import sqlWasm from "!!file-loader?name=sql-wasm-[contenthash].wasm!sql.js/dist/sql-wasm.wasm";
|
||||
import {copyStringToClipboard} from "../../utilities/Clipboard";
|
||||
import {FileDownloadButtonWithAuth, FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
|
||||
import {ImageWithAuth} from "../../utilities/ImageWithAuth";
|
||||
import {ResponseDisplayPlaintext} from "./ResponseDisplayPlaintext";
|
||||
import {UploadDirectFile} from "../../MythicComponents/MythicFileUpload";
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
|
||||
export const modeOptions = ["csharp", "golang", "html", "json", "markdown", "ruby", "python", "java",
|
||||
"javascript", "yaml", "toml", "swift", "sql", "rust", "powershell", "pgsql", "perl", "php", "objectivec",
|
||||
@@ -77,6 +78,7 @@ const fileMetaQuery = gql`
|
||||
size
|
||||
agent_file_id
|
||||
complete
|
||||
deleted
|
||||
total_chunks
|
||||
chunks_received
|
||||
task {
|
||||
@@ -98,6 +100,12 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
|
||||
const displayType = mimeType(media?.filename);
|
||||
const [value, setValue] = React.useState(displayType !== undefined ? 0 : 1);
|
||||
const [fileMetaData, setFileMetaData] = React.useState({});
|
||||
const panelStyle = {
|
||||
flex: "1 1 auto",
|
||||
minHeight: expand ? 0 : "24rem",
|
||||
overflow: "auto",
|
||||
width: "100%",
|
||||
};
|
||||
const fetchedData = ({data}) => {
|
||||
if(data.filemeta.length > 0){
|
||||
setFileMetaData({
|
||||
@@ -110,6 +118,7 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
|
||||
agent_file_id: data.filemeta[0].agent_file_id,
|
||||
task_display_id: data.filemeta[0]?.task?.display_id,
|
||||
complete: data.filemeta[0].complete,
|
||||
deleted: data.filemeta[0].deleted,
|
||||
total_chunks: data.filemeta[0].total_chunks,
|
||||
chunks_received: data.filemeta[0].chunks_received
|
||||
})
|
||||
@@ -127,9 +136,9 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
|
||||
setValue(newValue);
|
||||
}
|
||||
return (
|
||||
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
|
||||
<div className="mythic-response-media" style={{height: expand ? "100%" : undefined, minHeight: expand ? 0 : "24rem"}}>
|
||||
<DisplayFileMetaData fileMetaData={fileMetaData} />
|
||||
<AppBar color={'default'} position='static' className={"no-box-shadow"}>
|
||||
<AppBar color={'default'} position='static' className={"no-box-shadow mythic-response-media-tabs"}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
@@ -149,30 +158,32 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
|
||||
<Tab className={value === 2 ? "selectedCallback": ""} label={"Hex"}></Tab>
|
||||
<Tab className={value === 3 ? "selectedCallback": ""} label={"Database"}></Tab>
|
||||
<MythicStyledTooltip title={"Download the file"} tooltipStyle={{display: "inline-flex"}}>
|
||||
<Button style={{}} size={"small"} href={"/direct/download/" + media.agent_file_id}
|
||||
<FileDownloadButtonWithAuth style={{}} size={"small"} href={"/direct/download/" + media.agent_file_id}
|
||||
download color={"success"}>
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
</FileDownloadButtonWithAuth>
|
||||
</MythicStyledTooltip>
|
||||
</Tabs>
|
||||
</AppBar>
|
||||
<div hidden={value !== 0} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
|
||||
<div hidden={value !== 0} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
|
||||
{value === 0 &&
|
||||
<DisplayMedia agent_file_id={media?.agent_file_id || ""}
|
||||
task={task} filename={media?.filename || undefined}
|
||||
fileMetaData={fileMetaData}
|
||||
editable={Boolean(media?.editable)}
|
||||
expand={expand} />
|
||||
}
|
||||
</div>
|
||||
<div hidden={value !== 1} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
|
||||
<div hidden={value !== 1} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
|
||||
{value === 1 &&
|
||||
<DisplayText agent_file_id={media?.agent_file_id || ""}
|
||||
task={task} filename={media?.filename || undefined}
|
||||
fileMetaData={fileMetaData}
|
||||
editable={Boolean(media?.editable)}
|
||||
expand={expand} preview />
|
||||
}
|
||||
</div>
|
||||
<div hidden={value !== 2} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
|
||||
<div hidden={value !== 2} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
|
||||
{value === 2 &&
|
||||
<DisplayHex agent_file_id={media?.agent_file_id || ""}
|
||||
task={task} filename={media?.filename || undefined}
|
||||
@@ -181,7 +192,7 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
|
||||
}
|
||||
|
||||
</div>
|
||||
<div hidden={value !== 3} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
|
||||
<div hidden={value !== 3} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
|
||||
{value === 3 &&
|
||||
<DisplayDatabase agent_file_id={media?.agent_file_id || ""}
|
||||
task={task} filename={media?.filename || undefined}
|
||||
@@ -257,7 +268,7 @@ const mimeType = (path) => {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaData}) => {
|
||||
export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaData, editable=false}) => {
|
||||
const showMediaSetting = GetMythicSetting({setting_name: "showMedia", default_value: true});
|
||||
const [openScreenshot, setOpenScreenshot] = React.useState(false);
|
||||
const [showMedia, setShowMedia] = React.useState(showMediaSetting);
|
||||
@@ -324,9 +335,9 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
|
||||
<Typography variant={"h4"} >
|
||||
{fileData.message}
|
||||
</Typography>
|
||||
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
|
||||
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
|
||||
{"Download here"}
|
||||
</Link>
|
||||
</FileDownloadLinkWithAuth>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -343,9 +354,9 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
|
||||
if(fileData.display_type === "image"){
|
||||
return (
|
||||
<>
|
||||
<img width={"100%"} onClick={clickedScreenshot}
|
||||
<ImageWithAuth width={"100%"} onClick={clickedScreenshot}
|
||||
src={"/direct/view/" + agent_file_id} style={{cursor: "zoom-in", width: "100%"}}>
|
||||
</img>
|
||||
</ImageWithAuth>
|
||||
{openScreenshot &&
|
||||
<MythicDialog fullWidth={true} maxWidth="xl" open={openScreenshot}
|
||||
onClose={() => {
|
||||
@@ -365,7 +376,7 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
|
||||
return (
|
||||
<div style={{height: "100%", minHeight: "100px", width: "100%"}}>
|
||||
<DisplayText agent_file_id={agent_file_id} expand={expand} filename={filename}
|
||||
fileMetaData={fileMetaData}/>
|
||||
fileMetaData={fileMetaData} editable={editable}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -374,6 +385,7 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
|
||||
const MaxRenderSize = 2000000; // 2MB
|
||||
const DisplayFileMetaData = ({fileMetaData}) => {
|
||||
return (
|
||||
<TableContainer className="mythicElement mythic-response-media-metadata">
|
||||
<Table style={{marginLeft: "0px", width: "100%", tableLayout: "fixed"}}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -421,168 +433,193 @@ const DisplayFileMetaData = ({fileMetaData}) => {
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
const DisplayText = ({agent_file_id, expand, filename, preview, fileMetaData}) => {
|
||||
const theme = useTheme();
|
||||
const [mode, setMode] = React.useState("html");
|
||||
const getTextSyntaxForFilename = (filename) => {
|
||||
if(!filename){
|
||||
return "html";
|
||||
}
|
||||
let extension = filename.split(".");
|
||||
if(extension.length > 1){
|
||||
extension = extension[extension.length - 1].toLowerCase();
|
||||
return textExtensionTypesToSyntax[extension] || "html";
|
||||
}
|
||||
return textExtensionTypesToSyntax[filename.toLowerCase()] || "html";
|
||||
}
|
||||
|
||||
const DisplayText = ({agent_file_id, expand, filename, editable=false, fileMetaData}) => {
|
||||
const [content, setContent] = React.useState("");
|
||||
const [wrapText, setWrapText] = React.useState(true);
|
||||
const [previewFileString] = useMutation(previewFileQuery, {
|
||||
onCompleted: (data) => {
|
||||
if(data.previewFile.status === "success"){
|
||||
setContent(b64DecodeUnicode(data.previewFile.contents));
|
||||
if(data.previewFile.size > 512000){
|
||||
setLimitedPreviewWarning(true);
|
||||
}
|
||||
}else{
|
||||
console.log(data.previewFile.error);
|
||||
if(data.previewFile.error !== "EOF"){
|
||||
snackActions.error(data.previewFile.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (data) => {
|
||||
console.log(data);
|
||||
snackActions.error(data);
|
||||
}
|
||||
});
|
||||
const [editedContent, setEditedContent] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadError, setLoadError] = React.useState("");
|
||||
const [limitedPreviewWarning, setLimitedPreviewWarning] = React.useState(false);
|
||||
const currentContentRef = React.useRef();
|
||||
const [previewLoaded, setPreviewLoaded] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [previewFileString] = useMutation(previewFileQuery);
|
||||
const initialMode = React.useMemo(() => getTextSyntaxForFilename(filename), [filename]);
|
||||
React.useEffect( () => {
|
||||
if(preview){
|
||||
// get first 512KB
|
||||
previewFileString({variables: {file_id: agent_file_id}})
|
||||
}else{
|
||||
// get entire file
|
||||
fetch('/direct/view/' + agent_file_id).then((response) => {
|
||||
if(response.status !== 200){
|
||||
snackActions.warning("Failed to fetch contents from Mythic");
|
||||
let cancelled = false;
|
||||
const loadText = async () => {
|
||||
setLoading(true);
|
||||
setLoadError("");
|
||||
setPreviewLoaded(false);
|
||||
setLimitedPreviewWarning(false);
|
||||
setContent("");
|
||||
setEditedContent("");
|
||||
try{
|
||||
const {data} = await previewFileString({variables: {file_id: agent_file_id}});
|
||||
const previewData = data?.previewFile;
|
||||
if(cancelled){
|
||||
return;
|
||||
}
|
||||
response.text().then(data => {
|
||||
if(data.length > MaxRenderSize){
|
||||
snackActions.warning("File too large (> 2MB), truncating the render");
|
||||
setLimitedPreviewWarning(true);
|
||||
setContent(data.substring(0, MaxRenderSize));
|
||||
let previewText = "";
|
||||
if(previewData?.status !== "success"){
|
||||
const errorMessage = previewData?.error || "Failed to preview file";
|
||||
if(errorMessage === "EOF"){
|
||||
setPreviewLoaded(true);
|
||||
}else{
|
||||
setLoadError(errorMessage);
|
||||
snackActions.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
try{
|
||||
let cont = JSON.stringify(JSON.parse(data), null, 2);
|
||||
setContent(cont);
|
||||
}catch(error){
|
||||
setContent(data.substring(0, MaxRenderSize));
|
||||
}else{
|
||||
setPreviewLoaded(true);
|
||||
previewText = b64DecodeUnicode(previewData.contents || "");
|
||||
}
|
||||
if((previewData.size || 0) > MaxRenderSize){
|
||||
setLimitedPreviewWarning(true);
|
||||
setContent(previewText);
|
||||
setEditedContent(previewText);
|
||||
return;
|
||||
}
|
||||
const response = await mythicFetch('/direct/view/' + agent_file_id, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
||||
}
|
||||
}).catch(error => {
|
||||
snackActions.warning("Error getting contents from server: " + error.toString());
|
||||
console.log("Error trying to get json response", error, response);
|
||||
});
|
||||
}).catch(error => {
|
||||
if(error.toString() === "TypeError: Failed to fetch"){
|
||||
snackActions.warning("Please refresh and accept the SSL connection error");
|
||||
} else {
|
||||
snackActions.warning("Error talking to server: " + error.toString());
|
||||
if(cancelled){
|
||||
return;
|
||||
}
|
||||
if(response.status !== 200){
|
||||
setLoadError("Failed to fetch contents from Mythic");
|
||||
snackActions.warning("Failed to fetch contents from Mythic");
|
||||
setContent(previewText);
|
||||
setEditedContent(previewText);
|
||||
return;
|
||||
}
|
||||
const fullText = await response.text();
|
||||
if(cancelled){
|
||||
return;
|
||||
}
|
||||
if(fullText.length > MaxRenderSize){
|
||||
snackActions.warning("File too large (> 2MB), truncating the render");
|
||||
setLimitedPreviewWarning(true);
|
||||
const truncatedText = fullText.substring(0, MaxRenderSize);
|
||||
setContent(truncatedText);
|
||||
setEditedContent(truncatedText);
|
||||
return;
|
||||
}
|
||||
setContent(fullText);
|
||||
setEditedContent(fullText);
|
||||
}catch(error){
|
||||
if(cancelled){
|
||||
return;
|
||||
}
|
||||
const errorMessage = error.toString() === "TypeError: Failed to fetch" ?
|
||||
"Unable to reach Mythic. Check the server, network, or certificate approval." :
|
||||
"Error talking to server: " + error.toString();
|
||||
setLoadError(errorMessage);
|
||||
snackActions.warning(errorMessage);
|
||||
console.log("There was an error!", error);
|
||||
});
|
||||
}
|
||||
if(filename){
|
||||
let extension = filename.split(".");
|
||||
if(extension.length > 1){
|
||||
extension = extension[extension.length - 1];
|
||||
if(textExtensionTypesToSyntax[extension]){
|
||||
setMode(textExtensionTypesToSyntax[extension]);
|
||||
}
|
||||
} else {
|
||||
if(textExtensionTypesToSyntax[filename.toLowerCase()]){
|
||||
setMode(textExtensionTypesToSyntax[filename.toLowerCase()]);
|
||||
}finally{
|
||||
if(!cancelled){
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [agent_file_id, filename, fileMetaData]);
|
||||
const onChangeMode = (event) => {
|
||||
setMode(event.target.value);
|
||||
}
|
||||
const toggleWrapText = () => {
|
||||
setWrapText(!wrapText);
|
||||
}
|
||||
const formatJSON = () => {
|
||||
if(agent_file_id){
|
||||
loadText();
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
}
|
||||
}, [agent_file_id, previewFileString]);
|
||||
const canSave = editable &&
|
||||
previewLoaded &&
|
||||
!loading &&
|
||||
!loadError &&
|
||||
!limitedPreviewWarning &&
|
||||
fileMetaData?.complete !== false &&
|
||||
!fileMetaData?.deleted;
|
||||
const saveDisabledReason = !editable ? "Editing is not enabled for this file" :
|
||||
!previewLoaded ? "Text preview has not loaded successfully" :
|
||||
limitedPreviewWarning ? "Only a truncated preview is loaded" :
|
||||
fileMetaData?.complete === false ? "File is not completely uploaded" :
|
||||
fileMetaData?.deleted ? "Deleted files cannot be edited" :
|
||||
loadError ? loadError :
|
||||
saving ? "Saving file" :
|
||||
"";
|
||||
const onSave = async () => {
|
||||
if(!canSave || saving){
|
||||
if(saveDisabledReason){
|
||||
snackActions.warning(saveDisabledReason);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try{
|
||||
let tmp = JSON.stringify(JSON.parse(currentContentRef.current?.editor?.getValue()), null, 2);
|
||||
setContent(tmp);
|
||||
setMode("json");
|
||||
}catch(error){
|
||||
snackActions.warning("Failed to reformat as JSON")
|
||||
const newFile = new File([editedContent], filename || agent_file_id, {type: "text/plain;charset=utf-8"});
|
||||
const uploadResult = await UploadDirectFile(agent_file_id, newFile);
|
||||
if(uploadResult?.status === "success"){
|
||||
snackActions.success("Successfully saved file");
|
||||
}else if(uploadResult?.error){
|
||||
snackActions.error(uploadResult.error);
|
||||
}else{
|
||||
snackActions.error("Failed to save file");
|
||||
}
|
||||
}finally{
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
const toolbarActions = editable ? [
|
||||
<MythicStyledTooltip title={saveDisabledReason || "Save file"}>
|
||||
<span>
|
||||
<button
|
||||
aria-label="Save file"
|
||||
className="mythic-response-render-action-button mythic-action-tone mythic-tone-success"
|
||||
disabled={!canSave || saving}
|
||||
onClick={onSave}
|
||||
type="button">
|
||||
<SaveIcon fontSize="small" />
|
||||
</button>
|
||||
</span>
|
||||
</MythicStyledTooltip>
|
||||
] : [];
|
||||
const toolbarNotice = limitedPreviewWarning ? (
|
||||
<MythicStyledTooltip title={"Only a truncated preview of this file is shown"}>
|
||||
<WarningOutlinedIcon style={{marginLeft: "5px"}} color={"warning"} />
|
||||
</MythicStyledTooltip>
|
||||
) : null;
|
||||
return (
|
||||
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
|
||||
<div style={{display: "inline-flex", flexDirection: "row", alignItems: "center"}}>
|
||||
<FormControl sx={{ display: "inline-block", marginLeft: "10px" }} size="small" color={"secondary"}>
|
||||
<TextField
|
||||
select
|
||||
label={"Syntax"}
|
||||
margin={"dense"}
|
||||
size={"small"}
|
||||
style={{display: "inline-block", width: "100%",}}
|
||||
value={mode}
|
||||
sx={{padding: 0}}
|
||||
onChange={onChangeMode}
|
||||
>
|
||||
{
|
||||
modeOptions.map((opt, i) => (
|
||||
<MenuItem key={"searchopt" + opt} value={opt}>{opt}</MenuItem>
|
||||
))
|
||||
}
|
||||
</TextField>
|
||||
</FormControl>
|
||||
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
|
||||
<IconButton onClick={toggleWrapText} style={{}}>
|
||||
<WrapTextIcon color={wrapText ? "success" : "secondary"}
|
||||
style={{cursor: "pointer"}}
|
||||
/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={"Auto format JSON"} >
|
||||
<IconButton onClick={formatJSON} style={{}}>
|
||||
<CodeIcon color={"info"} style={{cursor: "pointer"}} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
{limitedPreviewWarning &&
|
||||
<>
|
||||
<MythicStyledTooltip title={preview ? "Only first 512KB of file is shown" :
|
||||
"Only first 2MB of file is shown"}>
|
||||
<WarningOutlinedIcon style={{marginLeft: "5px"}} color={"warning"} />
|
||||
</MythicStyledTooltip>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
<div style={{display: "flex", flexGrow: 1, height: "100%"}}>
|
||||
<AceEditor
|
||||
ref={currentContentRef}
|
||||
mode={mode}
|
||||
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
|
||||
fontSize={14}
|
||||
showGutter={true}
|
||||
//onLoad={onLoad}
|
||||
highlightActiveLine={false}
|
||||
showPrintMargin={false}
|
||||
value={content}
|
||||
height={expand ? "100%": undefined}
|
||||
maxLines={expand ? undefined : 20}
|
||||
width={"100%"}
|
||||
//autoScrollEditorIntoView={true}
|
||||
wrapEnabled={wrapText}
|
||||
minLines={2}
|
||||
//maxLines={props.expand ? 50 : 20}
|
||||
setOptions={{
|
||||
showLineNumbers: true,
|
||||
tabSize: 4,
|
||||
useWorker: false
|
||||
}}/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<ResponseDisplayPlaintext
|
||||
plaintext={loading ? "Loading..." : content}
|
||||
expand={expand}
|
||||
initial_mode={initialMode}
|
||||
autoFormat={false}
|
||||
readOnly={!editable}
|
||||
toolbarTitle={"Text"}
|
||||
initial_show_options={true}
|
||||
toolbarActions={toolbarActions}
|
||||
toolbarNotice={toolbarNotice}
|
||||
enableCredentialCreation={!loading && previewLoaded}
|
||||
credentialMetadata={{
|
||||
agent_file_id,
|
||||
filemeta_id: fileMetaData?.id,
|
||||
filename: filename || fileMetaData?.filename,
|
||||
}}
|
||||
onChangeContent={setEditedContent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const DisplayHex = ({agent_file_id, expand, fileMetaData}) => {
|
||||
@@ -762,7 +799,11 @@ const DisplayDatabase = ({agent_file_id, expand, fileMetaData}) => {
|
||||
React.useEffect( () => {
|
||||
async function initialize(){
|
||||
const newSQL = await initSQLJS({locateFile: () => sqlWasm});
|
||||
fetch('/direct/view/' + agent_file_id).then((response) => {
|
||||
mythicFetch('/direct/view/' + agent_file_id, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`
|
||||
}
|
||||
}).then((response) => {
|
||||
if(response.status !== 200){
|
||||
setLoading(false);
|
||||
snackActions.warning("Failed to fetch contents from Mythic");
|
||||
@@ -781,7 +822,7 @@ const DisplayDatabase = ({agent_file_id, expand, fileMetaData}) => {
|
||||
});
|
||||
}).catch(error => {
|
||||
if(error.toString() === "TypeError: Failed to fetch"){
|
||||
snackActions.warning("Please refresh and accept the SSL connection error");
|
||||
snackActions.warning("Unable to reach Mythic. Check the server, network, or certificate approval.");
|
||||
} else {
|
||||
snackActions.warning("Error talking to server: " + error.toString());
|
||||
}
|
||||
@@ -885,6 +926,7 @@ const DisplayDatabaseResult = ({result}) => {
|
||||
const [columns, setColumns] = React.useState(result.columns);
|
||||
const [rows, setRows] = React.useState(result.values?.slice(0, MAX_ROWS));
|
||||
return (
|
||||
<TableContainer className="mythicElement" style={{height: "100%", position: "relative"}}>
|
||||
<Table stickyHeader style={{width: "100%", height: "100%", position: "absolute"}}>
|
||||
<TableHead >
|
||||
<TableRow>
|
||||
@@ -905,5 +947,6 @@ const DisplayDatabaseResult = ({result}) => {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,22 +3,202 @@ import AceEditor from 'react-ace';
|
||||
import {useTheme} from '@mui/material/styles';
|
||||
import {snackActions} from "../../utilities/Snackbar";
|
||||
import {modeOptions} from "./ResponseDisplayMedia";
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import WrapTextIcon from '@mui/icons-material/WrapText';
|
||||
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { IconButton, Select } from '@mui/material';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
|
||||
import { useReactiveVar } from '@apollo/client';
|
||||
import { gql, useMutation, useReactiveVar } from '@apollo/client';
|
||||
import { meState } from '../../../cache';
|
||||
import CodeIcon from '@mui/icons-material/Code';
|
||||
import {GetOutputFormatAll} from "./ResponseDisplayInteractive";
|
||||
import PaletteIcon from '@mui/icons-material/Palette';
|
||||
import Input from '@mui/material/Input';
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import NotesIcon from '@mui/icons-material/Notes';
|
||||
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import {Terminal} from '@xterm/xterm';
|
||||
import {FitAddon} from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import {
|
||||
getInteractiveTerminalTheme as getTerminalTheme,
|
||||
getLongestTerminalTextLineLength as getLongestTerminalLineLength,
|
||||
sanitizeTerminalOutput
|
||||
} from "./ResponseDisplayInteractive";
|
||||
import {markdownPlugins} from "../../utilities/Markdown";
|
||||
import {markdownComponents} from "../../utilities/MarkdownComponents";
|
||||
import {MythicDialog} from "../../MythicComponents/MythicDialog";
|
||||
import {CredentialTableNewCredentialDialog} from "../Search/CredentialTableNewCredentialDialog";
|
||||
|
||||
const MaxRenderSize = 2000000;
|
||||
const RenderModes = {
|
||||
plaintext: "plaintext",
|
||||
markdown: "markdown",
|
||||
terminal: "terminal",
|
||||
};
|
||||
const renderModeOptions = [
|
||||
{value: RenderModes.plaintext, label: "Plaintext", Icon: ArticleIcon},
|
||||
{value: RenderModes.markdown, label: "Markdown", Icon: NotesIcon},
|
||||
{value: RenderModes.terminal, label: "Terminal", Icon: TerminalIcon},
|
||||
];
|
||||
const createCredentialMutation = gql`
|
||||
mutation createCredential($comment: String!, $account: String!, $realm: String!, $type: String!, $subtype: String, $credential: String!, $metadata: jsonb, $custom_display: String) {
|
||||
createCredential(account: $account, credential: $credential, comment: $comment, realm: $realm, credential_type: $type, credential_subtype: $subtype, metadata: $metadata, custom_display: $custom_display) {
|
||||
status
|
||||
error
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
const normalizeRenderMode = (value) => {
|
||||
return Object.values(RenderModes).includes(value) ? value : RenderModes.plaintext;
|
||||
}
|
||||
const getInitialRenderMode = (props) => {
|
||||
if(props?.render_mode !== undefined){
|
||||
return normalizeRenderMode(props.render_mode);
|
||||
}
|
||||
if(props?.initial_render_mode !== undefined){
|
||||
return normalizeRenderMode(props.initial_render_mode);
|
||||
}
|
||||
return props?.render_colors ? RenderModes.terminal : RenderModes.plaintext;
|
||||
}
|
||||
const ResponseMarkdownDisplay = ({value, wrapText, expand}) => {
|
||||
return (
|
||||
<div className={`mythic-response-markdown${wrapText ? " is-wrapped" : " is-unwrapped"}${expand ? " is-expanded" : " is-capped"}`}>
|
||||
<ReactMarkdown remarkPlugins={markdownPlugins} components={markdownComponents} skipHtml>
|
||||
{value}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ResponseTerminalDisplay = ({value, wrapText, expand, theme}) => {
|
||||
const terminalScrollContainerRef = React.useRef(null);
|
||||
const terminalElementRef = React.useRef(null);
|
||||
const terminalRef = React.useRef(null);
|
||||
const fitAddonRef = React.useRef(null);
|
||||
const resizeObserverRef = React.useRef(null);
|
||||
const fitAnimationFrameRef = React.useRef(null);
|
||||
const wrapTextRef = React.useRef(wrapText);
|
||||
const unwrappedColumnCountRef = React.useRef(0);
|
||||
const [terminalReady, setTerminalReady] = React.useState(false);
|
||||
const longestLineLength = React.useMemo( () => getLongestTerminalLineLength(value), [value]);
|
||||
const fitTerminal = React.useCallback( () => {
|
||||
const terminal = terminalRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
const terminalElement = terminalElementRef.current;
|
||||
const scrollContainer = terminalScrollContainerRef.current;
|
||||
if(!terminal || !fitAddon || !terminalElement || !scrollContainer){
|
||||
return;
|
||||
}
|
||||
try{
|
||||
terminalElement.style.width = "100%";
|
||||
const proposedDimensions = fitAddon.proposeDimensions();
|
||||
if(!proposedDimensions){
|
||||
return;
|
||||
}
|
||||
const rows = Math.max(1, proposedDimensions.rows);
|
||||
if(wrapTextRef.current){
|
||||
fitAddon.fit();
|
||||
scrollContainer.scrollLeft = 0;
|
||||
} else {
|
||||
const visibleCols = Math.max(1, proposedDimensions.cols);
|
||||
const cols = Math.max(visibleCols, unwrappedColumnCountRef.current);
|
||||
terminalElement.style.width = `${Math.ceil((cols / visibleCols) * scrollContainer.clientWidth)}px`;
|
||||
terminal.resize(cols, rows);
|
||||
}
|
||||
}catch(error){
|
||||
console.error(error);
|
||||
}
|
||||
}, []);
|
||||
const scheduleFitTerminal = React.useCallback( () => {
|
||||
if(fitAnimationFrameRef.current !== null){
|
||||
return;
|
||||
}
|
||||
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
fitAnimationFrameRef.current = null;
|
||||
fitTerminal();
|
||||
});
|
||||
}, [fitTerminal]);
|
||||
React.useEffect( () => {
|
||||
wrapTextRef.current = wrapText;
|
||||
if(wrapText){
|
||||
unwrappedColumnCountRef.current = 0;
|
||||
} else {
|
||||
unwrappedColumnCountRef.current = Math.min(Math.max(longestLineLength + 2, 1), 1000);
|
||||
}
|
||||
scheduleFitTerminal();
|
||||
}, [longestLineLength, scheduleFitTerminal, wrapText]);
|
||||
React.useEffect( () => {
|
||||
if(!terminalElementRef.current){
|
||||
return;
|
||||
}
|
||||
const terminal = new Terminal({
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
scrollback: 5000,
|
||||
theme: getTerminalTheme(theme),
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.open(terminalElementRef.current);
|
||||
terminalRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
resizeObserverRef.current = new ResizeObserver(() => scheduleFitTerminal());
|
||||
if(terminalScrollContainerRef.current){
|
||||
resizeObserverRef.current.observe(terminalScrollContainerRef.current);
|
||||
}
|
||||
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
|
||||
fitAnimationFrameRef.current = null;
|
||||
fitTerminal();
|
||||
setTerminalReady(true);
|
||||
});
|
||||
return () => {
|
||||
if(fitAnimationFrameRef.current !== null){
|
||||
window.cancelAnimationFrame(fitAnimationFrameRef.current);
|
||||
fitAnimationFrameRef.current = null;
|
||||
}
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
terminalRef.current = null;
|
||||
terminal.dispose();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
React.useEffect( () => {
|
||||
if(terminalRef.current){
|
||||
terminalRef.current.options.theme = getTerminalTheme(theme);
|
||||
}
|
||||
}, [theme]);
|
||||
React.useEffect( () => {
|
||||
if(!terminalReady || !terminalRef.current){
|
||||
return;
|
||||
}
|
||||
const terminal = terminalRef.current;
|
||||
scheduleFitTerminal();
|
||||
terminal.reset();
|
||||
terminal.write(sanitizeTerminalOutput(value), () => terminal.scrollToBottom());
|
||||
}, [scheduleFitTerminal, terminalReady, value]);
|
||||
return (
|
||||
<div className="mythic-response-terminal-shell"
|
||||
style={{height: expand ? "100%" : "360px", minHeight: expand ? 0 : "140px"}}>
|
||||
<div
|
||||
ref={terminalScrollContainerRef}
|
||||
className={"MythicInteractiveTerminal mythic-response-terminal"}
|
||||
style={{
|
||||
height: "100%",
|
||||
overflowX: wrapText ? "hidden" : "auto",
|
||||
overflowY: "hidden",
|
||||
width: "100%",
|
||||
}}>
|
||||
<div ref={terminalElementRef} style={{height: "100%", width: "100%"}} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResponseDisplayPlaintext = (props) =>{
|
||||
const theme = useTheme();
|
||||
const me = useReactiveVar(meState);
|
||||
@@ -26,51 +206,107 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
const [plaintextView, setPlaintextView] = React.useState("");
|
||||
const initialMode = props?.initial_mode || "html";
|
||||
const [mode, setMode] = React.useState(initialMode);
|
||||
const [renderMode, setRenderMode] = React.useState(getInitialRenderMode(props));
|
||||
const [wrapText, setWrapText] = React.useState(props?.wrap_text === undefined ? true : props.wrap_text);
|
||||
const [showOptions, setShowOptions] = React.useState(false);
|
||||
const [renderColors, setRenderColors] = React.useState(props?.render_colors === undefined ? false : props.render_colors);
|
||||
const [showOptions, setShowOptions] = React.useState(Boolean(props?.initial_show_options));
|
||||
const [createCredentialDialogOpen, setCreateCredentialDialogOpen] = React.useState(false);
|
||||
const [initialCredentialValues, setInitialCredentialValues] = React.useState({});
|
||||
const largeResponseWarningShown = React.useRef(false);
|
||||
const [createCredential] = useMutation(createCredentialMutation, {
|
||||
fetchPolicy: "no-cache",
|
||||
onCompleted: (data) => {
|
||||
if(data.createCredential.status === "success"){
|
||||
snackActions.success("Successfully created new credential");
|
||||
} else {
|
||||
snackActions.error(data.createCredential.error);
|
||||
}
|
||||
},
|
||||
onError: (data) => {
|
||||
snackActions.error("Failed to create credential");
|
||||
console.log(data);
|
||||
}
|
||||
});
|
||||
const onChangeText = (data) => {
|
||||
if(props.onChangeContent){
|
||||
props?.onChangeContent(data);
|
||||
}
|
||||
setPlaintextView(data);
|
||||
}
|
||||
React.useEffect( () => {
|
||||
largeResponseWarningShown.current = false;
|
||||
}, [props?.task?.id]);
|
||||
useEffect( () => {
|
||||
if(props.plaintext.length > MaxRenderSize){
|
||||
snackActions.warning("Response too large (> 2MB), truncating the render. Download task output to view entire response.");
|
||||
setPlaintextView(props.plaintext.substring(0, MaxRenderSize));
|
||||
const plaintext = props.plaintext === undefined || props.plaintext === null ? "" : String(props.plaintext);
|
||||
if(plaintext.length > MaxRenderSize){
|
||||
if(!largeResponseWarningShown.current){
|
||||
snackActions.warning("Response too large (> 2MB), truncating the render. Download task output to view entire response.");
|
||||
largeResponseWarningShown.current = true;
|
||||
}
|
||||
setPlaintextView(plaintext.substring(0, MaxRenderSize));
|
||||
} else {
|
||||
largeResponseWarningShown.current = false;
|
||||
try{
|
||||
if(props.autoFormat === undefined || props.autoFormat){
|
||||
const newPlaintext = JSON.stringify(JSON.parse(String(props.plaintext)), null, 4);
|
||||
const newPlaintext = JSON.stringify(JSON.parse(plaintext), null, 4);
|
||||
setPlaintextView(newPlaintext);
|
||||
setMode("json");
|
||||
} else {
|
||||
setPlaintextView(String(props.plaintext));
|
||||
setPlaintextView(plaintext);
|
||||
}
|
||||
}catch(error){
|
||||
setPlaintextView(String(props.plaintext));
|
||||
setPlaintextView(plaintext);
|
||||
}
|
||||
}
|
||||
}, [props.plaintext]);
|
||||
}, [props.plaintext, props.autoFormat]);
|
||||
const onChangeMode = (event) => {
|
||||
setMode(event.target.value);
|
||||
}
|
||||
const onChangeRenderMode = (newMode) => {
|
||||
setRenderMode(newMode);
|
||||
}
|
||||
const toggleWrapText = () => {
|
||||
setWrapText(!wrapText);
|
||||
}
|
||||
const formatJSON = () => {
|
||||
try{
|
||||
let tmp = JSON.stringify(JSON.parse(currentContentRef.current?.editor?.getValue()), null, 2);
|
||||
const currentValue = currentContentRef.current?.editor?.getValue?.() || plaintextView;
|
||||
let tmp = JSON.stringify(JSON.parse(currentValue), null, 2);
|
||||
setPlaintextView(tmp);
|
||||
props.onChangeContent?.(tmp);
|
||||
setMode("json");
|
||||
}catch(error){
|
||||
snackActions.warning("Failed to reformat as JSON")
|
||||
}
|
||||
}
|
||||
const onChangeShowOptions = (e) => {
|
||||
const onChangeShowOptions = () => {
|
||||
setShowOptions(!showOptions);
|
||||
}
|
||||
const sourceTaskMetadata = () => {
|
||||
const taskMetadata = props?.task?.id ? {task_id: props.task.id} : {};
|
||||
const credentialMetadata = typeof props?.credentialMetadata === "function" ?
|
||||
props.credentialMetadata() :
|
||||
(props?.credentialMetadata || {});
|
||||
return {
|
||||
...taskMetadata,
|
||||
...credentialMetadata,
|
||||
};
|
||||
};
|
||||
const openCreateCredentialDialog = () => {
|
||||
const selectedText = currentContentRef.current?.editor?.getSelectedText?.() || "";
|
||||
const credentialText = selectedText.trim() === "" ? plaintextView : selectedText;
|
||||
if(credentialText.trim() === ""){
|
||||
snackActions.warning("No credential text selected");
|
||||
return;
|
||||
}
|
||||
setInitialCredentialValues({
|
||||
credential: credentialText,
|
||||
metadata: sourceTaskMetadata(),
|
||||
});
|
||||
setCreateCredentialDialogOpen(true);
|
||||
}
|
||||
const onCreateCredential = ({type, subtype, account, realm, comment, credential, metadata, custom_display}) => {
|
||||
createCredential({variables: {type, subtype, account, realm, comment, credential, metadata, custom_display}})
|
||||
}
|
||||
const scrollContent = (node, isAppearing) => {
|
||||
// only auto-scroll if you issued the task
|
||||
if(props?.task?.operator?.username === (me?.user?.username || "")){
|
||||
@@ -90,75 +326,113 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
}
|
||||
React.useLayoutEffect( () => {
|
||||
scrollContent()
|
||||
}, []);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
React.useEffect( () => {
|
||||
setMode(props?.initial_mode || "html");
|
||||
}, [props?.initial_mode]);
|
||||
React.useEffect( () => {
|
||||
setRenderMode(getInitialRenderMode({
|
||||
initial_render_mode: props?.initial_render_mode,
|
||||
render_colors: props?.render_colors,
|
||||
render_mode: props?.render_mode,
|
||||
}));
|
||||
}, [props?.initial_render_mode, props?.render_mode, props?.render_colors]);
|
||||
const currentRenderModeLabel = renderModeOptions.find((option) => option.value === renderMode)?.label || "Plaintext";
|
||||
const displayWrapText = renderMode === RenderModes.plaintext ? wrapText : true;
|
||||
const toolbarActions = props?.toolbarActions || [];
|
||||
const showCreateCredentialAction = props?.enableCredentialCreation !== false &&
|
||||
(props.task || props.credentialMetadata || props.showCredentialAction);
|
||||
return (
|
||||
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
|
||||
{showOptions &&
|
||||
<div style={{display: "inline-flex", flexDirection: "row", alignItems: "center",
|
||||
color: theme.outputTextColor,
|
||||
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "20"),}}>
|
||||
<FormControl sx={{ display: "inline-block", marginLeft: "10px", color: theme.outputTextColor, }} size="small">
|
||||
<TextField
|
||||
label={"Syntax"}
|
||||
select
|
||||
margin={"dense"}
|
||||
size={"small"}
|
||||
sx={{
|
||||
// Target the icon element directly within the Select
|
||||
'.MuiSelect-icon': {
|
||||
color: theme.outputTextColor, // Set your desired color
|
||||
},
|
||||
'.MuiInputBase-root': {
|
||||
color: theme.outputTextColor,
|
||||
}
|
||||
}}
|
||||
style={{display: "inline-block", width: "100%",}}
|
||||
value={mode}
|
||||
onChange={onChangeMode}
|
||||
>
|
||||
{
|
||||
modeOptions.map((opt, i) => (
|
||||
<MenuItem key={"searchopt" + opt} value={opt}>{opt}</MenuItem>
|
||||
))
|
||||
}
|
||||
</TextField>
|
||||
</FormControl>
|
||||
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
|
||||
<IconButton onClick={toggleWrapText} style={{}}>
|
||||
<WrapTextIcon color={wrapText ? "success" : "secondary"}
|
||||
style={{cursor: "pointer"}}
|
||||
/>
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={"Auto format JSON"} >
|
||||
<IconButton onClick={formatJSON} style={{}}>
|
||||
<CodeIcon color={"info"} style={{cursor: "pointer"}} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={renderColors ? "Render Plaintext" : "Render Colors"} >
|
||||
<IconButton onClick={() => {setRenderColors(!renderColors)}}>
|
||||
<PaletteIcon color={renderColors ? "success" : "secondary"} style={{cursor: "pointer"}} />
|
||||
</IconButton>
|
||||
</MythicStyledTooltip>
|
||||
</div>
|
||||
<div style={{display: "flex", height: "100%", minHeight: 0, minWidth: 0, flexDirection: "column", flex: "1 1 auto"}}>
|
||||
{createCredentialDialogOpen &&
|
||||
<MythicDialog fullWidth={true} maxWidth="md" open={createCredentialDialogOpen}
|
||||
onClose={()=>{setCreateCredentialDialogOpen(false);}}
|
||||
innerDialog={<CredentialTableNewCredentialDialog initialValues={initialCredentialValues} onSubmit={onCreateCredential} onClose={()=>{setCreateCredentialDialogOpen(false);}} />}
|
||||
/>
|
||||
}
|
||||
{props.displayType !== 'console' &&
|
||||
<div style={{
|
||||
height: "1px",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
zIndex: 1,
|
||||
}}>
|
||||
<div className={`mythic-response-render-toolbar${showOptions ? " is-open" : ""}`}>
|
||||
<button className="mythic-response-render-toolbar-toggle"
|
||||
type="button"
|
||||
onClick={onChangeShowOptions}
|
||||
style={{color: theme.outputTextColor}}>
|
||||
{showOptions ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
|
||||
<span className="mythic-response-render-toolbar-title">{props?.toolbarTitle || "Output"}</span>
|
||||
<span className="mythic-response-render-toolbar-mode">{currentRenderModeLabel}</span>
|
||||
</button>
|
||||
{showOptions &&
|
||||
<UnfoldLessIcon onClick={onChangeShowOptions}
|
||||
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
|
||||
}
|
||||
{!showOptions &&
|
||||
<UnfoldMoreIcon onClick={onChangeShowOptions}
|
||||
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
|
||||
<div className="mythic-response-render-toolbar-controls">
|
||||
<div className="mythic-response-render-mode-group" role="group" aria-label="Response render mode">
|
||||
{renderModeOptions.map(({value, label, Icon}) => (
|
||||
<button
|
||||
aria-pressed={renderMode === value}
|
||||
className={`mythic-response-render-mode-button${renderMode === value ? " mythic-action-tone mythic-tone-info" : ""}`}
|
||||
key={value}
|
||||
onClick={() => onChangeRenderMode(value)}
|
||||
type="button">
|
||||
<Icon fontSize="small" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{toolbarActions.length > 0 &&
|
||||
<div className="mythic-response-render-action-group">
|
||||
{toolbarActions.map((action, index) => (
|
||||
<React.Fragment key={action?.key || index}>{action}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{renderMode === RenderModes.plaintext &&
|
||||
<>
|
||||
<div className="mythic-response-render-action-group">
|
||||
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
|
||||
<button
|
||||
aria-label={wrapText ? "Unwrap Text" : "Wrap Text"}
|
||||
aria-pressed={wrapText}
|
||||
className={`mythic-response-render-action-button${wrapText ? " mythic-action-tone mythic-tone-info" : ""}`}
|
||||
onClick={toggleWrapText}
|
||||
type="button">
|
||||
<WrapTextIcon fontSize="small" />
|
||||
</button>
|
||||
</MythicStyledTooltip>
|
||||
<MythicStyledTooltip title={"Auto format JSON"} >
|
||||
<button
|
||||
aria-label="Auto format JSON"
|
||||
className="mythic-response-render-action-button"
|
||||
onClick={formatJSON}
|
||||
type="button">
|
||||
<CodeIcon fontSize="small" />
|
||||
</button>
|
||||
</MythicStyledTooltip>
|
||||
{showCreateCredentialAction &&
|
||||
<MythicStyledTooltip title={"Create Credential"} >
|
||||
<button
|
||||
aria-label="Create Credential"
|
||||
className="mythic-response-render-action-button"
|
||||
onClick={openCreateCredentialDialog}
|
||||
type="button">
|
||||
<VpnKeyIcon fontSize="small" />
|
||||
</button>
|
||||
</MythicStyledTooltip>
|
||||
}
|
||||
</div>
|
||||
<label className="mythic-response-syntax-group">
|
||||
<span>Syntax</span>
|
||||
<select
|
||||
className="mythic-response-syntax-select"
|
||||
value={mode}
|
||||
onChange={onChangeMode}>
|
||||
{
|
||||
modeOptions.map((opt) => (
|
||||
<option key={"searchopt" + opt} value={opt}>{opt}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
{props.toolbarNotice}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -166,21 +440,15 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
<div style={{
|
||||
flexGrow: 1,
|
||||
height: "100%",
|
||||
paddingLeft: renderColors ? "15px" : "0px",
|
||||
paddingRight: renderColors ? "5px" : "0px"
|
||||
minHeight: 0,
|
||||
}}>
|
||||
{renderColors &&
|
||||
<GetOutputFormatAll data={[
|
||||
{response: plaintextView, id: props?.task?.id || 0, timestamp: "1970-01-01"}]}
|
||||
myTask={false}
|
||||
taskID={props?.task?.id || 0}
|
||||
useASNIColor={true}
|
||||
messagesEndRef={null}
|
||||
showTaskStatus={false}
|
||||
search={undefined}
|
||||
wrapText={wrapText}/>
|
||||
{renderMode === RenderModes.markdown &&
|
||||
<ResponseMarkdownDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} />
|
||||
}
|
||||
{!renderColors &&
|
||||
{renderMode === RenderModes.terminal &&
|
||||
<ResponseTerminalDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} theme={theme} />
|
||||
}
|
||||
{renderMode === RenderModes.plaintext &&
|
||||
<AceEditor
|
||||
className={"roundedBottomCorners"}
|
||||
ref={currentContentRef}
|
||||
@@ -189,6 +457,7 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
fontSize={14}
|
||||
showGutter={ props.displayType !== 'console'}
|
||||
onChange={onChangeText}
|
||||
readOnly={Boolean(props?.readOnly)}
|
||||
//onLoad={onLoad}
|
||||
highlightActiveLine={false}
|
||||
showPrintMargin={false}
|
||||
@@ -198,7 +467,7 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
width={"100%"}
|
||||
//style={{backgroundColor: "transparent"}}
|
||||
//autoScrollEditorIntoView={true}
|
||||
wrapEnabled={props.displayType !== 'console' ? wrapText : true}
|
||||
wrapEnabled={props.displayType !== 'console' ? displayWrapText : true}
|
||||
minLines={1}
|
||||
setOptions={{
|
||||
showLineNumbers: props.displayType !== 'console',
|
||||
@@ -212,4 +481,4 @@ export const ResponseDisplayPlaintext = (props) =>{
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
Loaded 100 of 844 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user