mirror of
https://github.com/SecurityBrewery/catalyst.git
synced 2025-12-06 15:22:47 +01:00
Compare commits
431 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e625a9c2 | ||
|
|
e724ec8883 | ||
|
|
0e19b80dd5 | ||
|
|
a7e031151b | ||
|
|
752d06702e | ||
|
|
a37ceec393 | ||
|
|
f27e20f002 | ||
|
|
7951196c4d | ||
|
|
e00426a4ef | ||
|
|
2918d39771 | ||
|
|
e39578a841 | ||
|
|
4d4f87156b | ||
|
|
39ec9d82f4 | ||
|
|
8f6c437be1 | ||
|
|
efacd28d2b | ||
|
|
f3f9174949 | ||
|
|
4b9e9fbe40 | ||
|
|
0ffa48849d | ||
|
|
e349891102 | ||
|
|
e7dede18de | ||
|
|
f0aa1792eb | ||
|
|
67dd6a2ead | ||
|
|
d246db506a | ||
|
|
e64e024140 | ||
|
|
9ad2c4c4dd | ||
|
|
7dce84a3b5 | ||
|
|
7ca552a225 | ||
|
|
84d337394e | ||
|
|
4449e2096b | ||
|
|
a6cb83e8c7 | ||
|
|
a78c355f3f | ||
|
|
69981ab975 | ||
|
|
e0a8e3e479 | ||
|
|
e41c50c9c2 | ||
|
|
494fee194b | ||
|
|
a3a38990bc | ||
|
|
430584e5ed | ||
|
|
7b5acb19a4 | ||
|
|
a2341423d7 | ||
|
|
42ea4b1f31 | ||
|
|
17230ef232 | ||
|
|
b2bc5932d0 | ||
|
|
853577c5ba | ||
|
|
0722369c7a | ||
|
|
0620fd40bd | ||
|
|
0798c2e196 | ||
|
|
d1566d0cd0 | ||
|
|
778a27d3b9 | ||
|
|
a7a1564726 | ||
|
|
43f11dc054 | ||
|
|
89afb1aab2 | ||
|
|
f9d129eae3 | ||
|
|
e13ffb75be | ||
|
|
dd647fee1e | ||
|
|
4f7491f360 | ||
|
|
f54bb043de | ||
|
|
f5144c6771 | ||
|
|
6056e5eaba | ||
|
|
9c94208aa9 | ||
|
|
cf680816c0 | ||
|
|
9d12b41175 | ||
|
|
53d9192258 | ||
|
|
1dc7901bdf | ||
|
|
ee7cd96aee | ||
|
|
d23c7e721a | ||
|
|
7b0eb2d186 | ||
|
|
b468a4a4cb | ||
|
|
8e4fe069b4 | ||
|
|
e53f5c9d7f | ||
|
|
31a2153446 | ||
|
|
8b24418b6e | ||
|
|
3c8cd48cab | ||
|
|
4a0d50d7cc | ||
|
|
aedfd06d4b | ||
|
|
8057aa5cc8 | ||
|
|
ca1a5815c5 | ||
|
|
ef0ca9946c | ||
|
|
7caf657cc2 | ||
|
|
6276a587a3 | ||
|
|
793e68e802 | ||
|
|
f6923f9b25 | ||
|
|
3b34a5c65e | ||
|
|
a2e9d6c36f | ||
|
|
dec44b6d1d | ||
|
|
0b99764eb1 | ||
|
|
a317490f61 | ||
|
|
55caa99e2f | ||
|
|
c927e5f338 | ||
|
|
6140b124bf | ||
|
|
25b6895c9d | ||
|
|
8c2d666e5e | ||
|
|
6aaabe9cbf | ||
|
|
a5f666a142 | ||
|
|
b4a1fcdfbe | ||
|
|
657b2d4431 | ||
|
|
064abf7bb8 | ||
|
|
f7a80a841c | ||
|
|
65cbf35611 | ||
|
|
4181439e71 | ||
|
|
39a9ebe778 | ||
|
|
3edde1b7c7 | ||
|
|
fda16aca30 | ||
|
|
8d68f8fc6d | ||
|
|
826811034e | ||
|
|
0c90b855a6 | ||
|
|
365649c709 | ||
|
|
23d2aa2cf4 | ||
|
|
985975a2a1 | ||
|
|
8a10d38d72 | ||
|
|
a79267f213 | ||
|
|
b79a721fb7 | ||
|
|
ce8485f8d2 | ||
|
|
a14baa49c6 | ||
|
|
c623859f64 | ||
|
|
7bc401041c | ||
|
|
1504ee4cf6 | ||
|
|
4b263b4e7b | ||
|
|
dda21ad4de | ||
|
|
b54c8ab813 | ||
|
|
268647084d | ||
|
|
d6452271d1 | ||
|
|
4b48bc312b | ||
|
|
e3a30e173d | ||
|
|
0d4d0cc596 | ||
|
|
34cdfc6339 | ||
|
|
07ae578686 | ||
|
|
caf47c20ab | ||
|
|
37e2d4e299 | ||
|
|
d41fdac3bf | ||
|
|
4960d1a28f | ||
|
|
e0e0b4108b | ||
|
|
bc71eb8bc9 | ||
|
|
2f665be433 | ||
|
|
701740d7c6 | ||
|
|
8720ab1485 | ||
|
|
185c47c791 | ||
|
|
5a935b391c | ||
|
|
680ac096f0 | ||
|
|
34bd77b2b2 | ||
|
|
a9184a149a | ||
|
|
b4e9438420 | ||
|
|
38f7d4639b | ||
|
|
b210d90d53 | ||
|
|
c9c5e2ca1c | ||
|
|
543bbd4460 | ||
|
|
3784e8fc98 | ||
|
|
2cd5ca81c5 | ||
|
|
067d544c60 | ||
|
|
54adc98e2c | ||
|
|
beaf9c0088 | ||
|
|
a3a7708da7 | ||
|
|
6fdca18a32 | ||
|
|
2216823f51 | ||
|
|
c3b998cba4 | ||
|
|
42f0ece838 | ||
|
|
cce8320acf | ||
|
|
ec7263bc19 | ||
|
|
bb5b01577f | ||
|
|
567e90d3b6 | ||
|
|
ad104c3d01 | ||
|
|
c947365a56 | ||
|
|
f69363a35b | ||
|
|
3d058d67ae | ||
|
|
1b8a9519b4 | ||
|
|
45589c1304 | ||
|
|
2701a4b8fe | ||
|
|
10f9cf5246 | ||
|
|
57e38f3545 | ||
|
|
8b4aff0ce3 | ||
|
|
d21924133c | ||
|
|
f59e18a09a | ||
|
|
ac994bfde7 | ||
|
|
73cc97b3dc | ||
|
|
d07081f503 | ||
|
|
4843338d5e | ||
|
|
bca4667cb5 | ||
|
|
2a6a77274a | ||
|
|
f992b6d3cb | ||
|
|
3950800a3e | ||
|
|
1dfbbf9f6e | ||
|
|
d0db3c0829 | ||
|
|
cb233072ca | ||
|
|
cba34f34d5 | ||
|
|
a048ceb503 | ||
|
|
df2672f819 | ||
|
|
90440cdbf4 | ||
|
|
fad7b4138d | ||
|
|
a2618fef27 | ||
|
|
550df2a831 | ||
|
|
b7a9adaa9f | ||
|
|
9dd0e166e6 | ||
|
|
f47bc8230c | ||
|
|
22fe237fbd | ||
|
|
065f67ff6c | ||
|
|
6ea69d6903 | ||
|
|
7d21b06bab | ||
|
|
25b9d693af | ||
|
|
c96e2ebe06 | ||
|
|
41e55756cf | ||
|
|
a377a4ccb8 | ||
|
|
2944ae2100 | ||
|
|
519d9152c8 | ||
|
|
3769743904 | ||
|
|
d9f4c979de | ||
|
|
b51d70c586 | ||
|
|
6f66584807 | ||
|
|
6a81e5cdf8 | ||
|
|
7ab504659f | ||
|
|
3b379f9a36 | ||
|
|
2048312a63 | ||
|
|
358f6d8f38 | ||
|
|
32fd39bac4 | ||
|
|
1d1a40d768 | ||
|
|
7b68319912 | ||
|
|
95498051cc | ||
|
|
979bbeb468 | ||
|
|
3c2a247241 | ||
|
|
d5dcd23380 | ||
|
|
f0d5925918 | ||
|
|
cc33031751 | ||
|
|
202f015f0d | ||
|
|
cc3ce0377f | ||
|
|
fb5002d5fc | ||
|
|
3bff4444a2 | ||
|
|
1e2efe6ae4 | ||
|
|
497d3a5b45 | ||
|
|
fd624ebe79 | ||
|
|
82d65430e8 | ||
|
|
51aa1f6333 | ||
|
|
458ec5a5e0 | ||
|
|
0dc2b31d74 | ||
|
|
215bb60c5b | ||
|
|
94ea23ab7f | ||
|
|
dd97863db1 | ||
|
|
ca8028e6f3 | ||
|
|
dab3c43ed6 | ||
|
|
19ced1c158 | ||
|
|
6df41e1725 | ||
|
|
5efd237486 | ||
|
|
e3008f3e32 | ||
|
|
68ff808737 | ||
|
|
1f6bcfb2f4 | ||
|
|
f6093bbf46 | ||
|
|
52da58314a | ||
|
|
4a57413229 | ||
|
|
197507cf9b | ||
|
|
ae36ce5cfb | ||
|
|
4b8691137c | ||
|
|
93f39c8c9a | ||
|
|
7845a8406e | ||
|
|
5e8813cb60 | ||
|
|
24618b7b43 | ||
|
|
7850e0b95e | ||
|
|
994c7e9b29 | ||
|
|
8d3b9b800f | ||
|
|
9bef926de0 | ||
|
|
f4a22a1b89 | ||
|
|
37bb0f480e | ||
|
|
b26ce2099a | ||
|
|
941b10caf7 | ||
|
|
c3ba3e4f6d | ||
|
|
897733b90a | ||
|
|
c6cb853729 | ||
|
|
ae05ef9e44 | ||
|
|
6293b1e876 | ||
|
|
d35359bd9b | ||
|
|
6dcda40b39 | ||
|
|
71b6788582 | ||
|
|
4978d9fccf | ||
|
|
f7c99548db | ||
|
|
d576fbe697 | ||
|
|
a81df1f857 | ||
|
|
394e505742 | ||
|
|
7223027209 | ||
|
|
7eaa98dc5b | ||
|
|
c8b6a6b482 | ||
|
|
aee0be7a68 | ||
|
|
188ca256af | ||
|
|
3b7af3bf08 | ||
|
|
e0d28bb36b | ||
|
|
16c4bb3d37 | ||
|
|
7a78066000 | ||
|
|
bdb0a638f8 | ||
|
|
b669b0e742 | ||
|
|
ef046cbac1 | ||
|
|
0806f320b1 | ||
|
|
0ccdde0522 | ||
|
|
911d60d80f | ||
|
|
f94bc2044f | ||
|
|
1e0a3393bf | ||
|
|
570c413c3d | ||
|
|
fa8494fe0d | ||
|
|
3fb22e10ce | ||
|
|
af22af2334 | ||
|
|
2bf0d511bf | ||
|
|
bd31f7b2a7 | ||
|
|
123f334c71 | ||
|
|
c3b7fd9f73 | ||
|
|
d6104db54f | ||
|
|
527cd0d828 | ||
|
|
56828c57bd | ||
|
|
81feebcd4e | ||
|
|
d56adb814d | ||
|
|
14b79304e1 | ||
|
|
8e9250a1e9 | ||
|
|
43d2d71609 | ||
|
|
571af85383 | ||
|
|
ddeff78bb4 | ||
|
|
416d438c4b | ||
|
|
116aa53b4f | ||
|
|
876cbfde02 | ||
|
|
c866758207 | ||
|
|
ffd16770b7 | ||
|
|
27f1b0df79 | ||
|
|
ee9d906e28 | ||
|
|
341097e69e | ||
|
|
bfd1cf19aa | ||
|
|
ede96d46f3 | ||
|
|
07045eb3f4 | ||
|
|
9aa0b9dbd2 | ||
|
|
e735667619 | ||
|
|
971ff2c2cf | ||
|
|
4371883922 | ||
|
|
40d134a482 | ||
|
|
82962f12ed | ||
|
|
ab954f1b2c | ||
|
|
982102c98a | ||
|
|
a019615053 | ||
|
|
23580c5ba2 | ||
|
|
2cd98dd1e4 | ||
|
|
7817abce77 | ||
|
|
43b0703843 | ||
|
|
82c5ac8b7f | ||
|
|
c72764a507 | ||
|
|
f6c8f033eb | ||
|
|
dc8061e050 | ||
|
|
b0c634f9a1 | ||
|
|
df1454e223 | ||
|
|
8984e509e1 | ||
|
|
709d7d43fa | ||
|
|
ca1f9f01f9 | ||
|
|
bd07e86379 | ||
|
|
aefb07770c | ||
|
|
0c2d6d4da0 | ||
|
|
70a5244eec | ||
|
|
e616bb25d5 | ||
|
|
b1f082cb91 | ||
|
|
0ed03bf2b9 | ||
|
|
25c6f190e6 | ||
|
|
48b5853d88 | ||
|
|
07df2affa2 | ||
|
|
3b433e5601 | ||
|
|
8fafcb42d0 | ||
|
|
0e3eb7953e | ||
|
|
1c3f337e7c | ||
|
|
d79407f645 | ||
|
|
65aba08ffb | ||
|
|
37406a11ee | ||
|
|
6f72fd74ca | ||
|
|
d34f2cd300 | ||
|
|
356b55fccc | ||
|
|
6e209cf328 | ||
|
|
c5bcf67875 | ||
|
|
68fc41b88f | ||
|
|
2b34fdb3a6 | ||
|
|
22fa8ec90f | ||
|
|
82d2b35f61 | ||
|
|
71a5a4f3d9 | ||
|
|
01b6bdba5d | ||
|
|
6be20911b0 | ||
|
|
c28a49c486 | ||
|
|
f4c0b024a3 | ||
|
|
f3c0f69c5f | ||
|
|
008f08e5fb | ||
|
|
13f2d10d3d | ||
|
|
47aeae44ab | ||
|
|
a7951cf67f | ||
|
|
23efb200c7 | ||
|
|
2b0c6f7286 | ||
|
|
189b5ed532 | ||
|
|
a89f11efbc | ||
|
|
2045b1057e | ||
|
|
c84470eb7e | ||
|
|
06a5c6a688 | ||
|
|
5599c3b035 | ||
|
|
da423e3a51 | ||
|
|
501134de7c | ||
|
|
a8a1bfdf8a | ||
|
|
a2f0d127d9 | ||
|
|
2cc371fbde | ||
|
|
b574fcc5d8 | ||
|
|
a6f8259400 | ||
|
|
f99f7d98c7 | ||
|
|
ae09e09b98 | ||
|
|
fcc34e6f84 | ||
|
|
ad437c5a5a | ||
|
|
220848edd8 | ||
|
|
e6fd4d1f53 | ||
|
|
1562977173 | ||
|
|
2bd73e6281 | ||
|
|
70c5045bbe | ||
|
|
73abe2ef18 | ||
|
|
eb7b6b6387 | ||
|
|
e556cfe0fc | ||
|
|
26a6194583 | ||
|
|
3d32e21170 | ||
|
|
8aac1eb6d6 | ||
|
|
0d67f85313 | ||
|
|
df6b3b9a75 | ||
|
|
95bb9c1746 | ||
|
|
bfb8301c65 | ||
|
|
975138dce7 | ||
|
|
4c3225173e | ||
|
|
094051f2f0 | ||
|
|
1a7881ffa2 | ||
|
|
7f9e5dcd4e | ||
|
|
9bbf237731 | ||
|
|
4494f1afdd | ||
|
|
2957eee40a | ||
|
|
2570aed5d9 | ||
|
|
309522eb72 | ||
|
|
57830be851 | ||
|
|
45f443540d | ||
|
|
f5875ff23e | ||
|
|
182181ae7b | ||
|
|
75b87074fb | ||
|
|
e01ccc8f90 | ||
|
|
bba495c0a6 | ||
|
|
717c4cb48e | ||
|
|
838a24ee71 | ||
|
|
0eeafffd5e |
36
.github/renovate.json
vendored
36
.github/renovate.json
vendored
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"datasources": [
|
||||
"go"
|
||||
],
|
||||
"extends": [
|
||||
":automergeDigest",
|
||||
":automergeMinor",
|
||||
":automergePr"
|
||||
],
|
||||
"postUpdateOptions": [
|
||||
"gomodTidy"
|
||||
]
|
||||
},
|
||||
{
|
||||
"datasources": [
|
||||
"npm"
|
||||
],
|
||||
"extends": [
|
||||
":automergeDigest",
|
||||
":automergeMinor",
|
||||
":automergePr"
|
||||
]
|
||||
}
|
||||
],
|
||||
"assignees": [
|
||||
"cugu"
|
||||
],
|
||||
"ignoreDeps": [
|
||||
"sass-loader"
|
||||
]
|
||||
}
|
||||
18
.github/stale.yml
vendored
18
.github/stale.yml
vendored
@@ -1,18 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- feature
|
||||
- bug
|
||||
- enhancement
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -13,34 +13,29 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with: { go-version: '1.19' }
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with: { go-version: '1.21' }
|
||||
- run: |
|
||||
mkdir -p ui/dist/img
|
||||
touch ui/dist/index.html ui/dist/favicon.ico ui/dist/manifest.json ui/dist/img/fake.png
|
||||
- uses: golangci/golangci-lint-action@v3
|
||||
with: { version: 'v1.54' }
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '14', cache: 'yarn', cache-dependency-path: 'ui/yarn.lock' }
|
||||
- uses: actions/setup-go@v3
|
||||
with: { go-version: '1.19', cache: true }
|
||||
- uses: actions/setup-go@v4
|
||||
with: { go-version: '1.21' }
|
||||
- run: |
|
||||
mkdir -p ui/dist/img
|
||||
touch ui/dist/index.html ui/dist/favicon.ico ui/dist/manifest.json ui/dist/img/fake.png
|
||||
- run: docker compose up --quiet-pull --detach
|
||||
working-directory: dev
|
||||
- name: Install ArangoDB
|
||||
run: |
|
||||
curl -OL https://download.arangodb.com/arangodb38/DEBIAN/Release.key
|
||||
sudo apt-key add Release.key
|
||||
sudo apt-add-repository 'deb https://download.arangodb.com/arangodb38/DEBIAN/ /'
|
||||
sudo apt-get update -y && sudo apt-get -y install arangodb3
|
||||
- run: go test -coverprofile=cover.out -coverpkg=./... ./...
|
||||
- run: go tool cover -func=cover.out
|
||||
- uses: codecov/codecov-action@v3
|
||||
@@ -52,10 +47,10 @@ jobs:
|
||||
auth: [ authelia ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with: { go-version: '1.18' }
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with: { go-version: '1.21' }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '14' }
|
||||
# run UI
|
||||
- run: |
|
||||
@@ -92,8 +87,8 @@ jobs:
|
||||
name: Build npm
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '14', cache: 'yarn', cache-dependency-path: 'ui/yarn.lock' }
|
||||
- run: yarn install && yarn build
|
||||
working-directory: ui
|
||||
@@ -106,9 +101,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ build-npm, test ]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with: { go-version: '1.19', cache: true }
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v4
|
||||
with: { go-version: '1.21' }
|
||||
- uses: actions/download-artifact@v3
|
||||
with: { name: ui, path: ui/dist }
|
||||
- name: Version
|
||||
@@ -117,17 +112,17 @@ jobs:
|
||||
echo ${{ github.ref_name }}
|
||||
echo ${{ github.ref_name }} > VERSION
|
||||
- run: go build -o catalyst ./cmd/catalyst/.
|
||||
- uses: docker/login-action@v2
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
- uses: docker/build-push-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -81,6 +81,7 @@ dist
|
||||
node_modules
|
||||
|
||||
profile.cov
|
||||
cover.out
|
||||
|
||||
generated/caql/parser/*.interp
|
||||
generated/caql/parser/*.tokens
|
||||
|
||||
@@ -9,7 +9,6 @@ linters:
|
||||
- asciicheck
|
||||
- containedctx
|
||||
- decorder
|
||||
- depguard
|
||||
- dogsled
|
||||
- durationcheck
|
||||
- errchkjson
|
||||
@@ -71,6 +70,7 @@ linters:
|
||||
- nestif
|
||||
|
||||
# disable
|
||||
- depguard
|
||||
- dupl
|
||||
- exhaustivestruct
|
||||
- funlen
|
||||
@@ -118,4 +118,6 @@ issues:
|
||||
- path: caql
|
||||
linters: [ forcetypeassert ]
|
||||
- text: github.com/go-chi/chi/v5.Router
|
||||
linters: [ ireturn ]
|
||||
linters: [ ireturn ]
|
||||
- path: ui/ui.go
|
||||
linters: [ typecheck ]
|
||||
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @cugu
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ubuntu:18.04
|
||||
FROM ubuntu:23.04
|
||||
|
||||
RUN apt-get update -y && apt-get -y install curl gnupg2 software-properties-common
|
||||
RUN curl -OL https://download.arangodb.com/arangodb34/DEBIAN/Release.key
|
||||
|
||||
9
Makefile
Normal file
9
Makefile
Normal file
@@ -0,0 +1,9 @@
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
gci write -s standard -s default -s "prefix(github.com/SecurityBrewery/catalyst)" .
|
||||
# gofumpt -l -w .
|
||||
# wsl --fix ./...
|
||||
@@ -9,11 +9,6 @@
|
||||
-
|
||||
<a href="https://try.catalyst-soar.com">Try online</a>
|
||||
</h4>
|
||||
<h4 align="center">
|
||||
<a href="https://twitter.com/securitybrewery">Twitter</a>
|
||||
-
|
||||
<a href="https://discord.gg/nrmpveWvZX">Discord</a>
|
||||
</h4>
|
||||
|
||||
|
||||
Catalyst is an incident response platform or SOAR (Security Orchestration, Automation and Response) system. It can help
|
||||
|
||||
2
auth.go
2
auth.go
@@ -82,7 +82,7 @@ func (c *catalystResolver) UserByIDAndPassword(ctx context.Context, username str
|
||||
return mapMautUser(user), nil
|
||||
}
|
||||
|
||||
func (c *catalystResolver) Role(ctx context.Context, roleID string) (r *maut.Role, err error) {
|
||||
func (c *catalystResolver) Role(_ context.Context, roleID string) (r *maut.Role, err error) {
|
||||
switch roleID {
|
||||
case "admin":
|
||||
return Admin, nil
|
||||
|
||||
155
backup.go
155
backup.go
@@ -1,155 +0,0 @@
|
||||
package catalyst
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
"github.com/SecurityBrewery/catalyst/generated/api"
|
||||
"github.com/SecurityBrewery/catalyst/storage"
|
||||
)
|
||||
|
||||
func backupHandler(catalystStorage *storage.Storage, c *database.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=backup.zip")
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
err := Backup(catalystStorage, c, w)
|
||||
if err != nil {
|
||||
api.JSONError(w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WriterAtBuffer struct {
|
||||
bytes.Buffer
|
||||
}
|
||||
|
||||
func (fw WriterAtBuffer) WriteAt(p []byte, offset int64) (n int, err error) {
|
||||
return fw.Write(p)
|
||||
}
|
||||
|
||||
func Backup(catalystStorage *storage.Storage, c *database.Config, writer io.Writer) error {
|
||||
archive := zip.NewWriter(writer)
|
||||
defer archive.Close()
|
||||
|
||||
err := archive.SetComment(GetVersion())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// S3
|
||||
if err := backupS3(catalystStorage, archive); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Arango
|
||||
return backupArango(c, archive)
|
||||
}
|
||||
|
||||
func backupS3(catalystStorage *storage.Storage, archive *zip.Writer) error {
|
||||
buckets, err := catalystStorage.S3().ListBuckets(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, bucket := range buckets.Buckets {
|
||||
objects, err := catalystStorage.S3().ListObjectsV2(&s3.ListObjectsV2Input{
|
||||
Bucket: bucket.Name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, content := range objects.Contents {
|
||||
rbuf := &WriterAtBuffer{}
|
||||
_, err := catalystStorage.Downloader().Download(rbuf, &s3.GetObjectInput{
|
||||
Bucket: bucket.Name,
|
||||
Key: content.Key,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
a, err := archive.Create(path.Join("minio", *bucket.Name, *content.Key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(a, rbuf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupArango(c *database.Config, archive *zip.Writer) error {
|
||||
dir, err := os.MkdirTemp("", "catalyst-backup")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if err := arangodump(dir, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return zipDump(dir, archive)
|
||||
}
|
||||
|
||||
func zipDump(dir string, archive *zip.Writer) error {
|
||||
fsys := os.DirFS(dir)
|
||||
|
||||
return fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
a, err := archive.Create(path.Join("arango", p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := fsys.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(a, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func arangodump(dir string, config *database.Config) error {
|
||||
host := strings.Replace(config.Host, "http", "tcp", 1)
|
||||
|
||||
name := config.Name
|
||||
if config.Name == "" {
|
||||
name = database.Name
|
||||
}
|
||||
args := []string{
|
||||
"--output-directory", dir, "--server.endpoint", host,
|
||||
"--server.username", config.User, "--server.password", config.Password,
|
||||
"--server.database", name,
|
||||
}
|
||||
cmd := exec.Command("arangodump", args...)
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -79,11 +79,7 @@ func copyFile(ctx context.Context, cli *client.Client, path string, contentStrin
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cli.CopyToContainer(ctx, id, "/", tarBuf, types.CopyToContainerOptions{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return cli.CopyToContainer(ctx, id, "/", tarBuf, types.CopyToContainerOptions{})
|
||||
}
|
||||
|
||||
func runDocker(ctx context.Context, jobID, containerID string, db *database.Database) (stdout []byte, stderr []byte, err error) {
|
||||
|
||||
@@ -238,7 +238,7 @@ func (s *aqlBuilder) ExitReference(ctx *parser.ReferenceContext) {
|
||||
}
|
||||
|
||||
// ExitCompound_value is called when production compound_value is exited.
|
||||
func (s *aqlBuilder) ExitCompound_value(ctx *parser.Compound_valueContext) {
|
||||
func (s *aqlBuilder) ExitCompound_value(_ *parser.Compound_valueContext) {
|
||||
// pass
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ func (s *aqlInterpreter) ExitReference(ctx *parser.ReferenceContext) {
|
||||
}
|
||||
|
||||
// ExitCompound_value is called when production compound_value is exited.
|
||||
func (s *aqlInterpreter) ExitCompound_value(ctx *parser.Compound_valueContext) {
|
||||
func (s *aqlInterpreter) ExitCompound_value(_ *parser.Compound_valueContext) {
|
||||
// pass
|
||||
}
|
||||
|
||||
|
||||
@@ -104,18 +104,18 @@ type errorListener struct {
|
||||
errs []error
|
||||
}
|
||||
|
||||
func (el *errorListener) SyntaxError(recognizer antlr.Recognizer, offendingSymbol any, line, column int, msg string, e antlr.RecognitionException) {
|
||||
func (el *errorListener) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
|
||||
el.errs = append(el.errs, fmt.Errorf("line "+strconv.Itoa(line)+":"+strconv.Itoa(column)+" "+msg))
|
||||
}
|
||||
|
||||
func (el *errorListener) ReportAmbiguity(recognizer antlr.Parser, dfa *antlr.DFA, startIndex, stopIndex int, exact bool, ambigAlts *antlr.BitSet, configs antlr.ATNConfigSet) {
|
||||
func (el *errorListener) ReportAmbiguity(_ antlr.Parser, _ *antlr.DFA, _, _ int, _ bool, _ *antlr.BitSet, _ antlr.ATNConfigSet) {
|
||||
el.errs = append(el.errs, errors.New("ReportAmbiguity"))
|
||||
}
|
||||
|
||||
func (el *errorListener) ReportAttemptingFullContext(recognizer antlr.Parser, dfa *antlr.DFA, startIndex, stopIndex int, conflictingAlts *antlr.BitSet, configs antlr.ATNConfigSet) {
|
||||
func (el *errorListener) ReportAttemptingFullContext(_ antlr.Parser, _ *antlr.DFA, _, _ int, _ *antlr.BitSet, _ antlr.ATNConfigSet) {
|
||||
el.errs = append(el.errs, errors.New("ReportAttemptingFullContext"))
|
||||
}
|
||||
|
||||
func (el *errorListener) ReportContextSensitivity(recognizer antlr.Parser, dfa *antlr.DFA, startIndex, stopIndex, prediction int, configs antlr.ATNConfigSet) {
|
||||
func (el *errorListener) ReportContextSensitivity(_ antlr.Parser, _ *antlr.DFA, _, _, _ int, _ antlr.ATNConfigSet) {
|
||||
el.errs = append(el.errs, errors.New("ReportContextSensitivity"))
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ type CLI struct {
|
||||
S3Host string `env:"S3_HOST" default:"http://minio:9000" name:"s3-host"`
|
||||
S3User string `env:"S3_USER" default:"minio" name:"s3-user"`
|
||||
S3Password string `env:"S3_PASSWORD" required:"" name:"s3-password"`
|
||||
S3Region string `env:"S3_REGION" default:"us-east-1" name:"s3-region"`
|
||||
|
||||
Version kong.VersionFlag `help:"Show version."`
|
||||
}
|
||||
|
||||
func ParseCatalystConfig() (*catalyst.Config, error) {
|
||||
@@ -57,6 +60,7 @@ func ParseCatalystConfig() (*catalyst.Config, error) {
|
||||
&cli,
|
||||
kong.Configuration(kong.JSON, "/etc/catalyst.json", ".catalyst.json"),
|
||||
kong.Configuration(kongyaml.Loader, "/etc/catalyst.yaml", ".catalyst.yaml"),
|
||||
kong.Vars{"version": catalyst.GetVersion()},
|
||||
)
|
||||
|
||||
if cli.OIDCEnable {
|
||||
@@ -81,7 +85,7 @@ func MapConfig(cli CLI) (*catalyst.Config, error) {
|
||||
User: cli.ArangoDBUser,
|
||||
Password: cli.ArangoDBPassword,
|
||||
},
|
||||
Storage: &storage.Config{Host: cli.S3Host, User: cli.S3User, Password: cli.S3Password},
|
||||
Storage: &storage.Config{Host: cli.S3Host, User: cli.S3User, Region: cli.S3Region, Password: cli.S3Password},
|
||||
ExternalAddress: cli.ExternalAddress,
|
||||
InternalAddress: cli.CatalystAddress,
|
||||
Port: cli.Port,
|
||||
|
||||
@@ -82,7 +82,7 @@ func NewCollection[T any](internal driver.Collection, db *BusDatabase) *Collecti
|
||||
return &Collection[T]{internal: internal, db: db}
|
||||
}
|
||||
|
||||
func (c *Collection[T]) CreateDocument(ctx, newctx context.Context, key string, document *T) (meta driver.DocumentMeta, err error) {
|
||||
func (c *Collection[T]) CreateDocument(_, newctx context.Context, key string, document *T) (meta driver.DocumentMeta, err error) {
|
||||
defer func() { err = toHTTPErr(err) }()
|
||||
|
||||
meta, err = c.internal.CreateDocument(newctx, &Keyed[T]{Key: key, Doc: document})
|
||||
@@ -95,7 +95,7 @@ func (c *Collection[T]) CreateDocument(ctx, newctx context.Context, key string,
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func (c *Collection[T]) CreateEdge(ctx, newctx context.Context, edge *driver.EdgeDocument) (meta driver.DocumentMeta, err error) {
|
||||
func (c *Collection[T]) CreateEdge(_, newctx context.Context, edge *driver.EdgeDocument) (meta driver.DocumentMeta, err error) {
|
||||
defer func() { err = toHTTPErr(err) }()
|
||||
|
||||
meta, err = c.internal.CreateDocument(newctx, edge)
|
||||
|
||||
@@ -93,7 +93,7 @@ func (db *Database) UserCreate(ctx context.Context, newUser *model.UserForm) (*m
|
||||
|
||||
var doc model.User
|
||||
newctx := driver.WithReturnNew(ctx, &doc)
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, strcase.ToKebab(newUser.ID), toUser(newUser, sha256Hash))
|
||||
meta, err := db.userCollection.CreateDocument(ctx, newctx, newUser.ID, toUser(newUser, sha256Hash))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
users:
|
||||
alice:
|
||||
alice@example.com:
|
||||
displayname: Alice
|
||||
password: "$argon2id$v=19$m=65536,t=3,p=4$S3hTSS90U1QycjNEWURZTw$aJP1fI/byC/3A7NCz5lyrXR7NS+l+1YMnqj5qFopZRk"
|
||||
email: alice@example.com
|
||||
bob:
|
||||
bob@example.com:
|
||||
displayname: "Bob"
|
||||
password: "$argon2id$v=19$m=65536,t=3,p=4$amxRcURFVUk4TlhPOXFmWg$sPRsvGg9rrqefRp0fFA7wQG3O8OcMnQhj4IckHYPEz8"
|
||||
email: bob@example.com
|
||||
admin:
|
||||
admin@example.com:
|
||||
displayname: "Admin"
|
||||
password: "$argon2id$v=19$m=65536,t=3,p=4$SFBXa1BXblNZKytoZ1ZLYQ$JruWROu9opYmcPNw1cIiHms4k4466DqrKIPvJe94nfA"
|
||||
email: admin@example.com
|
||||
@@ -1,7 +1,7 @@
|
||||
version: '2.4'
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.23
|
||||
image: nginx:1.25
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
ports: [ "80:80", "8529:8529", "9000:9000", "8082:8082", "9003:9003" ]
|
||||
|
||||
1
dev/start_dev.sh
Normal file → Executable file
1
dev/start_dev.sh
Normal file → Executable file
@@ -11,6 +11,7 @@ export OIDC_CLIENT_SECRET=secret
|
||||
export ARANGO_DB_HOST=http://localhost:8529
|
||||
export ARANGO_DB_PASSWORD=foobar
|
||||
export S3_HOST=http://localhost:9000
|
||||
export S3_REGION=eu-central-1
|
||||
export S3_PASSWORD=minio123
|
||||
|
||||
export AUTH_BLOCK_NEW=false
|
||||
|
||||
89
go.mod
89
go.mod
@@ -2,67 +2,68 @@ module github.com/SecurityBrewery/catalyst
|
||||
|
||||
go 1.19
|
||||
|
||||
replace github.com/xeipuuv/gojsonschema => github.com/warjiang/gojsonschema v1.2.1-0.20210329105853-aa9f9a8cfec7
|
||||
replace github.com/xeipuuv/gojsonschema => github.com/warjiang/gojsonschema v1.2.1-0.20201027075954-b076d39a02e5
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v0.7.0
|
||||
github.com/alecthomas/kong-yaml v0.1.1
|
||||
github.com/alecthomas/kong v0.8.1
|
||||
github.com/alecthomas/kong-yaml v0.2.0
|
||||
github.com/antlr/antlr4/runtime/Go/antlr v0.0.0-20220816024939-bc8df83d7b9d
|
||||
github.com/arangodb/go-driver v1.4.0
|
||||
github.com/aws/aws-sdk-go v1.44.131
|
||||
github.com/blevesearch/bleve/v2 v2.3.5
|
||||
github.com/coreos/go-oidc/v3 v3.4.0
|
||||
github.com/arangodb/go-driver v1.6.0
|
||||
github.com/aws/aws-sdk-go v1.46.2
|
||||
github.com/blevesearch/bleve/v2 v2.3.10
|
||||
github.com/coreos/go-oidc/v3 v3.7.0
|
||||
github.com/docker/docker v17.12.0-ce-rc1.0.20201201034508-7d75c1d40d88+incompatible
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/gobwas/ws v1.1.0
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/iancoleman/strcase v0.2.0
|
||||
github.com/icza/dyno v0.0.0-20220812133438-f0b6f8a18845
|
||||
github.com/imdario/mergo v0.3.13
|
||||
github.com/jonas-plum/maut v0.0.0-20221001191853-1856efe6da0b
|
||||
github.com/go-chi/chi/v5 v5.0.10
|
||||
github.com/gobwas/ws v1.3.0
|
||||
github.com/google/uuid v1.3.1
|
||||
github.com/iancoleman/strcase v0.3.0
|
||||
github.com/icza/dyno v0.0.0-20230330125955-09f820a8d9c0
|
||||
github.com/imdario/mergo v0.3.16
|
||||
github.com/jonas-plum/maut v0.0.0-20221105155335-ed984fd96915
|
||||
github.com/mingrammer/commonregex v1.0.1
|
||||
github.com/stretchr/testify v1.8.1
|
||||
github.com/tidwall/gjson v1.14.3
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/tidwall/gjson v1.17.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/tus/tusd v1.10.0
|
||||
github.com/tus/tusd v1.13.0
|
||||
github.com/xeipuuv/gojsonschema v1.2.0
|
||||
golang.org/x/exp v0.0.0-20221031165847-c99f073a8326
|
||||
golang.org/x/oauth2 v0.1.0
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
|
||||
golang.org/x/oauth2 v0.13.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
||||
github.com/Microsoft/go-winio v0.5.1 // indirect
|
||||
github.com/RoaringBitmap/roaring v0.9.4 // indirect
|
||||
github.com/RoaringBitmap/roaring v1.6.0 // indirect
|
||||
github.com/arangodb/go-velocypack v0.0.0-20200318135517-5af53c29c67e // indirect
|
||||
github.com/bits-and-blooms/bitset v1.2.1 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.0.4 // indirect
|
||||
github.com/blevesearch/geo v0.1.15 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.10.0 // indirect
|
||||
github.com/blevesearch/bleve_index_api v1.0.6 // indirect
|
||||
github.com/blevesearch/geo v0.1.18 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/gtreap v0.1.1 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.4 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.1.3 // indirect
|
||||
github.com/blevesearch/segment v0.9.0 // indirect
|
||||
github.com/blevesearch/scorch_segment_api/v2 v2.1.6 // indirect
|
||||
github.com/blevesearch/segment v0.9.1 // indirect
|
||||
github.com/blevesearch/snowballstem v0.9.0 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.1 // indirect
|
||||
github.com/blevesearch/vellum v1.0.9 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.3.6 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.3.6 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.3.6 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.3.6 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.3.6 // indirect
|
||||
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
|
||||
github.com/blevesearch/vellum v1.0.10 // indirect
|
||||
github.com/blevesearch/zapx/v11 v11.3.10 // indirect
|
||||
github.com/blevesearch/zapx/v12 v12.3.10 // indirect
|
||||
github.com/blevesearch/zapx/v13 v13.3.10 // indirect
|
||||
github.com/blevesearch/zapx/v14 v14.3.10 // indirect
|
||||
github.com/blevesearch/zapx/v15 v15.3.13 // indirect
|
||||
github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f // indirect
|
||||
github.com/containerd/containerd v1.6.8 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/docker/distribution v2.7.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.4.0 // indirect
|
||||
github.com/docker/go-units v0.4.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.0 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||
github.com/gorilla/sessions v1.2.1 // indirect
|
||||
@@ -78,18 +79,16 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
go.etcd.io/bbolt v1.3.6 // indirect
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be // indirect
|
||||
golang.org/x/net v0.1.0 // indirect
|
||||
golang.org/x/sys v0.1.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006 // indirect
|
||||
google.golang.org/grpc v1.50.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.7 // indirect
|
||||
golang.org/x/crypto v0.14.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20230807174057-1744710a1577 // indirect
|
||||
google.golang.org/grpc v1.57.1 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gotest.tools v2.2.0+incompatible // indirect
|
||||
)
|
||||
|
||||
176
restore.go
176
restore.go
@@ -1,176 +0,0 @@
|
||||
package catalyst
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws/awserr"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst/database"
|
||||
"github.com/SecurityBrewery/catalyst/generated/api"
|
||||
"github.com/SecurityBrewery/catalyst/generated/pointer"
|
||||
"github.com/SecurityBrewery/catalyst/storage"
|
||||
)
|
||||
|
||||
func restoreHandler(catalystStorage *storage.Storage, db *database.Database, c *database.Config) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
uf, header, err := r.FormFile("backup")
|
||||
if err != nil {
|
||||
api.JSONError(w, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err = Restore(r.Context(), catalystStorage, db, c, uf, header.Size); err != nil {
|
||||
api.JSONError(w, err)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Restore(ctx context.Context, catalystStorage *storage.Storage, db *database.Database, c *database.Config, r io.Reader, size int64) error {
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ra := bytes.NewReader(b)
|
||||
fsys, err := zip.NewReader(ra, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if fsys.Comment != GetVersion() {
|
||||
return fmt.Errorf("wrong version, got: %s, want: %s", fsys.Comment, GetVersion())
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "catalyst-restore")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
if err = unzip(fsys, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := restoreS3(catalystStorage, path.Join(dir, "minio")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := arangorestore(path.Join(dir, "arango"), c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.IndexRebuild(ctx)
|
||||
}
|
||||
|
||||
func restoreS3(catalystStorage *storage.Storage, p string) error {
|
||||
minioDir := os.DirFS(p)
|
||||
|
||||
entries, err := fs.ReadDir(minioDir, ".")
|
||||
if err != nil {
|
||||
// directory might not exist
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if err := restoreBucket(catalystStorage, entry, minioDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreBucket(catalystStorage *storage.Storage, entry fs.DirEntry, minioDir fs.FS) error {
|
||||
_, err := catalystStorage.S3().CreateBucket(&s3.CreateBucketInput{Bucket: pointer.String(entry.Name())})
|
||||
if err != nil {
|
||||
var awsError awserr.Error
|
||||
if errors.As(err, &awsError) && (awsError.Code() == s3.ErrCodeBucketAlreadyExists || awsError.Code() == s3.ErrCodeBucketAlreadyOwnedByYou) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
uploader := catalystStorage.Uploader()
|
||||
|
||||
f, err := minioDir.Open(entry.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = fs.WalkDir(minioDir, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_, err = uploader.Upload(&s3manager.UploadInput{Body: f, Bucket: pointer.String(entry.Name()), Key: pointer.String(path)})
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func unzip(archive *zip.Reader, dir string) error {
|
||||
return fs.WalkDir(archive, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
_ = os.MkdirAll(path.Join(dir, p), os.ModePerm)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := archive.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(path.Join(dir, p), b, os.ModePerm)
|
||||
})
|
||||
}
|
||||
|
||||
func arangorestore(dir string, config *database.Config) error {
|
||||
host := strings.Replace(config.Host, "http", "tcp", 1)
|
||||
|
||||
name := config.Name
|
||||
if config.Name == "" {
|
||||
name = database.Name
|
||||
}
|
||||
args := []string{
|
||||
"--batch-size", "524288",
|
||||
"--input-directory", dir, "--server.endpoint", host,
|
||||
"--server.username", config.User, "--server.password", config.Password,
|
||||
"--server.database", name,
|
||||
}
|
||||
cmd := exec.Command("arangorestore", args...)
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
13
server.go
13
server.go
@@ -74,7 +74,7 @@ func New(hooks *hooks.Hooks, config *Config) (*Server, error) {
|
||||
return nil, fmt.Errorf("failed to create authenticator: %w", err)
|
||||
}
|
||||
|
||||
apiServer, err := setupAPI(authenticator, catalystService, catalystStorage, catalystDatabase, config.DB, catalystBus, config)
|
||||
apiServer, err := setupAPI(authenticator, catalystService, catalystStorage, catalystDatabase, catalystBus, config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create api server: %w", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func New(hooks *hooks.Hooks, config *Config) (*Server, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupAPI(authenticator *maut.Authenticator, catalystService *service.Service, catalystStorage *storage.Storage, catalystDatabase *database.Database, dbConfig *database.Config, bus *bus.Bus, config *Config) (chi.Router, error) {
|
||||
func setupAPI(authenticator *maut.Authenticator, catalystService *service.Service, catalystStorage *storage.Storage, catalystDatabase *database.Database, bus *bus.Bus, config *Config) (chi.Router, error) {
|
||||
middlewares := []func(next http.Handler) http.Handler{
|
||||
authenticator.Authenticate(),
|
||||
authenticator.AuthorizeBlockedUser(),
|
||||
@@ -97,7 +97,6 @@ func setupAPI(authenticator *maut.Authenticator, catalystService *service.Servic
|
||||
// create server
|
||||
apiServer := api.NewServer(catalystService, permissionAuth(authenticator), middlewares...)
|
||||
apiServer.Mount("/files", fileServer(authenticator, catalystDatabase, bus, catalystStorage, config))
|
||||
apiServer.Mount("/backup", backupServer(authenticator, catalystStorage, catalystDatabase, dbConfig))
|
||||
|
||||
server := chi.NewRouter()
|
||||
server.Use(middleware.RequestID, middleware.RealIP, middleware.Logger, middleware.Recoverer)
|
||||
@@ -130,11 +129,3 @@ func fileServer(authenticator *maut.Authenticator, catalystDatabase *database.Da
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func backupServer(authenticator *maut.Authenticator, catalystStorage *storage.Storage, catalystDatabase *database.Database, dbConfig *database.Config) *chi.Mux {
|
||||
server := chi.NewRouter()
|
||||
server.With(authenticator.AuthorizePermission("backup:create")).Get("/create", backupHandler(catalystStorage, dbConfig))
|
||||
server.With(authenticator.AuthorizePermission("backup:restore")).Post("/restore", restoreHandler(catalystStorage, catalystDatabase, dbConfig))
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/arangodb/go-driver"
|
||||
maut "github.com/jonas-plum/maut/auth"
|
||||
@@ -20,14 +21,6 @@ func newUserResponseID(user *model.NewUserResponse) []driver.DocumentID {
|
||||
return userID(user.ID)
|
||||
}
|
||||
|
||||
func userResponseID(user *model.UserResponse) []driver.DocumentID {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return userID(user.ID)
|
||||
}
|
||||
|
||||
func userID(id string) []driver.DocumentID {
|
||||
return []driver.DocumentID{driver.DocumentID(fmt.Sprintf("%s/%s", database.UserCollectionName, id))}
|
||||
}
|
||||
@@ -42,20 +35,35 @@ func (s *Service) CreateUser(ctx context.Context, form *model.UserForm) (doc *mo
|
||||
return s.database.UserCreate(ctx, form)
|
||||
}
|
||||
|
||||
func (s *Service) GetUser(ctx context.Context, s2 string) (*model.UserResponse, error) {
|
||||
return s.database.UserGet(ctx, s2)
|
||||
func (s *Service) GetUser(ctx context.Context, id string) (*model.UserResponse, error) {
|
||||
decodedValue, err := url.QueryUnescape(id)
|
||||
if err == nil {
|
||||
id = decodedValue
|
||||
}
|
||||
|
||||
return s.database.UserGet(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUser(ctx context.Context, s2 string, form *model.UserForm) (doc *model.UserResponse, err error) {
|
||||
defer s.publishRequest(ctx, err, "UpdateUser", userID(s2))
|
||||
func (s *Service) UpdateUser(ctx context.Context, id string, form *model.UserForm) (doc *model.UserResponse, err error) {
|
||||
decodedValue, err := url.QueryUnescape(id)
|
||||
if err == nil {
|
||||
id = decodedValue
|
||||
}
|
||||
|
||||
return s.database.UserUpdate(ctx, s2, form)
|
||||
defer s.publishRequest(ctx, err, "UpdateUser", userID(id))
|
||||
|
||||
return s.database.UserUpdate(ctx, id, form)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteUser(ctx context.Context, s2 string) (err error) {
|
||||
defer s.publishRequest(ctx, err, "DeleteUser", userID(s2))
|
||||
func (s *Service) DeleteUser(ctx context.Context, id string) (err error) {
|
||||
decodedValue, err := url.QueryUnescape(id)
|
||||
if err == nil {
|
||||
id = decodedValue
|
||||
}
|
||||
|
||||
return s.database.UserDelete(ctx, s2)
|
||||
defer s.publishRequest(ctx, err, "DeleteUser", userID(id))
|
||||
|
||||
return s.database.UserDelete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) CurrentUser(ctx context.Context) (*model.UserResponse, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/arangodb/go-driver"
|
||||
maut "github.com/jonas-plum/maut/auth"
|
||||
@@ -29,10 +30,20 @@ func (s *Service) ListUserData(ctx context.Context) (doc []*model.UserDataRespon
|
||||
}
|
||||
|
||||
func (s *Service) GetUserData(ctx context.Context, id string) (*model.UserDataResponse, error) {
|
||||
decodedValue, err := url.QueryUnescape(id)
|
||||
if err == nil {
|
||||
id = decodedValue
|
||||
}
|
||||
|
||||
return s.database.UserDataGet(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUserData(ctx context.Context, id string, data *model.UserData) (doc *model.UserDataResponse, err error) {
|
||||
decodedValue, err := url.QueryUnescape(id)
|
||||
if err == nil {
|
||||
id = decodedValue
|
||||
}
|
||||
|
||||
defer s.publishRequest(ctx, err, "UpdateUserData", userDataResponseID(doc))
|
||||
|
||||
return s.database.UserDataUpdate(ctx, id, data)
|
||||
|
||||
@@ -20,6 +20,7 @@ type Storage struct {
|
||||
type Config struct {
|
||||
Host string
|
||||
User string
|
||||
Region string
|
||||
Password string
|
||||
}
|
||||
|
||||
@@ -27,7 +28,7 @@ func New(config *Config) (*Storage, error) {
|
||||
s, err := session.NewSession(&aws.Config{
|
||||
Credentials: credentials.NewStaticCredentials(config.User, config.Password, ""),
|
||||
Endpoint: aws.String(config.Host),
|
||||
Region: aws.String("us-east-1"),
|
||||
Region: aws.String(config.Region),
|
||||
DisableSSL: aws.Bool(true),
|
||||
S3ForcePathStyle: aws.Bool(true),
|
||||
})
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/aws/aws-sdk-go/service/s3/s3manager"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/SecurityBrewery/catalyst"
|
||||
"github.com/SecurityBrewery/catalyst/generated/model"
|
||||
"github.com/SecurityBrewery/catalyst/generated/pointer"
|
||||
)
|
||||
|
||||
func TestBackupAndRestore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
|
||||
if runtime.GOARCH == "arm64" {
|
||||
t.Skip("test does not run on arm")
|
||||
}
|
||||
|
||||
type want struct {
|
||||
status int
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
want want
|
||||
}{
|
||||
{name: "Backup", want: want{status: http.StatusOK}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _, server, err := Catalyst(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := SetupTestData(ctx, server.DB); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
createFile(ctx, server)
|
||||
|
||||
zipB := assertBackup(t, server)
|
||||
|
||||
assertZipFile(t, readZipFile(t, zipB))
|
||||
|
||||
clearAllDatabases(server)
|
||||
_, err = server.DB.UserCreateSetupAPIKey(ctx, "test")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
deleteAllBuckets(t, server)
|
||||
|
||||
assertRestore(t, zipB, server)
|
||||
|
||||
assertTicketExists(t, server)
|
||||
|
||||
assertFileExists(t, server)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackup(t *testing.T, server *catalyst.Server) []byte {
|
||||
t.Helper()
|
||||
|
||||
// setup request
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/backup/create", nil)
|
||||
req.Header.Set("PRIVATE-TOKEN", "test")
|
||||
|
||||
// run request
|
||||
backupRequestRecorder := httptest.NewRecorder()
|
||||
server.Server.ServeHTTP(backupRequestRecorder, req)
|
||||
backupResult := backupRequestRecorder.Result()
|
||||
|
||||
// assert results
|
||||
assert.Equal(t, http.StatusOK, backupResult.StatusCode)
|
||||
|
||||
zipBuf := &bytes.Buffer{}
|
||||
if _, err := io.Copy(zipBuf, backupResult.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.NoError(t, backupResult.Body.Close())
|
||||
|
||||
return zipBuf.Bytes()
|
||||
}
|
||||
|
||||
func assertZipFile(t *testing.T, r *zip.Reader) {
|
||||
t.Helper()
|
||||
|
||||
var names []string
|
||||
for _, f := range r.File {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
|
||||
if !includes(t, names, "minio/catalyst-8125/test.txt") {
|
||||
t.Error("Minio file missing")
|
||||
}
|
||||
|
||||
for _, p := range []string{
|
||||
"arango/ENCRYPTION", "arango/automations_.*.data.json.gz", "arango/automations_.*.structure.json", "arango/dump.json", "arango/jobs_.*.data.json.gz", "arango/jobs_.*.structure.json", "arango/logs_.*.data.json.gz", "arango/logs_.*.structure.json", "arango/migrations_.*.data.json.gz", "arango/migrations_.*.structure.json", "arango/playbooks_.*.data.json.gz", "arango/playbooks_.*.structure.json", "arango/related_.*.data.json.gz", "arango/related_.*.structure.json", "arango/templates_.*.data.json.gz", "arango/templates_.*.structure.json", "arango/tickets_.*.data.json.gz", "arango/tickets_.*.structure.json", "arango/tickettypes_.*.data.json.gz", "arango/tickettypes_.*.structure.json", "arango/userdata_.*.data.json.gz", "arango/userdata_.*.structure.json", "arango/users_.*.data.json.gz", "arango/users_.*.structure.json",
|
||||
} {
|
||||
if !includes(t, names, p) {
|
||||
t.Errorf("Arango file missing: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearAllDatabases(server *catalyst.Server) {
|
||||
server.DB.Truncate(context.Background())
|
||||
}
|
||||
|
||||
func deleteAllBuckets(t *testing.T, server *catalyst.Server) {
|
||||
t.Helper()
|
||||
|
||||
buckets, err := server.Storage.S3().ListBuckets(&s3.ListBucketsInput{})
|
||||
for _, bucket := range buckets.Buckets {
|
||||
_, _ = server.Storage.S3().DeleteBucket(&s3.DeleteBucketInput{
|
||||
Bucket: bucket.Name,
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRestore(t *testing.T, zipB []byte, server *catalyst.Server) {
|
||||
t.Helper()
|
||||
|
||||
bodyBuf := &bytes.Buffer{}
|
||||
bodyWriter := multipart.NewWriter(bodyBuf)
|
||||
fileWriter, err := bodyWriter.CreateFormFile("backup", "backup.zip")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = fileWriter.Write(zipB)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
assert.NoError(t, bodyWriter.Close())
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/backup/restore", bodyBuf)
|
||||
req.Header.Set("PRIVATE-TOKEN", "test")
|
||||
req.Header.Set("Content-Type", bodyWriter.FormDataContentType())
|
||||
|
||||
// run request
|
||||
restoreRequestRecorder := httptest.NewRecorder()
|
||||
server.Server.ServeHTTP(restoreRequestRecorder, req)
|
||||
restoreResult := restoreRequestRecorder.Result()
|
||||
|
||||
if !assert.Equal(t, http.StatusOK, restoreResult.StatusCode) {
|
||||
b, _ := io.ReadAll(restoreResult.Body)
|
||||
log.Println(string(b))
|
||||
t.FailNow()
|
||||
}
|
||||
}
|
||||
|
||||
func createFile(ctx context.Context, server *catalyst.Server) {
|
||||
buf := bytes.NewBufferString("test text")
|
||||
|
||||
_, _ = server.Storage.S3().CreateBucket(&s3.CreateBucketInput{Bucket: pointer.String("catalyst-8125")})
|
||||
|
||||
if _, err := server.Storage.Uploader().Upload(&s3manager.UploadInput{Body: buf, Bucket: pointer.String("catalyst-8125"), Key: pointer.String("test.txt")}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := server.DB.AddFile(ctx, 8125, &model.File{Key: "test.txt", Name: "test.txt"}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTicketExists(t *testing.T, server *catalyst.Server) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/tickets/8125", nil)
|
||||
req.Header.Set("PRIVATE-TOKEN", "test")
|
||||
|
||||
// run request
|
||||
backupRequestRecorder := httptest.NewRecorder()
|
||||
server.Server.ServeHTTP(backupRequestRecorder, req)
|
||||
backupResult := backupRequestRecorder.Result()
|
||||
|
||||
// assert results
|
||||
assert.Equal(t, http.StatusOK, backupResult.StatusCode)
|
||||
|
||||
zipBuf := &bytes.Buffer{}
|
||||
if _, err := io.Copy(zipBuf, backupResult.Body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assert.NoError(t, backupResult.Body.Close())
|
||||
|
||||
var ticket model.Ticket
|
||||
assert.NoError(t, json.Unmarshal(zipBuf.Bytes(), &ticket))
|
||||
|
||||
assert.Equal(t, "phishing from selenafadel@von.com detected", ticket.Name)
|
||||
}
|
||||
|
||||
func assertFileExists(t *testing.T, server *catalyst.Server) {
|
||||
t.Helper()
|
||||
|
||||
obj, err := server.Storage.S3().GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String("catalyst-8125"),
|
||||
Key: aws.String("test.txt"),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
b, err := io.ReadAll(obj.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "test text", string(b))
|
||||
}
|
||||
|
||||
func includes(t *testing.T, names []string, s string) bool {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range names {
|
||||
match, err := regexp.MatchString(s, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func readZipFile(t *testing.T, b []byte) *zip.Reader {
|
||||
t.Helper()
|
||||
|
||||
buf := bytes.NewReader(b)
|
||||
|
||||
zr, err := zip.NewReader(buf, int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatal(string(b), err)
|
||||
}
|
||||
|
||||
return zr
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func Context() context.Context {
|
||||
return maut.UserContext(context.Background(), Bob, nil) // TODO add permissions ?
|
||||
}
|
||||
|
||||
func Config(ctx context.Context) (*catalyst.Config, error) {
|
||||
func Config(_ context.Context) (*catalyst.Config, error) {
|
||||
config := &catalyst.Config{
|
||||
IndexPath: "index.bleve",
|
||||
Network: "catalyst",
|
||||
@@ -42,6 +42,7 @@ func Config(ctx context.Context) (*catalyst.Config, error) {
|
||||
Storage: &storage.Config{
|
||||
Host: "http://localhost:9000",
|
||||
User: "minio",
|
||||
Region: "eu-central-1",
|
||||
Password: "minio123",
|
||||
},
|
||||
Auth: &maut.Config{
|
||||
|
||||
@@ -26,7 +26,7 @@ Cypress.Commands.add('login', (options = {}) => {
|
||||
cy.get("#kc-login").click();
|
||||
} else if (Cypress.env('AUTH') === 'authelia') {
|
||||
cy.contains("Login with OIDC").should('be.visible').click();
|
||||
cy.get("#username-textfield").should('be.visible').type("bob");
|
||||
cy.get("#username-textfield").should('be.visible').type("bob@example.com");
|
||||
cy.get("#password-textfield").type("bob");
|
||||
cy.get("#sign-in-button").click();
|
||||
cy.get("#accept-button").should('be.visible').click();
|
||||
|
||||
@@ -9,31 +9,34 @@
|
||||
"lint": "vue-cli-service lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koumoul/vjsf": "2.21.0",
|
||||
"@mdi/font": "7.0.96",
|
||||
"@crinkles/digl": "^2.0.2",
|
||||
"@koumoul/vjsf": "2.23.2",
|
||||
"@mdi/font": "7.3.67",
|
||||
"@mdi/util": "0.3.2",
|
||||
"@types/luxon": "3.1.0",
|
||||
"@types/prismjs": "1.26.0",
|
||||
"@types/luxon": "3.3.3",
|
||||
"@types/prismjs": "1.26.2",
|
||||
"@uppy/core": "1.20.1",
|
||||
"@uppy/tus": "1.9.2",
|
||||
"@uppy/vue": "0.2.7",
|
||||
"ajv": "8.11.0",
|
||||
"ajv": "8.12.0",
|
||||
"ant-design-vue": "1.7.8",
|
||||
"antlr4": "4.11.0",
|
||||
"axios": "1.1.3",
|
||||
"axios": "1.5.1",
|
||||
"chart.js": "2.9.4",
|
||||
"core-js": "3.26.0",
|
||||
"core-js": "3.33.1",
|
||||
"d3": "^7.8.0",
|
||||
"graphlib": "2.1.8",
|
||||
"json-schema-editor-vue": "2.1.1",
|
||||
"just-kebab-case": "4.1.1",
|
||||
"less": "4.1.3",
|
||||
"less-loader": "11.1.0",
|
||||
"json-schema-editor-vue": "2.2.3",
|
||||
"just-kebab-case": "4.2.0",
|
||||
"less": "4.2.0",
|
||||
"less-loader": "11.1.3",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.1.0",
|
||||
"luxon": "3.4.3",
|
||||
"panzoom": "^9.4.3",
|
||||
"register-service-worker": "1.7.2",
|
||||
"splitpanes": "2.4.1",
|
||||
"swagger-ui": "4.13.0",
|
||||
"vue": "2.7.10",
|
||||
"vue": "2.7.15",
|
||||
"vue-axios": "3.5.2",
|
||||
"vue-chartjs": "3.5.1",
|
||||
"vue-class-component": "7.2.6",
|
||||
@@ -47,17 +50,17 @@
|
||||
"vue-prism-editor": "1.3.0",
|
||||
"vue-property-decorator": "9.1.2",
|
||||
"vue-router": "3.6.5",
|
||||
"vuetify": "2.6.12",
|
||||
"vuetify": "2.7.1",
|
||||
"vuex": "3.6.2",
|
||||
"yaml": "2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/vue": "6.6.1",
|
||||
"@types/jest": "29.2.2",
|
||||
"@types/lodash": "4.14.188",
|
||||
"@types/vue-markdown": "2.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "5.42.0",
|
||||
"@typescript-eslint/parser": "5.42.0",
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/jest": "29.5.6",
|
||||
"@types/lodash": "4.14.200",
|
||||
"@types/vue-markdown": "2.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.62.0",
|
||||
"@typescript-eslint/parser": "5.62.0",
|
||||
"@vue/cli-plugin-babel": "4.5.19",
|
||||
"@vue/cli-plugin-eslint": "4.5.19",
|
||||
"@vue/cli-plugin-pwa": "4.5.19",
|
||||
@@ -66,19 +69,19 @@
|
||||
"@vue/cli-plugin-unit-jest": "4.5.19",
|
||||
"@vue/cli-plugin-vuex": "4.5.19",
|
||||
"@vue/cli-service": "4.5.19",
|
||||
"@vue/compiler-sfc": "3.2.40",
|
||||
"@vue/compiler-sfc": "3.3.6",
|
||||
"@vue/eslint-config-typescript": "10.0.0",
|
||||
"@vue/test-utils": "2.2.1",
|
||||
"babel-eslint": "10.1.0",
|
||||
"cypress": "10.11.0",
|
||||
"@vue/test-utils": "2.4.1",
|
||||
"@babel/eslint-parser": "7.22.15",
|
||||
"cypress": "11.2.0",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-jest": "27.1.4",
|
||||
"eslint-plugin-jest": "27.4.3",
|
||||
"eslint-plugin-vue": "7.20.0",
|
||||
"sass": "1.56.0",
|
||||
"sass": "1.69.4",
|
||||
"sass-loader": "^10",
|
||||
"typescript": "4.8.4",
|
||||
"typescript": "5.1.6",
|
||||
"vue-cli-plugin-vuetify": "2.5.8",
|
||||
"vue-template-compiler": "2.7.10",
|
||||
"vue-template-compiler": "2.7.15",
|
||||
"vuetify-loader": "1.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
273
ui/src/components/playbookeditor/EditTask.vue
Normal file
273
ui/src/components/playbookeditor/EditTask.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<v-card data-app outlined>
|
||||
<v-card-title class="d-flex">
|
||||
<span>Edit Task</span>
|
||||
<v-spacer/>
|
||||
<v-dialog v-model="deleteDialog" max-width="400">
|
||||
<template #activator="{ on }">
|
||||
<v-btn outlined v-on="on" class="mr-2" small>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title class="headline">Delete Task</v-card-title>
|
||||
<v-card-text>Are you sure you want to delete this task?</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="blue darken-1" text @click="deleteDialog = false">Cancel</v-btn>
|
||||
<v-btn color="blue darken-1" text @click="deleteTask">Delete</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="task.name"
|
||||
label="Name"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="task.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-select
|
||||
v-model="task.type"
|
||||
:items="['input','automation','task']"
|
||||
label="Type"
|
||||
variant="underlined"/>
|
||||
|
||||
<AdvancedJSONSchemaEditor
|
||||
v-if="task.type === 'input'"
|
||||
:schema="task.schema"
|
||||
@save="task.schema = JSON.parse($event)" />
|
||||
|
||||
<v-select
|
||||
v-if="task.type === 'automation'"
|
||||
v-model="task.automation"
|
||||
:items="automations"
|
||||
item-text="id"
|
||||
item-value="id"
|
||||
label="Automation"
|
||||
variant="underlined"/>
|
||||
|
||||
<v-list v-if="task.type === 'automation'">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Payload Mapping</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.payload" :key="key" class="next-row" flat dense>
|
||||
{{ key }}:
|
||||
<v-text-field
|
||||
v-model="task.payload[key]"
|
||||
label="Expression"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn @click="deletePayloadMapping(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar class="next-row" flat dense>
|
||||
<v-text-field
|
||||
v-model="newPayloadMapping"
|
||||
label="Payload Field"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>:
|
||||
<v-text-field
|
||||
v-model="newExpression"
|
||||
label="CAQL Expression"
|
||||
variant="solo"
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addPayloadMapping"
|
||||
:disabled="!newPayloadMapping || !newExpression"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
|
||||
<v-list v-if="task.next || possibleNexts.length > 1">
|
||||
<v-subheader class="pa-0" style="padding-inline-start: 0 !important;">Next Task(s)</v-subheader>
|
||||
<v-toolbar v-for="(expr, key) in task.next" :key="key" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="task.next[key]"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<span class="font-weight-black">
|
||||
{{ playbook.tasks[key].name }}
|
||||
</span>
|
||||
<v-btn @click="deleteNext(key)" color="error" class="pa-0 ma-0" icon>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-toolbar v-if="possibleNexts.length > 0" class="next-row" flat dense>
|
||||
If
|
||||
<v-text-field
|
||||
v-model="newCondition"
|
||||
label="Condition (leave empty to always run)"
|
||||
variant="solo"
|
||||
clearable
|
||||
hide-details
|
||||
density="compact"
|
||||
bg-color="surface"
|
||||
/>
|
||||
run
|
||||
<v-select
|
||||
v-model="newNext"
|
||||
item-text="name"
|
||||
item-value="key"
|
||||
:items="possibleNexts"
|
||||
variant="solo"
|
||||
bg-color="surface"
|
||||
hide-details
|
||||
density="compact"
|
||||
/>
|
||||
<v-btn
|
||||
@click="addNext"
|
||||
:disabled="!newNext"
|
||||
class="pa-0 ma-0"
|
||||
icon>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
</v-list>
|
||||
<v-switch
|
||||
v-if="parents.length > 1"
|
||||
label="Join (Require all previous tasks to be completed)"
|
||||
v-model="task.join"
|
||||
color="primary"/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits, del, set, onMounted, computed} from "vue";
|
||||
import AdvancedJSONSchemaEditor from "@/components/AdvancedJSONSchemaEditor.vue";
|
||||
import {API} from "@/services/api";
|
||||
import {AutomationResponse} from "@/client";
|
||||
|
||||
interface Task {
|
||||
name: string;
|
||||
description: string;
|
||||
type: string;
|
||||
next: Record<string, string>;
|
||||
payload: Record<string, string>;
|
||||
join: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
value: Task;
|
||||
possibleNexts: Array<Record<string, string>>;
|
||||
parents: Array<string>;
|
||||
playbook: object;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["input", "delete", "close"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
const deleteTask = () => emit("delete");
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
const task = ref(props.value);
|
||||
watch(() => props.value, (value) => {
|
||||
task.value = value;
|
||||
});
|
||||
watch(task, (value) => {
|
||||
emit("input", value);
|
||||
});
|
||||
|
||||
// const task = computed({
|
||||
// get: () => {
|
||||
// console.log("get", props.value);
|
||||
// return props.value;
|
||||
// },
|
||||
// set: (value) => {
|
||||
// console.log("set", value);
|
||||
// emit("input", value);
|
||||
// }
|
||||
// });
|
||||
|
||||
const deleteNext = (key: string) => {
|
||||
del(task.value.next, key);
|
||||
};
|
||||
|
||||
const deletePayloadMapping = (key: string) => {
|
||||
del(task.value.payload, key);
|
||||
};
|
||||
|
||||
const newNext = ref('');
|
||||
const newCondition = ref('');
|
||||
|
||||
const newPayloadMapping = ref('');
|
||||
const newExpression = ref('');
|
||||
|
||||
watch(() => props.possibleNexts, () => {
|
||||
if (props.possibleNexts.length > 0) {
|
||||
newNext.value = props.possibleNexts[0].key;
|
||||
}
|
||||
}, {deep: true, immediate: true});
|
||||
|
||||
|
||||
const addNext = () => {
|
||||
if (task.value.next === undefined) {
|
||||
// task.value.next = {};
|
||||
set(task.value, 'next', {});
|
||||
}
|
||||
// task.value.next[newNext.value] = newCondition.value;
|
||||
set(task.value.next, newNext.value, newCondition.value);
|
||||
newNext.value = "";
|
||||
newCondition.value = "";
|
||||
};
|
||||
|
||||
const addPayloadMapping = () => {
|
||||
if (task.value.payload === undefined) {
|
||||
// task.value.payload = {};
|
||||
set(task.value, 'payload', {});
|
||||
}
|
||||
// task.value.payload[newPayloadMapping.value] = newExpression.value;
|
||||
set(task.value.payload, newPayloadMapping.value, newExpression.value);
|
||||
newPayloadMapping.value = "";
|
||||
newExpression.value = "";
|
||||
};
|
||||
|
||||
const automations = ref<Array<AutomationResponse>>([]);
|
||||
onMounted(() => {
|
||||
API.listAutomations().then((response) => {
|
||||
automations.value = response.data;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.next-row {
|
||||
padding-top: 10px;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
.next-row .v-toolbar__content {
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
80
ui/src/components/playbookeditor/NewTask.vue
Normal file
80
ui/src/components/playbookeditor/NewTask.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<v-card outlined>
|
||||
<v-card-title>
|
||||
Create a new step
|
||||
<v-spacer />
|
||||
<v-btn @click="close" outlined small>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-form v-model="valid">
|
||||
<v-text-field
|
||||
v-model="newTask.name"
|
||||
label="Name"
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
<v-textarea
|
||||
v-model="newTask.description"
|
||||
label="Description"
|
||||
auto-grow
|
||||
rows="1"
|
||||
variant="underlined"/>
|
||||
<v-text-field
|
||||
v-model="newTask.key"
|
||||
label="Key (generated automatically)"
|
||||
readonly
|
||||
disabled
|
||||
:rules="[val => (val || '').length > 0 || 'This field is required']"
|
||||
variant="underlined"/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-btn color="primary" @click="createTask" :disabled="!valid">Create</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {defineProps, ref, watch, defineEmits} from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
playbook: any;
|
||||
}>();
|
||||
|
||||
const valid = ref(false);
|
||||
const newTask = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
key: '',
|
||||
next: {},
|
||||
});
|
||||
|
||||
watch(newTask, (val) => {
|
||||
if (val.name) {
|
||||
const newKeyBase = val.name.toLowerCase().replace(/ /g, '_');
|
||||
let newKey = newKeyBase;
|
||||
if (!(newKey in props.playbook.tasks)) {
|
||||
newTask.value.key = newKey;
|
||||
} else {
|
||||
let i = 1;
|
||||
while (newKey in props.playbook.tasks) {
|
||||
newKey = newKeyBase + '_' + i;
|
||||
i++;
|
||||
}
|
||||
newTask.value.key = newKey;
|
||||
}
|
||||
}
|
||||
}, {deep: true});
|
||||
|
||||
const emit = defineEmits(["createTask", "close"]);
|
||||
|
||||
const createTask = () => {
|
||||
emit('createTask', newTask.value);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
164
ui/src/components/playbookeditor/PanZoom.vue
Normal file
164
ui/src/components/playbookeditor/PanZoom.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<div id="graphwrapper">
|
||||
<div id="gab">
|
||||
<slot name="actionbar"/>
|
||||
</div>
|
||||
<v-toolbar
|
||||
id="gtb"
|
||||
class="ma-2"
|
||||
floating
|
||||
dense
|
||||
>
|
||||
<v-btn @click.prevent.stop="reset" title="Reset" icon>
|
||||
<v-icon color="#000">mdi-image-filter-center-focus</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomIn" title="Zoom in" :disabled="isMaxZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-plus-outline</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click.prevent.stop="zoomOut" title="Zoom out" :disabled="isMinZoom" icon>
|
||||
<v-icon color="#000">mdi-magnify-minus-outline</v-icon>
|
||||
</v-btn>
|
||||
<slot name="toolbar"/>
|
||||
</v-toolbar>
|
||||
<div id="panzoom">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, ref, defineProps, defineExpose} from "vue";
|
||||
import createPanZoom from "panzoom";
|
||||
import * as panzoom from "panzoom";
|
||||
|
||||
const props = defineProps<{
|
||||
config: panzoom.PanZoomOptions
|
||||
}>();
|
||||
|
||||
const panZoom = ref<panzoom.PanZoom | null>(null);
|
||||
|
||||
const zoomLevel = ref<number>(1);
|
||||
|
||||
const minZoom = ref<number>(0.5);
|
||||
const maxZoom = ref<number>(1.5);
|
||||
|
||||
const isMaxZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value >= maxZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const isMinZoom = computed(() => {
|
||||
if (zoomLevel.value) {
|
||||
return zoomLevel.value <= minZoom.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const initialZoom = ref<number>(1);
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
initialZoom.value = props.config.initialZoom ? props.config.initialZoom : 1
|
||||
minZoom.value = props.config.initialZoom / 2
|
||||
maxZoom.value = props.config.initialZoom * 2
|
||||
|
||||
panZoom.value = createPanZoom(canvas, {
|
||||
...props.config,
|
||||
zoomDoubleClickSpeed: 1, // disable double click zoom
|
||||
autocenter: true,
|
||||
initialX: startX,
|
||||
initialY: startY,
|
||||
minZoom: minZoom.value,
|
||||
maxZoom: maxZoom.value,
|
||||
});
|
||||
|
||||
panZoom.value.on("zoom", (e: panzoom.PanZoom) => {
|
||||
zoomLevel.value = e.getTransform().scale;
|
||||
});
|
||||
|
||||
reset(false);
|
||||
});
|
||||
|
||||
const ZOOM_FACTOR = 0.255
|
||||
|
||||
const zoomIn = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom + ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
if (!panZoom.value) return
|
||||
const currentZoom = panZoom.value.getTransform().scale
|
||||
panZoom.value.smoothZoomAbs(0, 0, currentZoom - ZOOM_FACTOR)
|
||||
}
|
||||
|
||||
const reset = (smooth = true) => {
|
||||
if (!panZoom.value) return
|
||||
|
||||
const canvas = document.getElementById("panzoom")
|
||||
if (!canvas) {
|
||||
throw new Error("No element with id panzoom")
|
||||
}
|
||||
|
||||
const firstChild = canvas.firstElementChild
|
||||
if (!firstChild) {
|
||||
throw new Error("No child element")
|
||||
}
|
||||
|
||||
const startX = canvas.getBoundingClientRect().width / 2 - firstChild.getBoundingClientRect().width / 2
|
||||
const startY = canvas.getBoundingClientRect().height / 2 - firstChild.getBoundingClientRect().height / 2
|
||||
|
||||
panZoom.value.pause()
|
||||
if (smooth) {
|
||||
panZoom.value.smoothZoomAbs(0, 0, initialZoom.value)
|
||||
panZoom.value.smoothMoveTo(startX * 0.5, startY)
|
||||
} else {
|
||||
panZoom.value.zoomTo(0, 0, initialZoom.value)
|
||||
panZoom.value.moveTo(startX * 0.5, startY)
|
||||
}
|
||||
panZoom.value.resume()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomLevel,
|
||||
isMaxZoom,
|
||||
isMinZoom,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#panzoom {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
cursor: move;
|
||||
}
|
||||
</style>
|
||||
235
ui/src/components/playbookeditor/PlaybookEditor.vue
Normal file
235
ui/src/components/playbookeditor/PlaybookEditor.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<v-row style="position: relative; min-height: 600px">
|
||||
<v-col :cols="(selectedStep && selectedStep in playbook.tasks) || showNewDialog ? 8 :12">
|
||||
<v-card style="overflow: hidden" outlined>
|
||||
<v-card-text>
|
||||
<PanZoom ref="panZoomPanel" :config="panZoomConfig">
|
||||
<template #default>
|
||||
<PlaybookGraph
|
||||
v-if="playbook"
|
||||
:playbook="playbook"
|
||||
:horizontal="horizontal"
|
||||
:selected="selectedStep"
|
||||
@update:selected="showNewDialog = false; selectedStep = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #actionbar>
|
||||
<v-btn @click="showNewDialog = true; selectedStep = ''" large rounded>
|
||||
<v-icon color="#000">mdi-plus</v-icon>
|
||||
New Step
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<v-btn @click="toggleOrientation" label="Toggle Orientation" icon>
|
||||
<v-icon color="#000">mdi-format-rotate-90</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</PanZoom>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="(selectedStep && selectedStep in playbook.tasks) || showNewDialog"
|
||||
cols="4"
|
||||
>
|
||||
<EditTask
|
||||
v-if="selectedStep && selectedStep in playbook.tasks"
|
||||
:value="playbook.tasks[selectedStep]"
|
||||
@input="updateTask"
|
||||
:possibleNexts="possibleNexts"
|
||||
:parents="parents"
|
||||
:playbook="playbook"
|
||||
@delete="deleteTask"
|
||||
@close="unselectStep"/>
|
||||
<NewTask
|
||||
v-else-if="showNewDialog"
|
||||
:playbook="playbook"
|
||||
@createTask="createTask"
|
||||
@close="showNewDialog = false"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, ref, defineProps, set, defineEmits, del} from "vue";
|
||||
import PlaybookGraph from "@/components/playbookeditor/PlaybookGraph.vue";
|
||||
import PanZoom from "@/components/playbookeditor/PanZoom.vue";
|
||||
import EditTask from "@/components/playbookeditor/EditTask.vue";
|
||||
import NewTask from "@/components/playbookeditor/NewTask.vue";
|
||||
|
||||
const props = defineProps({
|
||||
'value': {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['input']);
|
||||
|
||||
const playbook = computed({
|
||||
get: () => props.value,
|
||||
set: (value) => {
|
||||
emit('input', value);
|
||||
}
|
||||
});
|
||||
|
||||
// selected step
|
||||
const selectedStep = ref("")
|
||||
const unselectStep = () => {
|
||||
selectedStep.value = "";
|
||||
}
|
||||
|
||||
const updateTask = (task: any) => {
|
||||
set(playbook.value.tasks, selectedStep.value, task);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const deleteTask = () => {
|
||||
const parents = Array<any>();
|
||||
for (const task in playbook.value.tasks) {
|
||||
if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
const children = Array<any>();
|
||||
if (playbook.value.tasks[selectedStep.value].next) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
children.push(next);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of parents) {
|
||||
del(playbook.value.tasks[parent].next, selectedStep.value);
|
||||
for (const child of children) {
|
||||
set(playbook.value.tasks[parent].next, child, playbook.value.tasks[selectedStep.value].next[child]);
|
||||
}
|
||||
}
|
||||
|
||||
del(playbook.value.tasks, selectedStep.value);
|
||||
|
||||
// for (const task in playbook.value.tasks) {
|
||||
// if (playbook.value.tasks[task].next && playbook.value.tasks[task].next[selectedStep.value]) {
|
||||
// del(playbook.value.tasks[task].next, selectedStep.value);
|
||||
// }
|
||||
// }
|
||||
// del(playbook.value.tasks, selectedStep.value);
|
||||
emit('input', playbook.value);
|
||||
}
|
||||
|
||||
const panZoomConfig = ref({})
|
||||
|
||||
const panZoomPanel = ref(null);
|
||||
|
||||
const horizontal = ref(false);
|
||||
const toggleOrientation = () => {
|
||||
horizontal.value = !horizontal.value;
|
||||
|
||||
nextTick(() => {
|
||||
panZoomPanel.value?.reset(false);
|
||||
});
|
||||
}
|
||||
|
||||
const showNewDialog = ref(false);
|
||||
const createTask = (task: any) => {
|
||||
const t = {
|
||||
name: task.name,
|
||||
description: task.description,
|
||||
type: 'task',
|
||||
next: {}
|
||||
};
|
||||
set(playbook.value.tasks, task.key, t);
|
||||
selectedStep.value = task.key;
|
||||
};
|
||||
|
||||
// edit task
|
||||
const possibleNexts = computed(() => {
|
||||
if (!selectedStep.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let nexts = Object.keys(playbook.value.tasks);
|
||||
nexts = nexts.filter((n) => n !== selectedStep.value);
|
||||
|
||||
// remove any nexts that are already in the list
|
||||
if (playbook.value.tasks[selectedStep.value] && 'next' in playbook.value.tasks[selectedStep.value]) {
|
||||
for (const next in playbook.value.tasks[selectedStep.value].next) {
|
||||
nexts = nexts.filter((n) => n !== next);
|
||||
}
|
||||
}
|
||||
|
||||
// remove parents recursively
|
||||
const parents = findAncestor(selectedStep.value);
|
||||
for (const parent of parents) {
|
||||
nexts = nexts.filter((n) => n !== parent);
|
||||
}
|
||||
|
||||
const result: Array<Record<string, string>> = [];
|
||||
for (const next of nexts) {
|
||||
if (next && playbook.value.tasks[next].name) {
|
||||
result.push({"key": next, "name": playbook.value.tasks[next].name});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const findAncestor = (step: string): Array<string> => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === step) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
parents.push(...findAncestor(task));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
};
|
||||
|
||||
const parents = computed(() => {
|
||||
const parents: Array<string> = [];
|
||||
for (const task in playbook.value.tasks) {
|
||||
for (const next in playbook.value.tasks[task].next) {
|
||||
if (next === selectedStep.value) {
|
||||
if (!parents.includes(task)) {
|
||||
parents.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parents;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#graphwrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
#gab {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:first-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
#gtb .v-toolbar__content > .v-btn:last-child {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
</style>
|
||||
511
ui/src/components/playbookeditor/PlaybookGraph.vue
Normal file
511
ui/src/components/playbookeditor/PlaybookGraph.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<svg
|
||||
class="pe-graph"
|
||||
:width="width * scale"
|
||||
:height="height * scale"
|
||||
:viewBox="`${minX - config.graphPadding} ${minY - config.graphPadding} ${maxX - minX + config.boxWidth + config.graphPadding * 2} ${maxY - minY + config.boxHeight + config.graphPadding * 2}`"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="cog"
|
||||
d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z"/>
|
||||
<path
|
||||
id="clipboard-outline"
|
||||
d="M19,3H14.82C14.4,1.84 13.3,1 12,1C10.7,1 9.6,1.84 9.18,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M12,3A1,1 0 0,1 13,4A1,1 0 0,1 12,5A1,1 0 0,1 11,4A1,1 0 0,1 12,3M7,7H17V5H19V19H5V5H7V7Z"/>
|
||||
<path
|
||||
id="keyboard"
|
||||
d="M19,10H17V8H19M19,13H17V11H19M16,10H14V8H16M16,13H14V11H16M16,17H8V15H16M7,10H5V8H7M7,13H5V11H7M8,11H10V13H8M8,8H10V10H8M11,11H13V13H11M11,8H13V10H11M20,5H4C2.89,5 2,5.89 2,7V17A2,2 0 0,0 4,19H20A2,2 0 0,0 22,17V7C22,5.89 21.1,5 20,5Z"/>
|
||||
<path
|
||||
id="star"
|
||||
d="M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z"/>
|
||||
</defs>
|
||||
<g class="pe-links">
|
||||
<path v-for="(link, index) in links" :key="index" :d="link.path"/>
|
||||
</g>
|
||||
<g class="pe-activelinks">
|
||||
<path
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:d="link.path"
|
||||
:class="{
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
/>
|
||||
</g>
|
||||
<g class="pe-nodes">
|
||||
<g
|
||||
v-for="(node, index) in positionedNodes"
|
||||
:key="index">
|
||||
<g
|
||||
v-if="node.type === 'start'"
|
||||
:transform="`translate(${props.horizontal ? node.x + config.boxWidth : node.x + (config.boxWidth / 2)}, ${node.y})`"
|
||||
class="start"
|
||||
>
|
||||
<circle
|
||||
:transform="`translate(0, ${config.boxHeight / 2})`"
|
||||
r="20"
|
||||
/>
|
||||
<g
|
||||
class="icon"
|
||||
:transform="`translate(${0 - 12}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
v-else
|
||||
:transform="`translate(${node.x}, ${node.y})`"
|
||||
:class="{
|
||||
'pe-node': true,
|
||||
'start': node.type === 'start',
|
||||
'hovered': node.id === hoverNode,
|
||||
'selected': node.id === selectedNode,
|
||||
'unhovered': hoverNode && !selectedNode && node.id !== hoverNode,
|
||||
'unselected': selectedNode && node.id !== selectedNode,
|
||||
}"
|
||||
@mouseover="hoverNode = node.id"
|
||||
@mouseout="hoverNode = null"
|
||||
@click="selectedNode === node.id ? selectedNode = null : selectedNode = node.id"
|
||||
>
|
||||
<rect
|
||||
class="pe-box"
|
||||
:width="config.boxWidth"
|
||||
:height="config.boxHeight"
|
||||
:rx="config.boxRadius"
|
||||
:ry="config.boxRadius"
|
||||
/>
|
||||
<g
|
||||
class="pe-icon"
|
||||
:transform="`translate(10, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use v-if="node.type === 'automation'" href="#cog"/>
|
||||
<use v-else-if="node.type === 'start'" href="#star"/>
|
||||
<use v-else-if="node.type === 'input'" href="#keyboard"/>
|
||||
<use v-else href="#clipboard-outline"/>
|
||||
</g>
|
||||
<text
|
||||
class="pe-text"
|
||||
:x="config.boxWidth / 2"
|
||||
:y="config.boxHeight / 2"
|
||||
>
|
||||
{{ node.label ? node.label : node.id }}
|
||||
</text>
|
||||
<g
|
||||
class="add"
|
||||
:transform="`translate(${config.boxWidth - 34}, ${(config.boxHeight - 24) / 2})`"
|
||||
>
|
||||
<use href="#star" fill="none"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g class="pe-connectors">
|
||||
<g
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
:class="{
|
||||
'pe-connector': true,
|
||||
'hovered': !selectedNode && (link.source === hoverNode || link.target === hoverNode),
|
||||
'selected': link.source === selectedNode || link.target === selectedNode
|
||||
}"
|
||||
>
|
||||
<circle
|
||||
v-if="link.source !== 'start'"
|
||||
:cx="link.start.x"
|
||||
:cy="link.start.y"
|
||||
:r="4"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="7"/>
|
||||
<circle
|
||||
:cx="link.end.x"
|
||||
:cy="link.end.y"
|
||||
:r="5"
|
||||
fill="white"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as d3 from "d3";
|
||||
import {digl} from "@crinkles/digl";
|
||||
import {Edge as DiglEdge, Node as DiglNode, Position, Rank} from "@crinkles/digl/dist/types";
|
||||
import {computed, defineEmits, ref, Ref, defineProps, ComputedRef} from "vue";
|
||||
|
||||
interface Config {
|
||||
graphPadding: number;
|
||||
boxWidth: number;
|
||||
boxHeight: number;
|
||||
boxMarginX: number;
|
||||
boxMarginY: number;
|
||||
boxRadius: number;
|
||||
lineDistance: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
playbook: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
horizontal: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 1
|
||||
}
|
||||
})
|
||||
|
||||
const config = ref({
|
||||
graphPadding: 10,
|
||||
boxWidth: 220,
|
||||
boxHeight: 40,
|
||||
boxMarginX: 60,
|
||||
boxMarginY: 70,
|
||||
boxRadius: 20,
|
||||
lineDistance: 0,
|
||||
});
|
||||
|
||||
interface Edge extends DiglEdge {
|
||||
label?: { text: string, x: number, y: number };
|
||||
}
|
||||
|
||||
interface Node extends DiglNode {
|
||||
type: 'automation' | 'input' | 'task' | 'start';
|
||||
}
|
||||
|
||||
const edges = computed(() => {
|
||||
const edges: Array<Edge> = [];
|
||||
|
||||
for (const key in props.playbook.tasks) {
|
||||
for (const next in props.playbook.tasks[key].next) {
|
||||
edges.push({
|
||||
source: key,
|
||||
target: next,
|
||||
label: props.playbook.tasks[key].next[next]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rootNodes = nodes.value.filter(node => edges.every(edge => edge.target !== node.id));
|
||||
for (const node of rootNodes) {
|
||||
if (node.id !== 'start') {
|
||||
edges.push({
|
||||
source: 'start',
|
||||
target: node.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
});
|
||||
|
||||
const nodes: ComputedRef<Array<Node>> = computed(() => {
|
||||
const nodes = [{
|
||||
id: "start",
|
||||
label: "Start",
|
||||
type: "start" as 'automation' | 'input' | 'task' | 'start',
|
||||
}];
|
||||
for (const key in props.playbook.tasks) {
|
||||
nodes.push({
|
||||
id: key,
|
||||
label: props.playbook.tasks[key].name,
|
||||
type: props.playbook.tasks[key].type
|
||||
});
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
|
||||
const hoverNode: Ref<string | null> = ref(null);
|
||||
|
||||
const emits = defineEmits(['update:selected']);
|
||||
const selectedNode = computed({
|
||||
get: () => props.selected,
|
||||
set: (newVal) => {
|
||||
emits('update:selected', newVal);
|
||||
}
|
||||
});
|
||||
|
||||
const rankses: Ref<Array<Array<Rank>>> = computed(() => {
|
||||
return digl(edges.value);
|
||||
});
|
||||
|
||||
const ranks = computed(() => {
|
||||
return rankses.value.length > 0 ? rankses.value[0] : [];
|
||||
});
|
||||
|
||||
const positionedNodes = computed(() => {
|
||||
return positioning(config.value, props.horizontal, nodes.value, ranks.value);
|
||||
});
|
||||
|
||||
const width = computed(() => {
|
||||
return maxX.value - minX.value + config.value.graphPadding * 2 + config.value.boxWidth;
|
||||
});
|
||||
|
||||
const height = computed(() => {
|
||||
return maxY.value - minY.value + config.value.graphPadding * 2 + config.value.boxHeight;
|
||||
});
|
||||
|
||||
const minX = computed(() => {
|
||||
if (props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.x)) + config.value.boxWidth - 22;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const minY = computed(() => {
|
||||
if (!props.horizontal) {
|
||||
return Math.min(...positionedNodes.value.map(node => node.y)) + config.value.boxHeight - 30;
|
||||
}
|
||||
return Math.min(...positionedNodes.value.map(node => node.y));
|
||||
});
|
||||
|
||||
const maxX = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.x));
|
||||
});
|
||||
|
||||
const maxY = computed(() => {
|
||||
return Math.max(...positionedNodes.value.map(node => node.y)) + 50;
|
||||
});
|
||||
|
||||
const links = computed(() => {
|
||||
return edges.value.map(edge => {
|
||||
const source = positionedNodes.value.find(node => node.id === edge.source);
|
||||
const target = positionedNodes.value.find(node => node.id === edge.target);
|
||||
|
||||
if (!source || !target) return;
|
||||
|
||||
// index within rank
|
||||
const sourceIndex = ranks.value.find(rank => rank.includes(edge.source))?.indexOf(edge.source);
|
||||
if (sourceIndex === undefined) {
|
||||
throw new Error(`sourceIndex is undefined for ${edge.source}`);
|
||||
}
|
||||
|
||||
const path = props.horizontal ?
|
||||
horizontalConnectionLine(source, target, sourceIndex, config.value) :
|
||||
verticalConnectionLine(source, target, sourceIndex, config.value);
|
||||
|
||||
const start = props.horizontal ?
|
||||
{x: source.x + config.value.boxWidth, y: source.y + config.value.boxHeight / 2} :
|
||||
{x: source.x + config.value.boxWidth / 2, y: source.y + config.value.boxHeight};
|
||||
|
||||
const end = props.horizontal ?
|
||||
{x: target.x, y: target.y + config.value.boxHeight / 2} :
|
||||
{x: target.x + config.value.boxWidth / 2, y: target.y};
|
||||
|
||||
return {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
path: path.toString(),
|
||||
start: start,
|
||||
end: end,
|
||||
};
|
||||
}).filter(link => link);
|
||||
});
|
||||
|
||||
interface PositionedNode extends Node, Position {
|
||||
}
|
||||
|
||||
function positioning(
|
||||
config: Config,
|
||||
horizontal: boolean,
|
||||
nodes: Node[],
|
||||
ranks: Rank[]
|
||||
): PositionedNode[] {
|
||||
const _nodes: PositionedNode[] = [];
|
||||
const _h = horizontal;
|
||||
|
||||
ranks.forEach((rank, i) => {
|
||||
const xStart = _h
|
||||
? (config.boxWidth + config.boxMarginX) * i
|
||||
: -0.5 * (rank.length - 1) * (config.boxWidth + config.boxMarginX);
|
||||
const yStart = _h
|
||||
? -0.5 * (rank.length - 1) * (config.boxHeight + config.boxMarginY)
|
||||
: (config.boxHeight + config.boxMarginY) * i;
|
||||
|
||||
rank.forEach((nodeId, nIndex) => {
|
||||
const _node: Node = nodes.find((n) => n.id == nodeId) as Node;
|
||||
if (!_node) return;
|
||||
const x = _h ? xStart : xStart + (config.boxWidth + config.boxMarginX) * nIndex;
|
||||
const y = _h ? yStart + (config.boxHeight + config.boxMarginY) * nIndex : yStart;
|
||||
_nodes.push({..._node, x, y});
|
||||
});
|
||||
});
|
||||
|
||||
return _nodes;
|
||||
}
|
||||
|
||||
function verticalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceBottomCenter = {
|
||||
x: source.x + config.boxWidth / 2,
|
||||
y: source.y + config.boxHeight,
|
||||
};
|
||||
|
||||
const targetTopCenter = {
|
||||
x: target.x + config.boxWidth / 2,
|
||||
y: target.y,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceBottomCenter.x, sourceBottomCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginY / 2;
|
||||
|
||||
if (sourceBottomCenter.x == targetTopCenter.x) {
|
||||
path.lineTo(targetTopCenter.x, targetTopCenter.y);
|
||||
} else if (sourceBottomCenter.x < targetTopCenter.x) {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x + lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x - lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.y !== source.y + config.boxHeight + config.boxMarginY) {
|
||||
path.lineTo(sourceBottomCenter.x, target.y - config.boxMarginY);
|
||||
}
|
||||
sourceBottomCenter.y = target.y - config.boxMarginY;
|
||||
path.quadraticCurveTo(
|
||||
sourceBottomCenter.x, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
sourceBottomCenter.x - lineCurve, sourceBottomCenter.y + lineCurve + sourceIndex * config.lineDistance,
|
||||
);
|
||||
path.lineTo(targetTopCenter.x + lineCurve, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance);
|
||||
path.quadraticCurveTo(
|
||||
targetTopCenter.x, targetTopCenter.y - lineCurve + sourceIndex * config.lineDistance,
|
||||
targetTopCenter.x, targetTopCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function horizontalConnectionLine(source: PositionedNode, target: PositionedNode, sourceIndex: number, config: Config) {
|
||||
const sourceRightCenter = {
|
||||
x: source.x + config.boxWidth,
|
||||
y: source.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const targetLeftCenter = {
|
||||
x: target.x,
|
||||
y: target.y + config.boxHeight / 2,
|
||||
};
|
||||
|
||||
const path = d3.path();
|
||||
path.moveTo(sourceRightCenter.x, sourceRightCenter.y);
|
||||
|
||||
const lineCurve = config.boxMarginX / 2;
|
||||
|
||||
if (sourceRightCenter.y == targetLeftCenter.y) {
|
||||
path.lineTo(targetLeftCenter.x, targetLeftCenter.y);
|
||||
} else if (sourceRightCenter.y < targetLeftCenter.y) {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y + lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y - lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
} else {
|
||||
if (target.x !== source.x + config.boxWidth + config.boxMarginX) {
|
||||
path.lineTo(target.x - config.boxMarginX, sourceRightCenter.y);
|
||||
}
|
||||
sourceRightCenter.x = target.x - config.boxMarginX;
|
||||
path.quadraticCurveTo(
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y,
|
||||
sourceRightCenter.x + lineCurve + sourceIndex * config.lineDistance, sourceRightCenter.y - lineCurve,
|
||||
);
|
||||
path.lineTo(targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y + lineCurve);
|
||||
path.quadraticCurveTo(
|
||||
targetLeftCenter.x - lineCurve + sourceIndex * config.lineDistance, targetLeftCenter.y,
|
||||
targetLeftCenter.x, targetLeftCenter.y,
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
svg .pe-node.unhovered {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
svg .pe-node .pe-box {
|
||||
fill: white;
|
||||
stroke: #4C566A;
|
||||
stroke-width: 1px;
|
||||
filter: drop-shadow(0 1.5px 1.5px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-node.selected .pe-box {
|
||||
stroke: #88C0D0;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
svg .pe-node text {
|
||||
text-anchor: middle;
|
||||
dominant-baseline: middle;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box {
|
||||
filter: drop-shadow(0 0 0.1rem #333);
|
||||
}
|
||||
|
||||
svg .pe-node:hover .pe-box,
|
||||
svg .pe-node:hover text {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
svg .pe-node:hover .add use {
|
||||
/* fill: red !important; */
|
||||
}
|
||||
|
||||
svg .pe-links path {
|
||||
stroke: black;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, .15));
|
||||
}
|
||||
|
||||
svg .pe-activelinks path {
|
||||
stroke: none;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
svg .pe-activelinks path.selected,
|
||||
svg .pe-activelinks path.hovered {
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke: #88C0D0;
|
||||
filter: drop-shadow(0 0 0.1rem #88C0D0);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fill-height">
|
||||
<div class="network-graph fill-height">
|
||||
<v-card
|
||||
v-if="selected !== undefined"
|
||||
class="mt-3 ml-3 px-0"
|
||||
@@ -184,33 +184,33 @@ export default Vue.extend({
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.node,
|
||||
.node.selected {
|
||||
.network-graph .node,
|
||||
.network-graph .node.selected {
|
||||
stroke: #388E3C !important;
|
||||
}
|
||||
.node.event {
|
||||
.network-graph .node.event {
|
||||
stroke: #D32F2F !important;
|
||||
}
|
||||
.node.center {
|
||||
.network-graph .node.center {
|
||||
stroke: #FFEB3B !important;
|
||||
fill: #FFEB3B !important;
|
||||
}
|
||||
|
||||
.theme--dark .node-label,
|
||||
.theme--dark .node-label.event {
|
||||
.theme--dark .network-graph .node-label,
|
||||
.theme--dark .network-graph .node-label.event {
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
|
||||
.node-label,
|
||||
.node-label.event {
|
||||
.network-graph .node-label,
|
||||
.network-graph .node-label.event {
|
||||
fill: #000000 !important;
|
||||
}
|
||||
|
||||
.link {
|
||||
.network-graph .link {
|
||||
stroke: #424242 !important;
|
||||
}
|
||||
.link.selected,
|
||||
.link:hover,.node:hover{
|
||||
.network-graph .link.selected,
|
||||
.network-graph .link:hover,.node:hover{
|
||||
stroke: #FFEB3B !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,25 +19,35 @@
|
||||
<v-alert v-else-if="error" color="warning">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
<div v-else class="px-4 overflow-scroll">
|
||||
<vue-pipeline
|
||||
v-if="pipelineData"
|
||||
ref="pipeline"
|
||||
:x="50"
|
||||
:y="55"
|
||||
:data="pipelineData"
|
||||
:showArrow="true"
|
||||
:ystep="70"
|
||||
:xstep="100"
|
||||
lineStyle="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
|
||||
Playbook
|
||||
</v-subheader>
|
||||
<div class="flex-grow-1 flex-shrink-1 overflow-scroll">
|
||||
<Editor v-model="playbook.yaml" @input="updatePipeline" lang="yaml" :readonly="readonly"></Editor>
|
||||
<v-tabs
|
||||
v-model="tab"
|
||||
background-color="transparent"
|
||||
>
|
||||
<v-tab>Graph (experimental)</v-tab>
|
||||
<v-tab>YAML</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-items v-model="tab" style="background: transparent">
|
||||
<v-tab-item>
|
||||
<v-text-field
|
||||
v-model="playbook.name"
|
||||
label="Name"
|
||||
outlined
|
||||
dense
|
||||
:readonly="readonly"
|
||||
class="mt-4"
|
||||
/>
|
||||
<PlaybookEditor
|
||||
v-if="playbookJSON"
|
||||
v-model="playbookJSON" />
|
||||
</v-tab-item>
|
||||
<v-tab-item>
|
||||
<v-card class="py-2">
|
||||
<Editor v-model="playbookYAML" lang="yaml" :readonly="readonly"></Editor>
|
||||
</v-card>
|
||||
</v-tab-item>
|
||||
</v-tabs-items>
|
||||
</div>
|
||||
|
||||
<v-row v-if="!readonly" class="px-3 my-6 flex-grow-0 flex-shrink-0">
|
||||
@@ -62,6 +72,7 @@ import Editor from "../components/Editor.vue";
|
||||
import {alg, Graph} from "graphlib";
|
||||
import yaml from 'yaml';
|
||||
import Ajv from "ajv";
|
||||
import PlaybookEditor from "@/components/playbookeditor/PlaybookEditor.vue";
|
||||
|
||||
const playbookSchema = {
|
||||
type: "object",
|
||||
@@ -83,7 +94,7 @@ const taskSchema = {
|
||||
properties: {
|
||||
automation: { type: "string" },
|
||||
join: { type: "boolean" },
|
||||
msg: { type: "object", additionalProperties: { type: "string" } },
|
||||
payload: { type: "object", additionalProperties: { type: "string" } },
|
||||
name: { type: "string" },
|
||||
next: {
|
||||
type: "object",
|
||||
@@ -100,8 +111,10 @@ interface State {
|
||||
playbook?: PlaybookTemplate;
|
||||
g: Record<string, any>;
|
||||
selected: any;
|
||||
pipelineData: any;
|
||||
error: string;
|
||||
tab: number;
|
||||
playbookYAML: string;
|
||||
playbookJSON: any;
|
||||
}
|
||||
|
||||
interface TaskWithID {
|
||||
@@ -129,8 +142,8 @@ const inityaml = "name: VirusTotal hash check\n" +
|
||||
" name: Hash the word\n" +
|
||||
" type: automation\n" +
|
||||
" automation: hash.sha1\n" +
|
||||
" msg:\n" +
|
||||
" payload: \"playbook.tasks['input'].data['word']\"\n" +
|
||||
" payload:\n" +
|
||||
" default: \"playbook.tasks['input'].data['word']\"\n" +
|
||||
" next:\n" +
|
||||
" end:\n" +
|
||||
"\n" +
|
||||
@@ -140,17 +153,26 @@ const inityaml = "name: VirusTotal hash check\n" +
|
||||
|
||||
export default Vue.extend({
|
||||
name: "Playbook",
|
||||
components: { Editor },
|
||||
components: { Editor, PlaybookEditor },
|
||||
data: (): State => ({
|
||||
playbook: undefined,
|
||||
g: {},
|
||||
selected: undefined,
|
||||
pipelineData: undefined,
|
||||
error: "",
|
||||
tab: 1,
|
||||
playbookJSON: undefined,
|
||||
playbookYAML: inityaml
|
||||
}),
|
||||
watch: {
|
||||
'$route': function () {
|
||||
this.loadPlaybook();
|
||||
},
|
||||
tab: function (value) {
|
||||
if (value === 0) {
|
||||
this.playbookJSON = yaml.parse(this.playbookYAML);
|
||||
} else {
|
||||
this.playbookYAML = yaml.stringify(this.playbookJSON);
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -198,77 +220,26 @@ export default Vue.extend({
|
||||
}
|
||||
return tasks;
|
||||
},
|
||||
updatePipeline: function () {
|
||||
if (this.playbook) {
|
||||
this.pipeline(this.playbook.yaml);
|
||||
}
|
||||
},
|
||||
pipeline: function(playbookYAML: string) {
|
||||
try {
|
||||
let playbook = yaml.parse(playbookYAML);
|
||||
|
||||
this.error = "";
|
||||
|
||||
let g = new Graph();
|
||||
|
||||
for (const stepKey in playbook.tasks) {
|
||||
g.setNode(stepKey);
|
||||
}
|
||||
|
||||
this.lodash.forEach(playbook.tasks, (task: Task, stepKey: string) => {
|
||||
if ("next" in task) {
|
||||
this.lodash.forEach(task.next, (condition, nextKey) => {
|
||||
g.setEdge(stepKey, nextKey);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let tasks = this.tasks(g, playbook);
|
||||
let elements = [] as Array<any>;
|
||||
this.lodash.forEach(tasks, task => {
|
||||
elements.push({
|
||||
id: task.id,
|
||||
name: task.task.name,
|
||||
next: [],
|
||||
status: "unknown"
|
||||
});
|
||||
});
|
||||
|
||||
this.lodash.forEach(tasks, (task: TaskWithID) => {
|
||||
if ("next" in task.task) {
|
||||
this.lodash.forEach(task.task.next, (condition, nextKey) => {
|
||||
let nextID = this.lodash.findIndex(elements, ["id", nextKey]);
|
||||
let stepID = this.lodash.findIndex(elements, ["id", task.id]);
|
||||
if (nextID !== -1) {
|
||||
// TODO: invalid schema
|
||||
elements[stepID].next.push({index: nextID});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.pipelineData = undefined;
|
||||
this.$nextTick(() => {
|
||||
this.pipelineData = this.lodash.values(elements);
|
||||
})
|
||||
}
|
||||
catch (e: unknown) {
|
||||
console.log(e);
|
||||
this.error = this.lodash.toString(e);
|
||||
}
|
||||
},
|
||||
save() {
|
||||
if (this.playbook === undefined) {
|
||||
return;
|
||||
}
|
||||
let playbook = this.playbook;
|
||||
if (this.tab === 0) {
|
||||
let jsonData = this.playbookJSON;
|
||||
jsonData["name"] = playbook.name;
|
||||
playbook.yaml = yaml.stringify(jsonData);
|
||||
} else {
|
||||
playbook.yaml = this.playbookYAML;
|
||||
}
|
||||
|
||||
if (this.$route.params.id == 'new') {
|
||||
let playbook = this.playbook;
|
||||
// playbook.id = kebabCase(playbook.name);
|
||||
API.createPlaybook(playbook).then(() => {
|
||||
this.$store.dispatch("alertSuccess", { name: "Playbook created" });
|
||||
});
|
||||
} else {
|
||||
API.updatePlaybook(this.$route.params.id, this.playbook).then(() => {
|
||||
API.updatePlaybook(this.$route.params.id, playbook).then(() => {
|
||||
this.$store.dispatch("alertSuccess", { name: "Playbook saved" });
|
||||
});
|
||||
}
|
||||
@@ -279,9 +250,13 @@ export default Vue.extend({
|
||||
}
|
||||
if (this.$route.params.id == 'new') {
|
||||
this.playbook = { name: "MyPlaybook", yaml: inityaml }
|
||||
this.playbookJSON = yaml.parse(this.playbook.yaml);
|
||||
this.playbookYAML = this.playbook.yaml;
|
||||
} else {
|
||||
API.getPlaybook(this.$route.params.id).then((response) => {
|
||||
this.playbook = response.data;
|
||||
this.playbookJSON = yaml.parse(this.playbook.yaml);
|
||||
this.playbookYAML = this.playbook.yaml;
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -290,7 +265,7 @@ export default Vue.extend({
|
||||
return this.lodash.includes(this.$store.state.settings.roles, s);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.loadPlaybook();
|
||||
|
||||
@@ -295,19 +295,13 @@
|
||||
<v-icon small>mdi-close-circle</v-icon>
|
||||
</v-btn>
|
||||
</v-card-subtitle>
|
||||
<div style="overflow-x: scroll">
|
||||
<vue-pipeline
|
||||
v-if="showPipelines"
|
||||
ref="pipeline"
|
||||
:x="50"
|
||||
:y="55"
|
||||
:data="pipeline(playbook)"
|
||||
:showArrow="true"
|
||||
:ystep="70"
|
||||
:xstep="90"
|
||||
lineStyle="default"
|
||||
@select="select"
|
||||
class="mx-4"
|
||||
<div style="overflow-x: scroll; text-align: center">
|
||||
<PlaybookGraph
|
||||
v-if="playbook"
|
||||
:playbook="playbook"
|
||||
horizontal
|
||||
:scale="0.3"
|
||||
style="margin: 0 auto"
|
||||
/>
|
||||
</div>
|
||||
<v-list dense color="cards" class="tasks py-0">
|
||||
@@ -319,7 +313,6 @@
|
||||
link
|
||||
@click="selectTask(taskwithid, playbookid)"
|
||||
>
|
||||
<!--template v-slot:activator-->
|
||||
<v-list-item-icon>
|
||||
<v-icon
|
||||
:class="{
|
||||
@@ -863,6 +856,8 @@ import {DateTime} from "luxon";
|
||||
import VueMarkdown from "vue-markdown";
|
||||
import JSONHTML from "../components/JSONHTML.vue";
|
||||
import TicketNew from "@/views/TicketNew.vue";
|
||||
import yaml from "yaml";
|
||||
import PlaybookGraph from "@/components/playbookeditor/PlaybookGraph.vue";
|
||||
|
||||
interface State {
|
||||
valid: boolean;
|
||||
@@ -929,6 +924,7 @@ interface TaskWithID {
|
||||
export default Vue.extend({
|
||||
name: "Ticket",
|
||||
components: {
|
||||
PlaybookGraph,
|
||||
TicketNew,
|
||||
Dashboard,
|
||||
ArtifactSnippet,
|
||||
@@ -999,6 +995,9 @@ export default Vue.extend({
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
yaml() {
|
||||
return yaml
|
||||
},
|
||||
schema: function() {
|
||||
if (this.ticket !== undefined && this.ticket.schema !== undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
@@ -99,6 +99,7 @@ export default Vue.extend({
|
||||
watch: {
|
||||
'$route': function () {
|
||||
this.loadUser();
|
||||
this.newUserResponse = undefined;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
1168
ui/yarn.lock
1168
ui/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user