Compare commits
310 Commits
v1.56.0
...
rf/debugKa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece4573076 | ||
|
|
833d0df965 | ||
|
|
1e7b7cc8d6 | ||
|
|
1de8eefe96 | ||
|
|
51a710ffa0 | ||
|
|
ad0c836129 | ||
|
|
be6707b461 | ||
|
|
c227ba2794 | ||
|
|
d60a770eb7 | ||
|
|
30d83a942c | ||
|
|
98bf9355dc | ||
|
|
a3928101af | ||
|
|
6e77757939 | ||
|
|
66c5d6e400 | ||
|
|
b0c184767c | ||
|
|
1165cf6e24 | ||
|
|
a9640107f3 | ||
|
|
73bc13bb7d | ||
|
|
e98966283d | ||
|
|
8ad2344ec6 | ||
|
|
b77c239f30 | ||
|
|
15cb83936b | ||
|
|
e2157ee1c0 | ||
|
|
bdfd50b970 | ||
|
|
4debfd68dc | ||
|
|
410032060a | ||
|
|
22b8fed9d9 | ||
|
|
0172587b12 | ||
|
|
f05f9e4edb | ||
|
|
24b1fa0ae3 | ||
|
|
fedb9fe74c | ||
|
|
5408da829c | ||
|
|
448e45b7e4 | ||
|
|
156f257619 | ||
|
|
7f40373fd6 | ||
|
|
f2ec03ba1b | ||
|
|
9a79c5537e | ||
|
|
cf156b0591 | ||
|
|
832ddabdf2 | ||
|
|
22080312f0 | ||
|
|
165095376d | ||
|
|
2a816b9e7b | ||
|
|
a7d03f4e25 | ||
|
|
5e18e580f7 | ||
|
|
9b7906e43d | ||
|
|
2143bbdc94 | ||
|
|
51a8810aa0 | ||
|
|
8f906026b3 | ||
|
|
aa6de3bb57 | ||
|
|
71a443e3c5 | ||
|
|
d1526d9c72 | ||
|
|
2456e35908 | ||
|
|
f359015d89 | ||
|
|
b8155f4d27 | ||
|
|
2ce903d7e7 | ||
|
|
d4765dee16 | ||
|
|
a78486d7e0 | ||
|
|
e969af9e44 | ||
|
|
e371fda1ce | ||
|
|
ee14bed3d9 | ||
|
|
3d873ed51c | ||
|
|
d85f550611 | ||
|
|
6c9daf7002 | ||
|
|
6a88e8c4f4 | ||
|
|
99246382ee | ||
|
|
73e1018f65 | ||
|
|
f1157e3be2 | ||
|
|
bd79938bed | ||
|
|
173093a403 | ||
|
|
ed61df6d8a | ||
|
|
24feb64dfe | ||
|
|
b3eaf86e40 | ||
|
|
4dfdf374af | ||
|
|
45191621d2 | ||
|
|
4d60be738e | ||
|
|
eef3bab6e4 | ||
|
|
14ebb6dc32 | ||
|
|
9e602691b6 | ||
|
|
02045001d2 | ||
|
|
3c483f5337 | ||
|
|
bcb5c3697d | ||
|
|
0e50579089 | ||
|
|
59c4cc2058 | ||
|
|
462adbe42f | ||
|
|
d708b6faa5 | ||
|
|
40d566ab8e | ||
|
|
c5d712c977 | ||
|
|
631a3da17f | ||
|
|
ff5145d7bd | ||
|
|
d1131c1a16 | ||
|
|
9b89df9ecf | ||
|
|
acff87a09f | ||
|
|
a182ce2d2c | ||
|
|
0954b5a4f2 | ||
|
|
d297223fa5 | ||
|
|
9bccb90c7a | ||
|
|
6403259e2a | ||
|
|
fa367f4128 | ||
|
|
164f281dd0 | ||
|
|
760cb31bae | ||
|
|
4f869811fe | ||
|
|
0f26a3b111 | ||
|
|
fadae2d2b7 | ||
|
|
12b00a808d | ||
|
|
f0435f5f81 | ||
|
|
f018d025a2 | ||
|
|
5d720e96e6 | ||
|
|
e1b77b0d5f | ||
|
|
2db6424f9d | ||
|
|
9a58145f72 | ||
|
|
51c1ae937c | ||
|
|
26bd9ac9aa | ||
|
|
3ba28b8156 | ||
|
|
3f318b4ac4 | ||
|
|
452c6de7e3 | ||
|
|
b1a86db58b | ||
|
|
b29eccf4bb | ||
|
|
ba28f6c62a | ||
|
|
279f671dcf | ||
|
|
954cc1bb95 | ||
|
|
776d4c9ca1 | ||
|
|
6949516e63 | ||
|
|
f6c13b0324 | ||
|
|
d811675882 | ||
|
|
735aee952a | ||
|
|
9432f4c1fa | ||
|
|
5bf5a4f86e | ||
|
|
60ef8f569b | ||
|
|
7bc59d9d26 | ||
|
|
bd4c7b7957 | ||
|
|
172b5dba8f | ||
|
|
b478ba8c45 | ||
|
|
71708b7566 | ||
|
|
c50c7406f2 | ||
|
|
00357261d5 | ||
|
|
c121908716 | ||
|
|
2191e85231 | ||
|
|
a85a964819 | ||
|
|
2ee418b6bf | ||
|
|
8f72c32fbe | ||
|
|
1cc5059179 | ||
|
|
9280a5132e | ||
|
|
a626aa6175 | ||
|
|
339742ca77 | ||
|
|
43e069eb96 | ||
|
|
8e1fecfda0 | ||
|
|
3e1c9463d6 | ||
|
|
1337811438 | ||
|
|
a028772bf4 | ||
|
|
0419e7e1c9 | ||
|
|
bdfc38d954 | ||
|
|
dbd632375c | ||
|
|
2dfbf84d62 | ||
|
|
cb25f88300 | ||
|
|
3cbe5d1f66 | ||
|
|
e93c511bb5 | ||
|
|
6bc0320bff | ||
|
|
3418a07c45 | ||
|
|
c0df9a5e20 | ||
|
|
b4e9468461 | ||
|
|
a14623feaa | ||
|
|
342ea5609b | ||
|
|
8e72007368 | ||
|
|
48d87ab12c | ||
|
|
4e0c2d06fc | ||
|
|
cbddd6b98b | ||
|
|
35297d4b52 | ||
|
|
2c717e00e5 | ||
|
|
3bc1e6248b | ||
|
|
2e2067fe13 | ||
|
|
367903e359 | ||
|
|
37f2de6196 | ||
|
|
5505d0a7c9 | ||
|
|
c61f9d4d66 | ||
|
|
18946918c3 | ||
|
|
e676c18b5f | ||
|
|
a9cce15b0c | ||
|
|
eb49dd8216 | ||
|
|
e2f2db2d7f | ||
|
|
6e9c61a7fb | ||
|
|
4d5acf6f52 | ||
|
|
55e2bf9fce | ||
|
|
daf8e2e8be | ||
|
|
b5b13518da | ||
|
|
d3e2cf0779 | ||
|
|
5347cd46a9 | ||
|
|
32d90d4f18 | ||
|
|
1076c4ffc1 | ||
|
|
a84eb9b1f7 | ||
|
|
9540e6cec3 | ||
|
|
54ef5a6d87 | ||
|
|
6edf9b9946 | ||
|
|
0bcdcaedcf | ||
|
|
50453ca690 | ||
|
|
b5edb7ed98 | ||
|
|
099c4fd10d | ||
|
|
3fba91084d | ||
|
|
aaac41761c | ||
|
|
1967413ad4 | ||
|
|
7aa4751531 | ||
|
|
37a8789741 | ||
|
|
3e9a6ebc5a | ||
|
|
93ce9c42fe | ||
|
|
7437dc00aa | ||
|
|
47b498da80 | ||
|
|
1c75bf7bef | ||
|
|
ad1ffdb613 | ||
|
|
5204e4a75d | ||
|
|
aaaece99e7 | ||
|
|
03da654714 | ||
|
|
43a7d9d7b4 | ||
|
|
68a09176d3 | ||
|
|
8958118bef | ||
|
|
82b30f4da6 | ||
|
|
2acb7d1774 | ||
|
|
976c3d49dd | ||
|
|
fa522da565 | ||
|
|
8517c25153 | ||
|
|
c2a733dc42 | ||
|
|
d8feaf341a | ||
|
|
4c25db3891 | ||
|
|
b0c7d9af27 | ||
|
|
76a860fe53 | ||
|
|
5de7dc9248 | ||
|
|
45acb89f87 | ||
|
|
34e3331919 | ||
|
|
39e7fc6c28 | ||
|
|
39e2bf39e9 | ||
|
|
4d6a568820 | ||
|
|
a34ae8b2ae | ||
|
|
c7e60843d4 | ||
|
|
160dfa3121 | ||
|
|
2c8e98a9c7 | ||
|
|
334ec1a084 | ||
|
|
ab0aeb0df8 | ||
|
|
21f98e29ea | ||
|
|
be14aab9b1 | ||
|
|
e24dc6de4f | ||
|
|
4bc90e8409 | ||
|
|
4822c863f8 | ||
|
|
8f3649e940 | ||
|
|
4135b6a4d5 | ||
|
|
5c8bfb4d39 | ||
|
|
72aeba121c | ||
|
|
9fa2f96b31 | ||
|
|
94e929ddc4 | ||
|
|
5e522c5720 | ||
|
|
4df19ab873 | ||
|
|
eb359b1a3d | ||
|
|
58a5889dc7 | ||
|
|
e9488f1b66 | ||
|
|
a1fec7c4b7 | ||
|
|
54a5e73117 | ||
|
|
0812f6efd8 | ||
|
|
3192ce5d21 | ||
|
|
e8fc94d7ef | ||
|
|
aedd02bc2a | ||
|
|
d108c3ed07 | ||
|
|
3296debfe7 | ||
|
|
2fd730f8d2 | ||
|
|
a42f802cfd | ||
|
|
adc84f06d9 | ||
|
|
9d30e5fa57 | ||
|
|
6e271d3d61 | ||
|
|
543490b103 | ||
|
|
f4c5de3d2b | ||
|
|
647b57b24e | ||
|
|
7d8cbfe115 | ||
|
|
0f1b19c7d3 | ||
|
|
fb395655e6 | ||
|
|
faa046a3fd | ||
|
|
17a5b3ffdb | ||
|
|
47fa61ba30 | ||
|
|
efe3973c29 | ||
|
|
26ea59b3e9 | ||
|
|
681e2e824a | ||
|
|
2d4a83bf90 | ||
|
|
8031a7b5ec | ||
|
|
b3adb7dad8 | ||
|
|
6d40f16695 | ||
|
|
a29357eb15 | ||
|
|
b72a3babd8 | ||
|
|
4e1e80e673 | ||
|
|
8c268ff34b | ||
|
|
1b0bb22da7 | ||
|
|
b24f3472d1 | ||
|
|
50c909d009 | ||
|
|
a82ed3f744 | ||
|
|
99fd968eba | ||
|
|
0e97b98d21 | ||
|
|
37db806723 | ||
|
|
b805e28adf | ||
|
|
972b2affae | ||
|
|
bf86fe5041 | ||
|
|
e7cdd72a26 | ||
|
|
3b17820533 | ||
|
|
eee8fcee75 | ||
|
|
037de7ec95 | ||
|
|
0e585a81a5 | ||
|
|
f1d0db3bc7 | ||
|
|
749db01ab2 | ||
|
|
19ff6faf35 | ||
|
|
39b3c57539 | ||
|
|
70e7a5d075 | ||
|
|
2bc297f2e9 | ||
|
|
65f4e86a22 | ||
|
|
40195d42f6 | ||
|
|
2aeab245a1 | ||
|
|
85e8b7c78d | ||
|
|
0614ec42ba |
2
.github/pull_hub_items.sh
vendored
2
.github/pull_hub_items.sh
vendored
@@ -9,6 +9,6 @@ for item in ${RT[@]}; do
|
||||
jq -r '.resource_type.schema' <<< "$body" > ./tmp
|
||||
description=$(jq -r '.resource_type.description' <<< "$body")
|
||||
description=$(echo -E $description)
|
||||
echo "{\"workspace_id\": \"starter\", \"name\": \"$name\", \"schema\": $(cat ./tmp), \"description\": \"$description\"} " | jq . > community/resource_types/${name}.json
|
||||
echo "{\"workspace_id\": \"admins\", \"name\": \"$name\", \"schema\": $(cat ./tmp), \"description\": \"$description\"} " | jq . > community/resource_types/${name}.json
|
||||
rm ./tmp
|
||||
done
|
||||
|
||||
5
.github/uffizzi/caddy/Caddyfile
vendored
Normal file
5
.github/uffizzi/caddy/Caddyfile
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
localhost {
|
||||
bind 0.0.0.0
|
||||
reverse_proxy /ws/* http://0.0.0.0:3001
|
||||
reverse_proxy /* http://0.0.0.0:8000
|
||||
}
|
||||
49
.github/uffizzi/docker-compose.uffizzi.yml
vendored
Normal file
49
.github/uffizzi/docker-compose.uffizzi.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
version: '3.7'
|
||||
|
||||
x-uffizzi:
|
||||
ingress:
|
||||
service: windmill
|
||||
port: 8000
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:14
|
||||
environment:
|
||||
POSTGRES_PASSWORD: changeme
|
||||
POSTGRES_DB: windmill
|
||||
|
||||
windmill:
|
||||
image: '${WINDMILL_IMAGE}'
|
||||
privileged: false
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8000:8000
|
||||
environment:
|
||||
- DATABASE_URL=postgres://postgres:changeme@localhost/windmill?sslmode=disable
|
||||
- BASE_URL=http://localhost
|
||||
- BASE_INTERNAL_URL=http://localhost:8000
|
||||
- RUST_LOG=info
|
||||
- NUM_WORKERS=3
|
||||
- KEEP_JOB_DIR=false
|
||||
- DENO_PATH=/usr/bin/deno
|
||||
- PYTHON_PATH=/usr/local/bin/python3
|
||||
- METRICS_ADDR=false
|
||||
volumes:
|
||||
- worker_dependency_cache:/tmp/windmill/cache
|
||||
|
||||
lsp:
|
||||
image: '${LSP_IMAGE}'
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 3001:3001
|
||||
|
||||
# caddy:
|
||||
# image: caddy:2.5.2-alpine
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./.github/uffizzi/caddy:/etc/caddy
|
||||
# environment:
|
||||
# - BASE_URL=localhost
|
||||
|
||||
volumes:
|
||||
worker_dependency_cache:
|
||||
2
.github/workflows/backend-test.yml
vendored
2
.github/workflows/backend-test.yml
vendored
@@ -40,4 +40,4 @@ jobs:
|
||||
backend -> target
|
||||
- name: cargo test
|
||||
timeout-minutes: 10
|
||||
run: mkdir frontend/build && cd backend && DATABASE_URL=postgres://postgres:changeme@postgres:5432/windmill cargo test --all -- --nocapture
|
||||
run: mkdir frontend/build && cd backend && touch windmill-api/openapi-deref.yaml && DATABASE_URL=postgres://postgres:changeme@postgres:5432/windmill cargo test --all -- --nocapture
|
||||
|
||||
5
.github/workflows/change-versions.yml
vendored
5
.github/workflows/change-versions.yml
vendored
@@ -12,4 +12,9 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Change versions
|
||||
run: ./.github/change-versions.sh "$(cat version.txt)"
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
- name: update lockfile
|
||||
run: cd backend && cargo generate-lockfile
|
||||
- uses: stefanzweifel/git-auto-commit-action@v4
|
||||
|
||||
106
.github/workflows/docker-image-arm.yml
vendored
106
.github/workflows/docker-image-arm.yml
vendored
@@ -1,62 +1,60 @@
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
LOCAL_REGISTRY: registry.wimill.xyz
|
||||
# env:
|
||||
# REGISTRY: ghcr.io
|
||||
# IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
name: Build and push arm docker image
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["*"]
|
||||
# name: Build and push arm docker image
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main]
|
||||
# tags: ["*"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}-arm
|
||||
cancel-in-progress: true
|
||||
# concurrency:
|
||||
# group: ${{ github.ref }}-arm
|
||||
# cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
publish_arm:
|
||||
runs-on: [self-hosted, new]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# permissions:
|
||||
# contents: read
|
||||
# id-token: write
|
||||
# packages: write
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
# jobs:
|
||||
# publish_arm:
|
||||
# runs-on: ubuntu-22.04
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# with:
|
||||
# fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
# - uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-slim-public
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# - name: Docker meta
|
||||
# id: meta-slim-public
|
||||
# uses: docker/metadata-action@v4
|
||||
# with:
|
||||
# images: |
|
||||
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
# tags: |
|
||||
# type=ref,event=branch
|
||||
# type=ref,event=pr
|
||||
# type=semver,pattern={{version}}
|
||||
# type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Login to registry
|
||||
# uses: docker/login-action@v2
|
||||
# with:
|
||||
# registry: ${{ env.REGISTRY }}
|
||||
# username: ${{ github.actor }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.meta-slim-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-slim-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-arm:buildcache
|
||||
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-arm:buildcache,mode=max
|
||||
# - name: Build and push publicly
|
||||
# uses: depot/build-push-action@v1
|
||||
# with:
|
||||
# context: .
|
||||
# push: true
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
# tags: |
|
||||
# ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
# ${{ steps.meta-slim-public.outputs.tags }}
|
||||
# labels: |
|
||||
# ${{ steps.meta-slim-public.outputs.labels }}
|
||||
# org.opencontainers.image.licenses=AGPLv3
|
||||
|
||||
86
.github/workflows/docker-image.yml
vendored
86
.github/workflows/docker-image.yml
vendored
@@ -9,22 +9,27 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, new]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v2
|
||||
@@ -33,36 +38,8 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-slim
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push publicly
|
||||
uses: docker/build-push-action@v3
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest
|
||||
${{ steps.meta-slim.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-slim.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache
|
||||
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache,mode=max
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-public
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
@@ -75,47 +52,31 @@ jobs:
|
||||
|
||||
|
||||
- name: Build and push publicly
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest
|
||||
${{ steps.meta-public.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=AGPLv3
|
||||
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache
|
||||
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:buildcache,mode=max
|
||||
|
||||
|
||||
build_ee:
|
||||
runs-on: [self-hosted, new]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-slim
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v2
|
||||
|
||||
- uses: depot/setup-action@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta-ee-public
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
@@ -128,15 +89,13 @@ jobs:
|
||||
|
||||
- name: Login to registry
|
||||
uses: docker/login-action@v2
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push publicly ee
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@v3
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -149,8 +108,7 @@ jobs:
|
||||
labels: |
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
cache-from: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-ee:buildcache
|
||||
cache-to: type=registry,ref=${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-ee:buildcache,mode=max
|
||||
|
||||
|
||||
playwright:
|
||||
runs-on: [self-hosted, new]
|
||||
@@ -172,14 +130,14 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: "Docker"
|
||||
run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}-slim:latest)"
|
||||
run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)"
|
||||
id: docker-container
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: "Playwright run"
|
||||
timeout-minutes: 2
|
||||
run: cd frontend && npm ci @playwright/test && npx playwright install && npm run test
|
||||
run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=http://localhost:8000 && npm run test
|
||||
- name: "Clean up"
|
||||
run: docker kill ${{ steps.docker-container.outputs.id }}
|
||||
if: always()
|
||||
@@ -188,7 +146,6 @@ jobs:
|
||||
publish_privately_heavy:
|
||||
needs: [build_ee]
|
||||
runs-on: [self-hosted, new]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -202,7 +159,6 @@ jobs:
|
||||
with:
|
||||
images: |
|
||||
${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.LOCAL_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
|
||||
17
.github/workflows/frontend-check.yml
vendored
Normal file
17
.github/workflows/frontend-check.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: check frontend build
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened,synchronize,reopened,closed]
|
||||
paths:
|
||||
- "frontend/**"
|
||||
jobs:
|
||||
npm_check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: "npm check"
|
||||
timeout-minutes: 2
|
||||
run: cd frontend && npm ci && npm run generate-backend-client && npm run check
|
||||
1
.github/workflows/pypi_on_release.yml
vendored
1
.github/workflows/pypi_on_release.yml
vendored
@@ -74,6 +74,7 @@ jobs:
|
||||
${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ steps.metalocal.outputs.tags }}
|
||||
${{ steps.meta.outputs.tags }}
|
||||
registry.uffizzi.com/windmill-lsp:60d
|
||||
labels: ${{ steps.metalocal.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
91
.github/workflows/uffizzi-build.yml
vendored
Normal file
91
.github/workflows/uffizzi-build.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Build PR Image
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened,synchronize,reopened,closed]
|
||||
paths:
|
||||
- "backend/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-windmill:
|
||||
name: Build and Push `windmill`
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event_name != 'pull_request' || github.event.action != 'closed')}}
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Generate UUID image name
|
||||
id: uuid
|
||||
run: echo "UUID_TAG_APP=$(uuidgen)" >> $GITHUB_ENV
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: registry.uffizzi.com/${{ env.UUID_TAG_APP }}
|
||||
tags: type=raw,value=60d
|
||||
- name: Build and Push Image to registry.uffizzi.com ephemeral registry
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
push: true
|
||||
context: ./
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
|
||||
render-compose-file:
|
||||
name: Render Docker Compose File
|
||||
# Pass output of this workflow to another triggered by `workflow_run` event.
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-windmill
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ steps.hash.outputs.hash }}
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Render Compose File
|
||||
run: |
|
||||
WINDMILL_IMAGE=${{ needs.build-windmill.outputs.tags }}
|
||||
export WINDMILL_IMAGE
|
||||
LSP_IMAGE=registry.uffizzi.com/windmill-lsp:60d
|
||||
export LSP_IMAGE
|
||||
envsubst '${WINDMILL_IMAGE} ${LSP_IMAGE}' < ./.github/uffizzi/docker-compose.uffizzi.yml > docker-compose.rendered.yml
|
||||
cat docker-compose.rendered.yml
|
||||
- name: Upload Rendered Compose File as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: docker-compose.rendered.yml
|
||||
retention-days: 2
|
||||
- name: Serialize PR Event to File
|
||||
run: |
|
||||
cat << EOF > event.json
|
||||
${{ toJSON(github.event) }}
|
||||
EOF
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
|
||||
delete-preview:
|
||||
name: Call for Preview Deletion
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.action == 'closed' }}
|
||||
steps:
|
||||
# If this PR is closing, we will not render a compose file nor pass it to the next workflow.
|
||||
- name: Serialize PR Event to File
|
||||
run: echo '${{ toJSON(github.event) }}' > event.json
|
||||
- name: Upload PR Event as Artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: preview-spec
|
||||
path: event.json
|
||||
retention-days: 2
|
||||
102
.github/workflows/uffizzi-preview.yml
vendored
Normal file
102
.github/workflows/uffizzi-preview.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: Deploy Uffizzi Preview
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Build PR Image"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
cache-compose-file:
|
||||
name: Cache Compose File
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
outputs:
|
||||
compose-file-cache-key: ${{ env.COMPOSE_FILE_HASH }}
|
||||
pr-number: ${{ env.PR_NUMBER }}
|
||||
steps:
|
||||
- name: 'Download artifacts'
|
||||
# Fetch output (zip archive) from the workflow run that triggered this workflow.
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "preview-spec"
|
||||
})[0];
|
||||
if (matchArtifact === undefined) {
|
||||
throw TypeError('Build Artifact not found!');
|
||||
}
|
||||
let download = await github.rest.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
let fs = require('fs');
|
||||
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/preview-spec.zip`, Buffer.from(download.data));
|
||||
- name: 'Unzip artifact'
|
||||
run: unzip preview-spec.zip
|
||||
- name: Read Event into ENV
|
||||
run: |
|
||||
echo 'EVENT_JSON<<EOF' >> $GITHUB_ENV
|
||||
cat event.json >> $GITHUB_ENV
|
||||
echo 'EOF' >> $GITHUB_ENV
|
||||
- name: Hash Rendered Compose File
|
||||
id: hash
|
||||
# If the previous workflow was triggered by a PR close event, we will not have a compose file artifact.
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
run: echo "COMPOSE_FILE_HASH=$(md5sum docker-compose.rendered.yml | awk '{ print $1 }')" >> $GITHUB_ENV
|
||||
- name: Cache Rendered Compose File
|
||||
if: ${{ fromJSON(env.EVENT_JSON).action != 'closed' }}
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: docker-compose.rendered.yml
|
||||
key: ${{ env.COMPOSE_FILE_HASH }}
|
||||
|
||||
- name: Read PR Number From Event Object
|
||||
id: pr
|
||||
run: echo "PR_NUMBER=${{ fromJSON(env.EVENT_JSON).number }}" >> $GITHUB_ENV
|
||||
|
||||
- name: DEBUG - Print Job Outputs
|
||||
if: ${{ runner.debug }}
|
||||
run: |
|
||||
echo "PR number: ${{ env.PR_NUMBER }}"
|
||||
echo "Compose file hash: ${{ env.COMPOSE_FILE_HASH }}"
|
||||
cat event.json
|
||||
|
||||
deploy-uffizzi-preview:
|
||||
name: Use Remote Workflow to Preview on Uffizzi
|
||||
needs:
|
||||
- cache-compose-file
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
uses: UffizziCloud/preview-action/.github/workflows/reusable.yaml@v2
|
||||
with:
|
||||
# If this workflow was triggered by a PR close event, cache-key will be an empty string
|
||||
# and this reusable workflow will delete the preview deployment.
|
||||
compose-file-cache-key: ${{ needs.cache-compose-file.outputs.compose-file-cache-key }}
|
||||
compose-file-cache-path: docker-compose.rendered.yml
|
||||
server: https://app.uffizzi.com
|
||||
pr-number: ${{ needs.cache-compose-file.outputs.pr-number }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- deploy-uffizzi-preview
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
- name: "Playwright run"
|
||||
timeout-minutes: 2
|
||||
run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=${{ needs.deploy-uffizzi-preview.outputs.url }} && npm run test
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -1,6 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## [1.60.0](https://github.com/windmill-labs/windmill/compare/v1.59.0...v1.60.0) (2023-01-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'add user to workspace' ([a14623f](https://github.com/windmill-labs/windmill/commit/a14623feaab4a36c01d558b775a42e587a74cdc9))
|
||||
* **frontend:** Add frost to color palette ([#1084](https://github.com/windmill-labs/windmill/issues/1084)) ([8e72007](https://github.com/windmill-labs/windmill/commit/8e7200736827e8f6e593f900124b1bd1bc0bd5f2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** Keep pane resizer under open drawer ([#1089](https://github.com/windmill-labs/windmill/issues/1089)) ([cb25f88](https://github.com/windmill-labs/windmill/commit/cb25f883005b99b4ce98e8ae7b8253a8a2fedb5b))
|
||||
|
||||
## [1.59.0](https://github.com/windmill-labs/windmill/compare/v1.58.0...v1.59.0) (2023-01-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add relative imports for python scripts ([#1075](https://github.com/windmill-labs/windmill/issues/1075)) ([5347cd4](https://github.com/windmill-labs/windmill/commit/5347cd46a996b4cf48a96fbb873e4d029ca4f75f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** Iconed resource height issue ([#1073](https://github.com/windmill-labs/windmill/issues/1073)) ([a84eb9b](https://github.com/windmill-labs/windmill/commit/a84eb9b1f7e1b10c960ee1594ef476e7ba146f5e))
|
||||
|
||||
## [1.58.0](https://github.com/windmill-labs/windmill/compare/v1.57.1...v1.58.0) (2023-01-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add archive/unarchive/delete workspace ([6edf9b9](https://github.com/windmill-labs/windmill/commit/6edf9b9946d613b599cb91688c4986044caaba8d))
|
||||
* add hub support for apps ([50453ca](https://github.com/windmill-labs/windmill/commit/50453ca690dfd936474ebbf000e36ae1006b188b))
|
||||
* add min/max constraint to number + slider component ([0bcdcae](https://github.com/windmill-labs/windmill/commit/0bcdcaedcfdf7b7f76f703df3bf50d97dd389995))
|
||||
* add support for yaml format as a string format ([5204e4a](https://github.com/windmill-labs/windmill/commit/5204e4a75d74e6bb4087dee7087390f7c0388e51))
|
||||
* **frontend:** Add integration icons ([#1063](https://github.com/windmill-labs/windmill/issues/1063)) ([45acb89](https://github.com/windmill-labs/windmill/commit/45acb89f87ad78c48a1ba6abf1bd1424088b41c4))
|
||||
* **frontend:** Toggle to hide optional inputs ([#1060](https://github.com/windmill-labs/windmill/issues/1060)) ([4d6a568](https://github.com/windmill-labs/windmill/commit/4d6a568820ceb6c064dc2871085b80412e18c379))
|
||||
* **frontend:** Update app auto-refresh button ([#1062](https://github.com/windmill-labs/windmill/issues/1062)) ([34e3331](https://github.com/windmill-labs/windmill/commit/34e33319192f6d747d84fc6559853410f5d72ec8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** Remove popover hover styles ([#1064](https://github.com/windmill-labs/windmill/issues/1064)) ([76a860f](https://github.com/windmill-labs/windmill/commit/76a860fe538dadfc6691074384f92db1a331063d))
|
||||
|
||||
## [1.57.1](https://github.com/windmill-labs/windmill/compare/v1.57.0...v1.57.1) (2023-01-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* preserver order changes for flows' schema ([2c8e98a](https://github.com/windmill-labs/windmill/commit/2c8e98a9c7fe3fdd48c851c0575fdb1d87c953a9))
|
||||
* support setting undefined states ([ab0aeb0](https://github.com/windmill-labs/windmill/commit/ab0aeb0df825fb5afefbefae6739179dbbbc5f30))
|
||||
|
||||
## [1.57.0](https://github.com/windmill-labs/windmill/compare/v1.56.1...v1.57.0) (2023-01-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add a All Static Inputs module to the flow editor ([3296deb](https://github.com/windmill-labs/windmill/commit/3296debfe7940fe833d489af0a4b6609c2d53411))
|
||||
* apps can be published publicly ([be14aab](https://github.com/windmill-labs/windmill/commit/be14aab9b102ef81eccf689e00cd3cd8eae8f503))
|
||||
* **app:** Update sidebar menu ([#1050](https://github.com/windmill-labs/windmill/issues/1050)) ([faa046a](https://github.com/windmill-labs/windmill/commit/faa046a3fdc326084df93f8e57dd5c573164b91d))
|
||||
* **app:** Use consistent styles on settings pages ([#1048](https://github.com/windmill-labs/windmill/issues/1048)) ([681e2e8](https://github.com/windmill-labs/windmill/commit/681e2e824a39d9748f1aaa37f20001b5200f82bc))
|
||||
* **backend:** resume from owner directly in flow status viewer ([#1042](https://github.com/windmill-labs/windmill/issues/1042)) ([40195d4](https://github.com/windmill-labs/windmill/commit/40195d42f661d401cd9ce11ca9739f87c1a27afd))
|
||||
* **frontend:** Add customization props to radio ([#1056](https://github.com/windmill-labs/windmill/issues/1056)) ([0812f6e](https://github.com/windmill-labs/windmill/commit/0812f6efd8484e86a4f09631b28c71d17cd69627))
|
||||
* **frontend:** Fix initial component dimensions + Select select + add spinner when buttons are clicked ([#1044](https://github.com/windmill-labs/windmill/issues/1044)) ([70e7a5d](https://github.com/windmill-labs/windmill/commit/70e7a5d07542e1ac936152e434146e056a80bad4))
|
||||
* **frontend:** Properly support resource ([#1039](https://github.com/windmill-labs/windmill/issues/1039)) ([65f4e86](https://github.com/windmill-labs/windmill/commit/65f4e86a22838bd34373ce808c77a1770eeaf295))
|
||||
* **frontend:** Update tooltip and home list dropdown ([#1053](https://github.com/windmill-labs/windmill/issues/1053)) ([9d30e5f](https://github.com/windmill-labs/windmill/commit/9d30e5fa57363c4cf715f845f5268856c4aa0fb3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **app:** Fix inconsistencies in list items and sidebar menus ([#1051](https://github.com/windmill-labs/windmill/issues/1051)) ([0f1b19c](https://github.com/windmill-labs/windmill/commit/0f1b19c7d3eea4f8106fed3188460678e5035812))
|
||||
* **frontend:** List item overflowing corners ([#1055](https://github.com/windmill-labs/windmill/issues/1055)) ([2fd730f](https://github.com/windmill-labs/windmill/commit/2fd730f8d2303b57f2da42354cd207dad2a410ce))
|
||||
* **frontend:** Minor fixes in editor ([#1054](https://github.com/windmill-labs/windmill/issues/1054)) ([adc84f0](https://github.com/windmill-labs/windmill/commit/adc84f06d97275b17bf77cb6c8d264ad28b0f6ce))
|
||||
* **frontend:** Static inputs overflow ([#1057](https://github.com/windmill-labs/windmill/issues/1057)) ([72aeba1](https://github.com/windmill-labs/windmill/commit/72aeba121cb694e8e96ad189b4acbfc2340bf520))
|
||||
|
||||
## [1.56.1](https://github.com/windmill-labs/windmill/compare/v1.56.0...v1.56.1) (2022-12-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** typo in cli deps ([0614ec4](https://github.com/windmill-labs/windmill/commit/0614ec42baf3e8f1675d62ca0f143b831c2700a1))
|
||||
|
||||
## [1.56.0](https://github.com/windmill-labs/windmill/compare/v1.55.0...v1.56.0) (2022-12-23)
|
||||
|
||||
|
||||
|
||||
@@ -51,12 +51,16 @@ COPY frontend .
|
||||
RUN mkdir /backend
|
||||
COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml
|
||||
COPY /openflow.openapi.yaml /openflow.openapi.yaml
|
||||
COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh
|
||||
RUN cd /backend/windmill-api && . ./build_openapi.sh
|
||||
|
||||
RUN npm run generate-backend-client
|
||||
ENV NODE_OPTIONS "--max-old-space-size=8192"
|
||||
RUN npm run build
|
||||
RUN npm run check
|
||||
|
||||
|
||||
|
||||
FROM rust_base AS planner
|
||||
|
||||
COPY ./openflow.openapi.yaml /openflow.openapi.yaml
|
||||
@@ -75,6 +79,7 @@ COPY ./openflow.openapi.yaml /openflow.openapi.yaml
|
||||
COPY ./backend ./
|
||||
|
||||
COPY --from=frontend /frontend /frontend
|
||||
COPY --from=frontend /backend/windmill-api/openapi-deref.yaml ./windmill-api/openapi-deref.yaml
|
||||
COPY .git/ .git/
|
||||
|
||||
RUN CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features"
|
||||
|
||||
10
README.md
10
README.md
@@ -91,7 +91,7 @@ Windmill is <b>fully open-sourced (AGPLv3)</b>:
|
||||
shared on [WindmillHub](https://hub.windmill.dev).
|
||||

|
||||
|
||||
4. (Coming soon) Build complex UI on top of your scripts and flows.
|
||||
4. Build complex UI on top of your scripts and flows.
|
||||

|
||||
|
||||
Scripts and flows can also be triggered by a cron schedule '*/5 * * * *' or
|
||||
@@ -285,6 +285,7 @@ upcoming CLI tool.
|
||||
| BASE_INTERNAL_URL | http://localhost:8000 | The base url that is reachable by your workers to talk to the Servers. This help avoiding going through the external load balancer for VPC-internal requests. | Worker |
|
||||
| TIMEOUT | 300 | The timeout in seconds for the execution of a script | Worker |
|
||||
| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
|
||||
| MAX_LOG_SIZE | 500000 | The maximum number of characters a job can emit (log + result) | Worker |
|
||||
| DISABLE_NUSER | false | If Nsjail is enabled, disable the nsjail's `clone_newuser` setting | Worker |
|
||||
| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
|
||||
| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
|
||||
@@ -303,6 +304,13 @@ upcoming CLI tool.
|
||||
| HOME | None | The home directory to use for Go and Bash , usually inherited | Worker |
|
||||
| DATABASE_CONNECTIONS | 50 (Server)/3 (Worker) | The max number of connections in the database connection pool | All |
|
||||
| SUPERADMIN_SECRET | None | A token that would let the caller act as a virtual superadmin superadmin@windmill.dev | Server |
|
||||
| TIMEOUT_WAIT_RESULT | 20 | The number of seconds to wait before timeout on the 'run_wait_result' endpoint | Worker |
|
||||
| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker |
|
||||
| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker |
|
||||
| DENO_FLAGS | None | Override the flags passed to deno (default --allow-all) to tighten permissions. Minimum permissions needed are "--allow-read=args.json --allow-write=result.json" | Worker |
|
||||
| |
|
||||
|
||||
|
||||
|
||||
|
||||
## Run a local dev setup
|
||||
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
target/
|
||||
.env
|
||||
oauth.json
|
||||
windmill-api/openapi-deref.yaml
|
||||
3
backend/.vscode/settings.json
vendored
Normal file
3
backend/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
1122
backend/Cargo.lock
generated
1122
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.56.0"
|
||||
version = "1.60.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -19,7 +19,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.56.0"
|
||||
version = "1.60.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -45,6 +45,9 @@ tokio-metrics.workspace = true
|
||||
rand.workspace = true
|
||||
chrono.workspace = true
|
||||
git-version.workspace = true
|
||||
rsa.workspace = true
|
||||
base64.workspace = true
|
||||
sha2.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -140,3 +143,5 @@ lazy_static = "1.4.0"
|
||||
serde_derive = "1.0.147"
|
||||
const_format = { version = "0.2", features = ["rust_1_64", "rust_1_51"] }
|
||||
dyn-iter = "0.2.0"
|
||||
rsa = "0.7.2"
|
||||
async-stripe = { version = "0.14", features = ["runtime-tokio-hyper", "checkout"] }
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
15
backend/migrations/20230101074836_sync_from_admin.up.sql
Normal file
15
backend/migrations/20230101074836_sync_from_admin.up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Add up migration script here
|
||||
UPDATE script SET content = 'import wmill from "https://deno.land/x/wmill@v1.57.0/main.ts";
|
||||
export async function main() {
|
||||
await run(
|
||||
"workspace", "add", "__automation", "admins", Deno.env.get("WM_BASE_URL") + "/", "--token", Deno.env.get("WM_TOKEN"));
|
||||
|
||||
await run("hub", "pull");
|
||||
}
|
||||
|
||||
async function run(...cmd: string[]) {
|
||||
console.log("Running \"" + cmd.join('' '') + "\"");
|
||||
await wmill.parse(cmd);
|
||||
}', summary = 'Synchronize Hub Resource types with admins workspace',
|
||||
description = 'Basic administrative script to sync latest resource types from hub to share to every workspace. Recommended to run at least once. On a schedule by default.'
|
||||
WHERE hash = -28028598712388162 AND workspace_id = 'admins';
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Add up migration script here
|
||||
-- Add up migration script here
|
||||
UPDATE script SET content = 'import wmill from "https://deno.land/x/wmill@v1.57.0/main.ts";
|
||||
export async function main() {
|
||||
await run(
|
||||
"workspace", "add", "__automation", "admins", Deno.env.get("BASE_INTERNAL_URL") + "/", "--token", Deno.env.get("WM_TOKEN"));
|
||||
|
||||
await run("hub", "pull");
|
||||
}
|
||||
|
||||
async function run(...cmd: string[]) {
|
||||
console.log("Running \"" + cmd.join('' '') + "\"");
|
||||
await wmill.parse(cmd);
|
||||
}', summary = 'Synchronize Hub Resource types with admins workspace',
|
||||
description = 'Basic administrative script to sync latest resource types from hub to share to every workspace. Recommended to run at least once. On a schedule by default.'
|
||||
WHERE hash = -28028598712388162 AND workspace_id = 'admins';
|
||||
1
backend/migrations/20230119194229_customer_id.down.sql
Normal file
1
backend/migrations/20230119194229_customer_id.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
3
backend/migrations/20230119194229_customer_id.up.sql
Normal file
3
backend/migrations/20230119194229_customer_id.up.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE workspace_settings ADD COLUMN customer_id VARCHAR(100);
|
||||
ALTER TABLE workspace_settings ADD COLUMN plan VARCHAR(40);
|
||||
1
backend/migrations/20230126023323_webhook.down.sql
Normal file
1
backend/migrations/20230126023323_webhook.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
5
backend/migrations/20230126023323_webhook.up.sql
Normal file
5
backend/migrations/20230126023323_webhook.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE
|
||||
workspace_settings
|
||||
ADD
|
||||
COLUMN webhook text;
|
||||
@@ -104,6 +104,7 @@ pub fn parse_python_signature(code: &str) -> error::Result<MainArgSignature> {
|
||||
"bytes" => Typ::Bytes,
|
||||
"datetime" => Typ::Datetime,
|
||||
"datetime.datetime" => Typ::Datetime,
|
||||
"Sql" | "sql" => Typ::Sql,
|
||||
_ => Typ::Resource(id),
|
||||
},
|
||||
_ => Typ::Unknown,
|
||||
@@ -174,8 +175,13 @@ fn constant_to_value(c: &Constant) -> serde_json::Value {
|
||||
|
||||
static PYTHON_IMPORTS_REPLACEMENT: phf::Map<&'static str, &'static str> = phf_map! {
|
||||
"psycopg2" => "psycopg2-binary",
|
||||
"psycopg" => "psycopg[binary, pool]",
|
||||
"yaml" => "pyyaml",
|
||||
"git" => "GitPython"
|
||||
"git" => "GitPython",
|
||||
"u" => "requests",
|
||||
"f" => "requests",
|
||||
"shopify" => "ShopifyAPI",
|
||||
"seleniumwire" => "selenium-wire",
|
||||
};
|
||||
|
||||
fn replace_import(x: String) -> String {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
24
backend/src/ee.rs
Normal file
24
backend/src/ee.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
#[cfg(feature = "enterprise")]
|
||||
use rsa::{pkcs8::DecodePublicKey, signature::Verifier};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use sha2::Sha256;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub fn verify_license_key(license_key: Option<String>) -> anyhow::Result<()> {
|
||||
if let Some(license_key) = license_key {
|
||||
let splitted_lk = license_key
|
||||
.split_once(".")
|
||||
.expect("license_key can be splitted with a .");
|
||||
|
||||
let pub_key = rsa::RsaPublicKey::from_public_key_der(
|
||||
&base64::decode("MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgVShzcLSPiOi+8ET8fggob1kmi47/cE12JaidPkwfGnScZItghkqtiLsct0U4kJhlp5gO89DYTBmIKadvxwY7kMsLlZzmi2emVH7c27cByGASY8QmWDNdG4Ggy/NDflGGBdAtN6gHawZAg4zHv3qpbPQGHH1/6sXIohcXhOnouwIDAQAB")?)?;
|
||||
let msg = base64::decode(splitted_lk.0)?;
|
||||
let signature = base64::decode(splitted_lk.1)?;
|
||||
rsa::pss::VerifyingKey::<Sha256>::new(pub_key)
|
||||
.verify(&msg, &rsa::pss::Signature::from(signature))
|
||||
.map_err(|_| anyhow::anyhow!("Invalid license key".to_string()))?;
|
||||
} else {
|
||||
panic!("License key is required for the enterprise edition");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,6 +15,8 @@ use windmill_worker::WorkerConfig;
|
||||
|
||||
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
|
||||
|
||||
mod ee;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
@@ -190,19 +192,12 @@ pub async fn run_workers(
|
||||
license_key: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(license_key) = license_key {
|
||||
if license_key != "REQUIRED_DEC1" {
|
||||
panic!("Invalid license key");
|
||||
}
|
||||
} else {
|
||||
panic!("License key is required for the enterprise edition");
|
||||
}
|
||||
ee::verify_license_key(license_key)?;
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if license_key.is_some() {
|
||||
panic!("License key is required ONLY for the enterprise edition");
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if !worker_config.disable_nsjail {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -186,7 +186,7 @@ mod suspend_resume {
|
||||
const secret = await r.text();\
|
||||
console.log('Secret: ' + secret + ' ' + job + ' ' + token);\
|
||||
const r2 = await fetch(
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs/${op}/${job}/0/${secret}?approver=ruben`,\
|
||||
`http://localhost:${port}/api/w/test-workspace/jobs_u/${op}/${job}/0/${secret}?approver=ruben`,\
|
||||
{\
|
||||
method: 'POST',\
|
||||
body: JSON.stringify('from job'),\
|
||||
@@ -276,7 +276,7 @@ mod suspend_resume {
|
||||
|
||||
/* ImZyb20gdGVzdCIK = base64 "from test" */
|
||||
reqwest::get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/resume/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK&approver=ruben"
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs_u/resume/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK&approver=ruben"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -333,7 +333,7 @@ mod suspend_resume {
|
||||
server.close().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json!({"error": "Job canceled: approval request disapproved by ruben" }),
|
||||
json!({"error": {"name": "Canceled", "reason": "approval request disapproved", "message": "Job canceled: approval request disapproved by ruben", "canceler": "ruben"}}),
|
||||
result
|
||||
);
|
||||
}
|
||||
@@ -379,7 +379,7 @@ mod suspend_resume {
|
||||
|
||||
/* ImZyb20gdGVzdCIK = base64 "from test" */
|
||||
reqwest::get(format!(
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs/cancel/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
|
||||
"http://localhost:{port}/api/w/test-workspace/jobs_u/cancel/{second}/0/{secret}?payload=ImZyb20gdGVzdCIK"
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -395,7 +395,7 @@ mod suspend_resume {
|
||||
let result = completed_job(flow, &db).await.result.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
json!({"error": "Job canceled: approval request disapproved by unknown" }),
|
||||
json!( {"error": {"name": "Canceled", "reason": "approval request disapproved", "message": "Job canceled: approval request disapproved by unknown", "canceler": "unknown"}}),
|
||||
result
|
||||
);
|
||||
}
|
||||
@@ -462,7 +462,7 @@ export async function main(index, port) {
|
||||
const buf = new Uint8Array([0]);
|
||||
const sock = await Deno.connect({ port });
|
||||
await sock.write(new Uint8Array([index]));
|
||||
if (await sock.read(buf) != 1) throw "read";
|
||||
if (await sock.read(buf) != 1) throw Error("read");
|
||||
return buf[0];
|
||||
}
|
||||
"#
|
||||
@@ -581,10 +581,17 @@ def main(last, port):
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(server.close().await, attempts);
|
||||
assert!(result[1]["error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(r#"Uncaught (in promise) "read""#));
|
||||
|
||||
assert!(
|
||||
result[1]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
== "read"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
@@ -615,6 +622,10 @@ def main(last, port):
|
||||
let result = job.result.unwrap();
|
||||
assert_eq!(server.close().await, attempts);
|
||||
assert!(result["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("index out of range"));
|
||||
@@ -684,9 +695,7 @@ def main(error, port):
|
||||
result,
|
||||
json!({
|
||||
"recv": 42,
|
||||
"from failure module": {
|
||||
"error": "Error during execution of the script:\n\nTraceback (most recent call last):\n File \"/tmp/main.py\", line 14, in <module>\n res = inner_script.main(**kwargs)\n File \"/tmp/inner.py\", line 5, in main\n return sock.recv(1)[0]\nIndexError: index out of range",
|
||||
}
|
||||
"from failure module": {"error": {"name": "IndexError", "stack": " File \"/tmp/inner.py\", line 5, in main\n return sock.recv(1)[0]\n", "message": "index out of range"}},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -739,9 +748,13 @@ async fn test_iteration(db: Pool<Postgres>) {
|
||||
.unwrap();
|
||||
assert!(matches!(result, serde_json::Value::Array(_)));
|
||||
assert!(result[2]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("StopIteration: 2"));
|
||||
.contains("2"));
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
@@ -792,9 +805,13 @@ async fn test_iteration_parallel(db: Pool<Postgres>) {
|
||||
let result = job.result.unwrap();
|
||||
assert!(matches!(result, serde_json::Value::Array(_)));
|
||||
assert!(result[2]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("StopIteration: 2"));
|
||||
.contains("2"));
|
||||
}
|
||||
|
||||
struct RunJob {
|
||||
@@ -1767,7 +1784,7 @@ async fn test_invalid_first_step(db: Pool<Postgres>) {
|
||||
|
||||
assert_eq!(
|
||||
job.result.unwrap(),
|
||||
serde_json::json!({"error":"Expected an array value, found: {}"})
|
||||
serde_json::json!( {"error": {"name": "InternalErr", "message": "Expected an array value, found: {}"}})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2083,7 +2100,7 @@ async fn test_branchall_skip_failure(db: Pool<Postgres>) {
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([{"error": "Error during execution of the script:\n\nerror: Uncaught (in promise) Error: failure\nexport function main(){ throw Error('failure') }\n ^\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1"}, [1,3]])
|
||||
serde_json::json!([{"error": {"name": "Error", "stack": "Error: failure\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1", "message": "failure"}}, [1,3]])
|
||||
);
|
||||
|
||||
let flow: FlowValue = serde_json::from_value(json!({
|
||||
@@ -2117,7 +2134,7 @@ async fn test_branchall_skip_failure(db: Pool<Postgres>) {
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!([{"error": "Error during execution of the script:\n\nerror: Uncaught (in promise) Error: failure\nexport function main(){ throw Error('failure') }\n ^\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1"}, [1, 2]])
|
||||
serde_json::json!([ {"error": {"name": "Error", "stack": "Error: failure\n at main (file:///tmp/inner.ts:1:31)\n at run (file:///tmp/main.ts:9:26)\n at file:///tmp/main.ts:14:1", "message": "failure"}}, [1, 2]])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2256,7 +2273,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(n, l) { if (n == 0) throw l; return { l: [...l, 0] } }",
|
||||
"content": "export function main(n, l) { if (n == 0) throw Error(JSON.stringify(l)); return { l: [...l, 0] } }",
|
||||
},
|
||||
}, {
|
||||
"id": "b",
|
||||
@@ -2267,7 +2284,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(n, l) { if (n == 1) throw l; return { l: [...l, 1] } }",
|
||||
"content": "export function main(n, l) { if (n == 1) throw Error(JSON.stringify(l)); return { l: [...l, 1] } }",
|
||||
},
|
||||
}, {
|
||||
"value": {
|
||||
@@ -2277,7 +2294,7 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
},
|
||||
"type": "rawscript",
|
||||
"language": "deno",
|
||||
"content": "export function main(n, l) { if (n == 2) throw l; return { l: [...l, 2] } }",
|
||||
"content": "export function main(n, l) { if (n == 2) throw Error(JSON.stringify(l)); return { l: [...l, 2] } }",
|
||||
},
|
||||
}],
|
||||
"failure_module": {
|
||||
@@ -2297,10 +2314,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
.await
|
||||
.result
|
||||
.unwrap();
|
||||
|
||||
assert!(result["from failure module"]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Uncaught (in promise) []"));
|
||||
.contains("[]"));
|
||||
|
||||
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
|
||||
.arg("n", json!(1))
|
||||
@@ -2308,10 +2330,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
.await
|
||||
.result
|
||||
.unwrap();
|
||||
|
||||
assert!(result["from failure module"]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Uncaught (in promise) [ 0 ]"));
|
||||
.contains("[0]"));
|
||||
|
||||
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
|
||||
.arg("n", json!(2))
|
||||
@@ -2319,10 +2346,15 @@ async fn test_failure_module(db: Pool<Postgres>) {
|
||||
.await
|
||||
.result
|
||||
.unwrap();
|
||||
|
||||
assert!(result["from failure module"]["error"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.get("message")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("Uncaught (in promise) [ 0, 1 ]"));
|
||||
.contains("[0,1]"));
|
||||
|
||||
let result = RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None })
|
||||
.arg("n", json!(3))
|
||||
|
||||
@@ -69,3 +69,6 @@ hmac.workspace = true
|
||||
cookie.workspace = true
|
||||
sha2.workspace = true
|
||||
urlencoding.workspace = true
|
||||
async-stripe.workspace = true
|
||||
lazy_static.workspace = true
|
||||
prometheus.workspace = true
|
||||
|
||||
9
backend/windmill-api/build_openapi.sh
Executable file
9
backend/windmill-api/build_openapi.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
npx @redocly/openapi-cli@latest bundle openapi.yaml > openapi-bundled.yaml
|
||||
|
||||
|
||||
npx @redocly/openapi-cli@latest bundle openapi-bundled.yaml --ext yaml -d > openapi-deref.yaml
|
||||
|
||||
rm openapi-bundled.yaml
|
||||
0
backend/windmill-api/openapi-deref.yaml
Normal file
0
backend/windmill-api/openapi-deref.yaml
Normal file
@@ -1,10 +1,11 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.56.0
|
||||
title: Windmill server API
|
||||
version: 1.60.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
name: Windmill contact
|
||||
name: Windmill Team
|
||||
email: contact@windmill.dev
|
||||
url: https://windmill.dev
|
||||
|
||||
@@ -12,6 +13,8 @@ info:
|
||||
name: Apache 2.0
|
||||
url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
|
||||
x-logo:
|
||||
url: https://docs.windmill.dev/img/windmill.svg
|
||||
externalDocs:
|
||||
description: documentation portal
|
||||
url: https://docs.windmill.dev
|
||||
@@ -670,6 +673,43 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/add_user:
|
||||
post:
|
||||
summary: add user to workspace
|
||||
operationId: addUser
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: WorkspaceInvite
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
is_admin:
|
||||
type: boolean
|
||||
username:
|
||||
type: string
|
||||
operator:
|
||||
type: boolean
|
||||
required:
|
||||
- email
|
||||
- is_admin
|
||||
- operator
|
||||
- username
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/delete_invite:
|
||||
post:
|
||||
summary: delete user invite
|
||||
@@ -704,9 +744,41 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/delete:
|
||||
/w/{workspace}/workspaces/archive:
|
||||
post:
|
||||
summary: archive workspace
|
||||
operationId: archiveWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/workspaces/unarchive/{workspace}:
|
||||
post:
|
||||
summary: unarchive workspace
|
||||
operationId: unarchiveWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/workspaces/delete/{workspace}:
|
||||
delete:
|
||||
summary: delete workspace
|
||||
summary: delete workspace (require super admin)
|
||||
operationId: deleteWorkspace
|
||||
tags:
|
||||
- workspace
|
||||
@@ -807,6 +879,12 @@ paths:
|
||||
type: string
|
||||
auto_invite_operator:
|
||||
type: boolean
|
||||
plan:
|
||||
type: string
|
||||
customer_id:
|
||||
type: string
|
||||
webhook:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/premium_info:
|
||||
get:
|
||||
@@ -886,6 +964,33 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_webhook:
|
||||
post:
|
||||
summary: edit webhook
|
||||
operationId: editWebhook
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: WorkspaceWebhook
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
webhook:
|
||||
type: string
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/users/list:
|
||||
get:
|
||||
summary: list users
|
||||
@@ -1422,6 +1527,32 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/resources/update_value/{path}:
|
||||
post:
|
||||
summary: update resource value
|
||||
operationId: updateResourceValue
|
||||
tags:
|
||||
- resource
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
requestBody:
|
||||
description: updated resource
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
value: {}
|
||||
responses:
|
||||
"200":
|
||||
description: resource value updated
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/resources/get/{path}:
|
||||
get:
|
||||
summary: get resource
|
||||
@@ -1743,6 +1874,75 @@ paths:
|
||||
flow:
|
||||
$ref: "../../openflow.openapi.yaml#/components/schemas/OpenFlow"
|
||||
|
||||
/apps/hub/list:
|
||||
get:
|
||||
summary: list all available hub apps
|
||||
operationId: listHubApps
|
||||
tags:
|
||||
- app
|
||||
responses:
|
||||
"200":
|
||||
description: hub apps list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
apps:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
app_id:
|
||||
type: number
|
||||
summary:
|
||||
type: string
|
||||
apps:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
approved:
|
||||
type: boolean
|
||||
votes:
|
||||
type: number
|
||||
required:
|
||||
- id
|
||||
- app_id
|
||||
- summary
|
||||
- apps
|
||||
- approved
|
||||
- votes
|
||||
|
||||
/apps/hub/get/{id}:
|
||||
get:
|
||||
summary: get hub app by id
|
||||
operationId: getHubAppById
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PathId"
|
||||
responses:
|
||||
"200":
|
||||
description: app
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
app:
|
||||
type: object
|
||||
properties:
|
||||
summary:
|
||||
type: string
|
||||
value: {}
|
||||
required:
|
||||
- summary
|
||||
- value
|
||||
required:
|
||||
- app
|
||||
|
||||
/scripts/hub/get/{path}:
|
||||
get:
|
||||
summary: get hub script content by path
|
||||
@@ -1877,6 +2077,25 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/Script"
|
||||
|
||||
/w/{workspace}/scripts/list_paths:
|
||||
get:
|
||||
summary: list all available scripts paths
|
||||
operationId: listScriptPaths
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of script paths
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
|
||||
/w/{workspace}/scripts/create:
|
||||
post:
|
||||
summary: create script
|
||||
@@ -2238,6 +2457,7 @@ paths:
|
||||
type: integer
|
||||
- $ref: "#/components/parameters/ParentJob"
|
||||
- $ref: "#/components/parameters/IncludeHeader"
|
||||
- $ref: "#/components/parameters/QueueLimit"
|
||||
|
||||
requestBody:
|
||||
description: script args
|
||||
@@ -2284,6 +2504,24 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/flows/list_paths:
|
||||
get:
|
||||
summary: list all available flow paths
|
||||
operationId: listFlowPaths
|
||||
tags:
|
||||
- flow
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of flow paths
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/flows/list:
|
||||
get:
|
||||
summary: list all available flows
|
||||
@@ -2505,6 +2743,41 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
/w/{workspace}/apps_u/public_app/{path}:
|
||||
get:
|
||||
summary: get public app by secret
|
||||
operationId: getPublicAppBySecret
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: app details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AppWithLastVersion"
|
||||
|
||||
|
||||
/w/{workspace}/apps/secret_of/{path}:
|
||||
get:
|
||||
summary: get public secret of app
|
||||
operationId: getPublicSecretOfApp
|
||||
tags:
|
||||
- app
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"200":
|
||||
description: app secret
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/get/v/{id}:
|
||||
get:
|
||||
summary: get app by version
|
||||
@@ -2607,7 +2880,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/apps/execute_component/{path}:
|
||||
/w/{workspace}/apps_u/execute_component/{path}:
|
||||
post:
|
||||
summary: executeComponent
|
||||
operationId: executeComponent
|
||||
@@ -2824,7 +3097,8 @@ paths:
|
||||
- $ref: "#/components/parameters/CreatedAfter"
|
||||
- $ref: "#/components/parameters/Success"
|
||||
- $ref: "#/components/parameters/JobKinds"
|
||||
|
||||
- $ref: "#/components/parameters/Suspended"
|
||||
- $ref: "#/components/parameters/Running"
|
||||
responses:
|
||||
"200":
|
||||
description: All available queued jobs
|
||||
@@ -2914,7 +3188,7 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/Job"
|
||||
|
||||
/w/{workspace}/jobs/get/{id}:
|
||||
/w/{workspace}/jobs_u/get/{id}:
|
||||
get:
|
||||
summary: get job
|
||||
operationId: getJob
|
||||
@@ -2931,7 +3205,24 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Job"
|
||||
|
||||
/w/{workspace}/jobs/getupdate/{id}:
|
||||
# /w/{workspace}/jobs/flow/current_state/{id}:
|
||||
# get:
|
||||
# summary: get flow current step state
|
||||
# operationId: getJob
|
||||
# tags:
|
||||
# - job
|
||||
# parameters:
|
||||
# - $ref: "#/components/parameters/WorkspaceId"
|
||||
# - $ref: "#/components/parameters/JobId"
|
||||
# responses:
|
||||
# "200":
|
||||
# description: state details
|
||||
# content:
|
||||
# application/json:
|
||||
# schema:
|
||||
# type: string
|
||||
|
||||
/w/{workspace}/jobs_u/getupdate/{id}:
|
||||
get:
|
||||
summary: get job updates
|
||||
operationId: getJobUpdates
|
||||
@@ -3089,7 +3380,7 @@ paths:
|
||||
- resume
|
||||
- cancel
|
||||
|
||||
/w/{workspace}/jobs/resume/{id}/{resume_id}/{signature}:
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
operationId: resumeSuspendedJobGet
|
||||
@@ -3156,7 +3447,30 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/cancel/{id}/{resume_id}/{signature}:
|
||||
/w/{workspace}/jobs/flow/resume/{id}:
|
||||
post:
|
||||
summary: resume a job for a suspended flow as an owner
|
||||
operationId: resumeSuspendedFlowAsOwner
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"201":
|
||||
description: job resumed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/cancel/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: cancel a job for a suspended flow
|
||||
operationId: cancelSuspendedJobGet
|
||||
@@ -3223,7 +3537,7 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/get_flow/{id}/{resume_id}/{signature}:
|
||||
/w/{workspace}/jobs_u/get_flow/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: get parent flow job of suspended job
|
||||
operationId: getSuspendedJobFlow
|
||||
@@ -3907,7 +4221,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder, app]
|
||||
responses:
|
||||
"200":
|
||||
description: acls
|
||||
@@ -3932,7 +4246,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder, app]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
required: true
|
||||
@@ -3968,7 +4282,7 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder]
|
||||
enum: [script, group_, resource, schedule, variable, flow, folder, app]
|
||||
requestBody:
|
||||
description: acl to add
|
||||
required: true
|
||||
@@ -3988,6 +4302,20 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
|
||||
/w/{workspace}/capture_u/{path}:
|
||||
post:
|
||||
summary: update flow preview capture
|
||||
operationId: updateCapture
|
||||
tags:
|
||||
- capture
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"204":
|
||||
description: flow preview captured
|
||||
|
||||
/w/{workspace}/capture/{path}:
|
||||
put:
|
||||
summary: create flow preview capture
|
||||
@@ -4000,17 +4328,6 @@ paths:
|
||||
responses:
|
||||
"201":
|
||||
description: flow preview capture created
|
||||
post:
|
||||
summary: update flow preview capture
|
||||
operationId: updateCapture
|
||||
tags:
|
||||
- capture
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/Path"
|
||||
responses:
|
||||
"204":
|
||||
description: flow preview captured
|
||||
get:
|
||||
summary: get flow preview capture
|
||||
operationId: getCapture
|
||||
@@ -4178,6 +4495,13 @@ components:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
QueueLimit:
|
||||
name: queue_limit
|
||||
description: |
|
||||
The maximum size of the queue for which the request would get rejected if that job would push it above that limit
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
|
||||
ScriptStartPath:
|
||||
name: script_path_start
|
||||
@@ -4217,6 +4541,18 @@ components:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
Suspended:
|
||||
name: suspended
|
||||
description: filter on suspended jobs
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
Running:
|
||||
name: running
|
||||
description: filter on running jobs
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
After:
|
||||
name: after
|
||||
description: filter on created after (exclusive) timestamp
|
||||
@@ -5298,6 +5634,8 @@ components:
|
||||
enum: [viewer, publisher, anonymous]
|
||||
on_behalf_of:
|
||||
type: string
|
||||
on_behalf_of_email:
|
||||
type: string
|
||||
|
||||
ListableApp:
|
||||
type: object
|
||||
|
||||
@@ -11,24 +11,31 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
jobs::script_path_to_payload,
|
||||
users::{require_owner_of_path, Authed, OptAuthed},
|
||||
variables::build_crypt,
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
extract::{Extension, Json, Path, Query},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
Router,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use magic_crypt::MagicCryptTrait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
use sqlx::{types::Uuid, FromRow};
|
||||
use std::str;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
apps::ListAppQuery,
|
||||
error::{to_anyhow, Error, JsonResult, Result},
|
||||
users::owner_to_token_owner,
|
||||
utils::{not_found_if_none, paginate, Pagination, StripPath},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
http_get_from_hub, list_elems_from_hub, not_found_if_none, paginate, Pagination, StripPath,
|
||||
},
|
||||
};
|
||||
use windmill_queue::{push, JobPayload, RawCode};
|
||||
|
||||
@@ -36,15 +43,24 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_apps))
|
||||
.route("/get/p/*path", get(get_app))
|
||||
.route("/secret_of/*path", get(get_secret_id))
|
||||
.route("/get/v/*id", get(get_app_by_id))
|
||||
.route("/exists/*path", post(exists_app))
|
||||
.route("/exists/*path", get(exists_app))
|
||||
.route("/update/*path", post(update_app))
|
||||
.route("/delete/*path", delete(delete_app))
|
||||
.route("/create", post(create_app))
|
||||
}
|
||||
|
||||
pub fn unauthed_service() -> Router {
|
||||
Router::new().route("/execute_component/*path", post(execute_component))
|
||||
Router::new()
|
||||
.route("/execute_component/*path", post(execute_component))
|
||||
.route("/public_app/:secret", get(get_public_app_by_secret))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/hub/list", get(list_hub_apps))
|
||||
.route("/hub/get/:id", get(get_hub_app_by_id))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Deserialize, Serialize)]
|
||||
@@ -57,6 +73,7 @@ pub struct ListableApp {
|
||||
pub extra_perms: serde_json::Value,
|
||||
pub execution_mode: String,
|
||||
pub starred: bool,
|
||||
pub edited_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
@@ -121,10 +138,10 @@ pub struct EditApp {
|
||||
|
||||
async fn list_apps(
|
||||
authed: Authed,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListAppQuery>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListAppQuery>,
|
||||
) -> JsonResult<Vec<ListableApp>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
@@ -222,9 +239,79 @@ async fn get_app_by_id(
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_public_app_by_secret(
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, secret)): Path<(String, String)>,
|
||||
) -> JsonResult<AppWithLastVersion> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
|
||||
let decrypted = mc
|
||||
.decrypt_bytes_to_bytes(&(hex::decode(secret)?))
|
||||
.map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let bytes = str::from_utf8(&decrypted).map_err(to_anyhow)?;
|
||||
|
||||
let id: i64 = bytes.parse().map_err(to_anyhow)?;
|
||||
|
||||
let app_o = sqlx::query_as!(
|
||||
AppWithLastVersion,
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy,
|
||||
app.extra_perms, app_version.value,
|
||||
app_version.created_at, app_version.created_by from app, app_version
|
||||
WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let app = not_found_if_none(app_o, "App", id.to_string())?;
|
||||
|
||||
let policy = serde_json::from_value::<Policy>(app.policy.clone()).map_err(to_anyhow)?;
|
||||
|
||||
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Json(app))
|
||||
}
|
||||
|
||||
async fn get_secret_id(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let id_o = sqlx::query_scalar!(
|
||||
"SELECT app.id FROM app
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let id = not_found_if_none(id_o, "App", path.to_string())?;
|
||||
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
|
||||
let hx = hex::encode(mc.encrypt_str_to_bytes(id.to_string()));
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(hx)
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(app): Json<CreateApp>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
@@ -271,14 +358,51 @@ async fn create_app(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::CreateApp {
|
||||
workspace: w_id.clone(),
|
||||
path: app.path.clone(),
|
||||
});
|
||||
|
||||
Ok((StatusCode::CREATED, app.path))
|
||||
}
|
||||
|
||||
async fn list_hub_apps(
|
||||
Authed { email, .. }: Authed,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
let flows = list_elems_from_hub(
|
||||
http_client,
|
||||
"https://hub.windmill.dev/searchUiData?approved=true",
|
||||
&email,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(flows))
|
||||
}
|
||||
|
||||
pub async fn get_hub_app_by_id(
|
||||
Authed { email, .. }: Authed,
|
||||
Path(id): Path<i32>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
let value = http_get_from_hub(
|
||||
http_client,
|
||||
&format!("https://hub.windmill.dev/apps/{id}/json"),
|
||||
&email,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(to_anyhow)?;
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
@@ -302,6 +426,8 @@ async fn delete_app(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook
|
||||
.send_message(WebhookMessage::DeleteApp { workspace: w_id.clone(), path: path.to_owned() });
|
||||
|
||||
Ok(format!("app {} deleted", path))
|
||||
}
|
||||
@@ -309,6 +435,7 @@ async fn delete_app(
|
||||
async fn update_app(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditApp>,
|
||||
@@ -398,6 +525,11 @@ async fn update_app(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::UpdateApp {
|
||||
workspace: w_id.clone(),
|
||||
old_path: path.to_owned(),
|
||||
new_path: npath.clone(),
|
||||
});
|
||||
|
||||
Ok(format!("app {} updated (npath: {:?})", path, npath))
|
||||
}
|
||||
@@ -427,12 +559,12 @@ async fn execute_component(
|
||||
Json(payload): Json<ExecuteApp>,
|
||||
) -> Result<String> {
|
||||
match (payload.path.is_some(), payload.raw_code.is_some()) {
|
||||
(true, true) => {
|
||||
(false, false) => {
|
||||
return Err(Error::BadRequest(
|
||||
"path or raw_code is required".to_string(),
|
||||
))
|
||||
}
|
||||
(false, false) => {
|
||||
(true, true) => {
|
||||
return Err(Error::BadRequest(
|
||||
"path and raw_code cannot be set at the same time".to_string(),
|
||||
))
|
||||
@@ -443,18 +575,6 @@ async fn execute_component(
|
||||
let path = path.to_path();
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let policy = not_found_if_none(policy_o, "App", path)?;
|
||||
|
||||
let policy = serde_json::from_value::<Policy>(policy).map_err(to_anyhow)?;
|
||||
|
||||
let policy = if let Some(static_fields) = payload.clone().force_viewer_static_fields {
|
||||
let mut hm = HashMap::new();
|
||||
if let Some(path) = payload.path.clone() {
|
||||
@@ -472,7 +592,17 @@ async fn execute_component(
|
||||
on_behalf_of_email: None,
|
||||
}
|
||||
} else {
|
||||
policy
|
||||
let policy_o = sqlx::query_scalar!(
|
||||
"SELECT policy from app WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?;
|
||||
|
||||
let policy = not_found_if_none(policy_o, "App", path)?;
|
||||
|
||||
serde_json::from_value::<Policy>(policy).map_err(to_anyhow)?
|
||||
};
|
||||
|
||||
let (username, permissioned_as, email) = match policy.execution_mode {
|
||||
@@ -496,7 +626,7 @@ async fn execute_component(
|
||||
})?;
|
||||
(
|
||||
username.clone(),
|
||||
owner_to_token_owner(&username, false),
|
||||
username_to_permissioned_as(&username),
|
||||
email,
|
||||
)
|
||||
}
|
||||
@@ -566,7 +696,7 @@ fn get_on_behalf_of(policy: &Policy) -> Result<(String, String)> {
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(
|
||||
"on_behalf_of is missing in the app policy and is required for anonymous execution"
|
||||
"on_behalf_of_email is missing in the app policy and is required for anonymous execution"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
schedule::clear_schedule,
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -42,6 +43,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/archive/*path", post(archive_flow_by_path))
|
||||
.route("/get/*path", get(get_flow_by_path))
|
||||
.route("/exists/*path", get(exists_flow_by_path))
|
||||
.route("/list_paths", get(list_paths))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
@@ -122,6 +124,24 @@ async fn list_hub_flows(
|
||||
Ok(Json(flows))
|
||||
}
|
||||
|
||||
async fn list_paths(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flows = sqlx::query_scalar!(
|
||||
"SELECT distinct(path) FROM flow WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(flows))
|
||||
}
|
||||
|
||||
pub async fn get_hub_flow_by_id(
|
||||
Authed { email, .. }: Authed,
|
||||
Path(id): Path<i32>,
|
||||
@@ -162,6 +182,7 @@ async fn check_path_conflict<'c>(
|
||||
async fn create_flow(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(nf): Json<NewFlow>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
@@ -202,6 +223,10 @@ async fn create_flow(
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::CreateFlow {
|
||||
workspace: w_id.clone(),
|
||||
path: nf.path.clone(),
|
||||
});
|
||||
|
||||
let tx = user_db.begin(&authed).await?;
|
||||
let (dependency_job_uuid, mut tx) = push(
|
||||
@@ -211,7 +236,7 @@ async fn create_flow(
|
||||
serde_json::Map::new(),
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
windmill_common::users::owner_to_token_owner(&authed.username, false),
|
||||
windmill_common::users::username_to_permissioned_as(&authed.username),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -261,6 +286,7 @@ async fn update_flow(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, flow_path)): Path<(String, StripPath)>,
|
||||
Json(nf): Json<NewFlow>,
|
||||
) -> Result<String> {
|
||||
@@ -280,13 +306,13 @@ async fn update_flow(
|
||||
let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?;
|
||||
sqlx::query!(
|
||||
"UPDATE flow SET path = $1, summary = $2, description = $3, value = $4, edited_by = $5, \
|
||||
edited_at = now(), schema = $6, dependency_job = NULL WHERE path = $7 AND workspace_id = $8",
|
||||
edited_at = now(), schema = $6::text::json, dependency_job = NULL WHERE path = $7 AND workspace_id = $8",
|
||||
nf.path,
|
||||
nf.summary,
|
||||
nf.description,
|
||||
nf.value,
|
||||
&authed.username,
|
||||
schema,
|
||||
schema.and_then(|x| serde_json::to_string(&x).ok()),
|
||||
flow_path,
|
||||
w_id,
|
||||
)
|
||||
@@ -349,6 +375,11 @@ async fn update_flow(
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::UpdateFlow {
|
||||
workspace: w_id.clone(),
|
||||
old_path: flow_path.to_owned(),
|
||||
new_path: nf.path.clone(),
|
||||
});
|
||||
|
||||
let tx = user_db.begin(&authed).await?;
|
||||
let (dependency_job_uuid, mut tx) = push(
|
||||
@@ -358,7 +389,7 @@ async fn update_flow(
|
||||
serde_json::Map::new(),
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
windmill_common::users::owner_to_token_owner(&authed.username, false),
|
||||
windmill_common::users::username_to_permissioned_as(&authed.username),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -431,6 +462,7 @@ async fn exists_flow_by_path(
|
||||
async fn archive_flow_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
@@ -455,6 +487,10 @@ async fn archive_flow_by_path(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::ArchiveFlow {
|
||||
workspace: w_id.clone(),
|
||||
path: path.to_owned(),
|
||||
});
|
||||
|
||||
Ok(format!("Flow {path} archived"))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
users::Authed,
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -19,7 +20,7 @@ use itertools::Itertools;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{self, Error, JsonResult, Result},
|
||||
users::owner_to_token_owner,
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
};
|
||||
|
||||
@@ -37,7 +38,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/delete/:name", delete(delete_folder))
|
||||
.route("/addowner/:name", post(add_owner))
|
||||
.route("/removeowner/:name", post(remove_owner))
|
||||
.route("/is_owner", get(is_owner))
|
||||
.route("/is_owner/*path", get(is_owner))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize, Clone)]
|
||||
@@ -139,13 +140,14 @@ async fn check_name_conflict<'c>(
|
||||
async fn create_folder(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(ng): Json<NewFolder>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
check_name_conflict(&mut tx, &w_id, &ng.name).await?;
|
||||
let owner = owner_to_token_owner(&authed.username, false);
|
||||
let owner = username_to_permissioned_as(&authed.username);
|
||||
let owners = &ng.owners.unwrap_or(vec![owner.clone()]);
|
||||
|
||||
if let Some(extra_perms) = ng.extra_perms.clone() {
|
||||
@@ -193,8 +195,12 @@ async fn create_folder(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::CreateFolder {
|
||||
workspace: w_id.clone(),
|
||||
name: ng.name.clone(),
|
||||
});
|
||||
|
||||
Ok(format!("Created folder {}", ng.name))
|
||||
}
|
||||
|
||||
@@ -245,6 +251,7 @@ pub async fn require_is_owner(
|
||||
async fn update_folder(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
Json(ng): Json<UpdateFolder>,
|
||||
) -> Result<String> {
|
||||
@@ -298,8 +305,12 @@ async fn update_folder(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::UpdateFolder {
|
||||
workspace: w_id.clone(),
|
||||
name: name.to_owned(),
|
||||
});
|
||||
|
||||
Ok(format!("Updated folder {}", name))
|
||||
}
|
||||
|
||||
@@ -416,6 +427,7 @@ async fn get_folder_usage(
|
||||
async fn delete_folder(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -440,6 +452,10 @@ async fn delete_folder(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook
|
||||
.send_message(WebhookMessage::DeleteFolder { workspace: w_id.clone(), name: name.clone() });
|
||||
|
||||
Ok(format!("delete folder at name {}", name))
|
||||
}
|
||||
|
||||
@@ -447,6 +463,7 @@ async fn add_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
Json(Owner { owner }): Json<Owner>,
|
||||
) -> Result<String> {
|
||||
@@ -477,6 +494,10 @@ async fn add_owner(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook
|
||||
.send_message(WebhookMessage::UpdateFolder { workspace: w_id.clone(), name: name.clone() });
|
||||
|
||||
Ok(format!("Added {} to folder {}", owner, name))
|
||||
}
|
||||
|
||||
@@ -510,6 +531,7 @@ async fn remove_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
Json(Owner { owner }): Json<Owner>,
|
||||
) -> Result<String> {
|
||||
@@ -540,5 +562,9 @@ async fn remove_owner(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook
|
||||
.send_message(WebhookMessage::UpdateFolder { workspace: w_id.clone(), name: name.clone() });
|
||||
|
||||
Ok(format!("Removed {} to folder {}", owner, name))
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::users::username_to_permissioned_as;
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
users::owner_to_token_owner,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
};
|
||||
|
||||
@@ -199,7 +199,17 @@ async fn create_group(
|
||||
w_id,
|
||||
ng.name,
|
||||
ng.summary,
|
||||
serde_json::json!({owner_to_token_owner(&authed.username, false): true})
|
||||
serde_json::json!({username_to_permissioned_as(&authed.username): true})
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as!(
|
||||
Group,
|
||||
"INSERT INTO usr_to_group (workspace_id, usr, group_) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
&w_id,
|
||||
&authed.username,
|
||||
ng.name,
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
@@ -282,7 +292,9 @@ async fn delete_group(
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -321,8 +333,9 @@ async fn update_group(
|
||||
Json(eg): Json<EditGroup>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
sqlx::query_as!(
|
||||
@@ -357,8 +370,9 @@ async fn add_user(
|
||||
Json(Username { username: user_username }): Json<Username>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
|
||||
@@ -394,7 +408,9 @@ async fn remove_user(
|
||||
Json(Username { username: user_username }): Json<Username>,
|
||||
) -> Result<String> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
if !authed.is_admin {
|
||||
require_is_owner(&name, &authed.username, &authed.groups, &w_id, &db).await?;
|
||||
}
|
||||
|
||||
not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?;
|
||||
if &name == "all" {
|
||||
|
||||
@@ -10,16 +10,17 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{FromRequest, Path, Query},
|
||||
extract::{FromRequest, Json, Path, Query},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
Extension, Router,
|
||||
};
|
||||
use base64::Engine;
|
||||
use hmac::Mac;
|
||||
use hyper::{HeaderMap, StatusCode};
|
||||
use hyper::{HeaderMap, Request, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use sql_builder::{prelude::*, quote, SqlBuilder};
|
||||
use sqlx::{query_scalar, types::Uuid, Postgres, Transaction};
|
||||
use sqlx::{query_scalar, types::Uuid, FromRow, Postgres, Transaction};
|
||||
use urlencoding::encode;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
@@ -28,16 +29,16 @@ use windmill_common::{
|
||||
flows::FlowValue,
|
||||
oauth2::HmacSha256,
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
users::owner_to_token_owner,
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath},
|
||||
};
|
||||
use windmill_queue::{get_queued_job, push, JobKind, JobPayload, QueuedJob, RawCode};
|
||||
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
users::Authed,
|
||||
users::{require_owner_of_path, Authed},
|
||||
variables::get_workspace_key,
|
||||
BaseUrl,
|
||||
BaseUrl, QueueLimitWaitResult, TimeoutWaitResult,
|
||||
};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -62,8 +63,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/completed/get/:id", get(get_completed_job))
|
||||
.route("/completed/get_result/:id", get(get_completed_job_result))
|
||||
.route("/completed/delete/:id", post(delete_completed_job))
|
||||
.route("/get/:id", get(get_job))
|
||||
.route("/getupdate/:id", get(get_job_update))
|
||||
.route("/flow/resume/:id", post(resume_suspended_flow_as_owner))
|
||||
.route(
|
||||
"/job_signature/:job_id/:resume_id",
|
||||
get(create_job_signature),
|
||||
@@ -94,6 +94,8 @@ pub fn global_service() -> Router {
|
||||
"/get_flow/:job_id/:resume_id/:secret",
|
||||
get(get_suspended_job_flow),
|
||||
)
|
||||
.route("/get/:id", get(get_job))
|
||||
.route("/getupdate/:id", get(get_job_update))
|
||||
}
|
||||
|
||||
async fn get_result_by_id(
|
||||
@@ -101,7 +103,9 @@ async fn get_result_by_id(
|
||||
Query(ResultByIdQuery { skip_direct }): Query<ResultByIdQuery>,
|
||||
Path((w_id, flow_id, node_id)): Path<(String, String, String)>,
|
||||
) -> windmill_common::error::JsonResult<serde_json::Value> {
|
||||
tracing::error!("get_result_by_id_bef: {:?} {:?}", flow_id, node_id);
|
||||
let res = windmill_queue::get_result_by_id(db, skip_direct, w_id, flow_id, node_id).await?;
|
||||
tracing::error!("get_result_by_id: {:?}", res);
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
@@ -214,28 +218,40 @@ pub async fn get_job_by_id<'c>(
|
||||
pub struct CompletedJob {
|
||||
pub workspace_id: String,
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_job: Option<Uuid>,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
||||
pub duration_ms: i32,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub script_hash: Option<ScriptHash>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub script_path: Option<String>,
|
||||
pub args: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logs: Option<String>,
|
||||
pub deleted: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_code: Option<String>,
|
||||
pub canceled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub canceled_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub canceled_reason: Option<String>,
|
||||
pub job_kind: JobKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schedule_path: Option<String>,
|
||||
pub permissioned_as: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_status: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_flow: Option<serde_json::Value>,
|
||||
pub is_flow_step: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<ScriptLang>,
|
||||
pub is_skipped: bool,
|
||||
pub email: String,
|
||||
@@ -249,6 +265,7 @@ pub struct RunJobQuery {
|
||||
parent_job: Option<Uuid>,
|
||||
include_header: Option<String>,
|
||||
invisible_to_owner: Option<bool>,
|
||||
queue_limit: Option<i64>,
|
||||
}
|
||||
|
||||
impl RunJobQuery {
|
||||
@@ -300,6 +317,7 @@ pub struct ListQueueQuery {
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub suspended: Option<bool>,
|
||||
}
|
||||
|
||||
fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> SqlBuilder {
|
||||
@@ -334,6 +352,14 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
|
||||
if let Some(dt) = &lq.created_after {
|
||||
sqlb.and_where_gt("created_at", format!("to_timestamp({})", dt.timestamp()));
|
||||
}
|
||||
|
||||
if let Some(s) = &lq.suspended {
|
||||
if *s {
|
||||
sqlb.and_where_gt("suspend", 0);
|
||||
} else {
|
||||
sqlb.and_where_eq("suspend", 0);
|
||||
}
|
||||
}
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
sqlb.and_where_in(
|
||||
"job_kind",
|
||||
@@ -344,13 +370,55 @@ fn list_queue_jobs_query(w_id: &str, lq: &ListQueueQuery, fields: &[&str]) -> Sq
|
||||
sqlb
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
struct ListableQueuedJob {
|
||||
pub id: Uuid,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub scheduled_for: chrono::DateTime<chrono::Utc>,
|
||||
pub script_hash: Option<ScriptHash>,
|
||||
pub script_path: Option<String>,
|
||||
pub args: Option<serde_json::Value>,
|
||||
pub job_kind: JobKind,
|
||||
pub schedule_path: Option<String>,
|
||||
pub is_flow_step: bool,
|
||||
pub language: Option<ScriptLang>,
|
||||
pub email: String,
|
||||
pub suspend: Option<i32>,
|
||||
}
|
||||
|
||||
async fn list_queue_jobs(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(lq): Query<ListQueueQuery>,
|
||||
) -> error::JsonResult<Vec<QueuedJob>> {
|
||||
let sql = list_queue_jobs_query(&w_id, &lq, &["*"]).sql()?;
|
||||
let jobs = sqlx::query_as::<_, QueuedJob>(&sql).fetch_all(&db).await?;
|
||||
) -> error::JsonResult<Vec<ListableQueuedJob>> {
|
||||
let sql = list_queue_jobs_query(
|
||||
&w_id,
|
||||
&lq,
|
||||
&[
|
||||
"id",
|
||||
"created_by",
|
||||
"created_at",
|
||||
"started_at",
|
||||
"scheduled_for",
|
||||
"script_hash",
|
||||
"script_path",
|
||||
"args",
|
||||
"job_kind",
|
||||
"schedule_path",
|
||||
"permissioned_as",
|
||||
"is_flow_step",
|
||||
"language",
|
||||
"same_worker",
|
||||
"email",
|
||||
"suspend",
|
||||
],
|
||||
)
|
||||
.sql()?;
|
||||
let jobs = sqlx::query_as::<_, ListableQueuedJob>(&sql)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
Ok(Json(jobs))
|
||||
}
|
||||
|
||||
@@ -377,6 +445,7 @@ async fn list_jobs(
|
||||
parent_job: lq.parent_job,
|
||||
order_desc: Some(true),
|
||||
job_kinds: lq.job_kinds,
|
||||
suspended: lq.suspended,
|
||||
},
|
||||
&[
|
||||
"'QueuedJob' as typ",
|
||||
@@ -399,12 +468,12 @@ async fn list_jobs(
|
||||
"job_kind",
|
||||
"schedule_path",
|
||||
"permissioned_as",
|
||||
"flow_status",
|
||||
"is_flow_step",
|
||||
"language",
|
||||
"false as is_skipped",
|
||||
"email",
|
||||
"visible_to_owner",
|
||||
"suspend",
|
||||
],
|
||||
);
|
||||
let sqlc = list_completed_jobs_query(
|
||||
@@ -433,12 +502,12 @@ async fn list_jobs(
|
||||
"job_kind",
|
||||
"schedule_path",
|
||||
"permissioned_as",
|
||||
"flow_status",
|
||||
"is_flow_step",
|
||||
"language",
|
||||
"is_skipped",
|
||||
"email",
|
||||
"visible_to_owner",
|
||||
"null as suspend",
|
||||
],
|
||||
);
|
||||
let sql = format!(
|
||||
@@ -454,12 +523,41 @@ async fn list_jobs(
|
||||
Ok(Json(jobs.into_iter().map(From::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn resume_suspended_flow_as_owner(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, flow_id)): Path<(String, Uuid)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
if !authed.is_admin {
|
||||
require_owner_of_path(
|
||||
&w_id,
|
||||
&authed.username,
|
||||
&authed.groups,
|
||||
&flow.script_path.clone().unwrap_or_else(|| String::new()),
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
insert_resume_job(0, job_id, &flow, value, Some(authed.username), &mut tx).await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn resume_suspended_job(
|
||||
/* unauthed */
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
Query(approver): Query<QueryApprover>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -472,18 +570,7 @@ pub async fn resume_suspended_job(
|
||||
}
|
||||
mac.verify_slice(hex::decode(secret)?.as_ref())
|
||||
.map_err(|_| anyhow::anyhow!("Invalid signature"))?;
|
||||
let flow = sqlx::query!(
|
||||
r#"
|
||||
SELECT id, flow_status, suspend
|
||||
FROM queue
|
||||
WHERE id = ( SELECT parent_job FROM queue WHERE id = $1 UNION ALL SELECT parent_job FROM completed_job WHERE id = $1)
|
||||
FOR UPDATE
|
||||
"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let flow = get_suspended_prent_flow_info(job_id, &mut tx).await?;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -499,6 +586,55 @@ pub async fn resume_suspended_job(
|
||||
return Err(anyhow::anyhow!("resume request already sent").into());
|
||||
}
|
||||
|
||||
insert_resume_job(resume_id, job_id, &flow, value, approver.approver, &mut tx).await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
/* If the flow is currently waiting to be resumed (`FlowStatusModule::WaitingForEvents`)
|
||||
* the suspend column must be set to the number of resume messages waited on.
|
||||
*
|
||||
* The flow's queue row is locked in this transaction because to avoid race conditions around
|
||||
* the suspend column.
|
||||
* That is, a job needs one event but it hasn't arrived, a worker counts zero events before
|
||||
* entering WaitingForEvents. Then this message arrives but the job isn't in WaitingForEvents
|
||||
* yet so the suspend counter isn't updated. Then the job enters WaitingForEvents expecting
|
||||
* one event to arrive based on the count that is no longer correct. */
|
||||
async fn resume_immediately_if_relevant<'c>(
|
||||
flow: FlowInfo,
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
Ok(
|
||||
if let Some(suspend) = (0 < flow.suspend).then(|| flow.suspend - 1) {
|
||||
let status =
|
||||
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
|
||||
.context("deserialize flow status")?;
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
|
||||
{
|
||||
sqlx::query!(
|
||||
"UPDATE queue SET suspend = $1 WHERE id = $2",
|
||||
suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(tx)
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
async fn insert_resume_job<'c>(
|
||||
resume_id: u32,
|
||||
job_id: Uuid,
|
||||
flow: &FlowInfo,
|
||||
value: serde_json::Value,
|
||||
approver: Option<String>,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO resume_job
|
||||
@@ -510,38 +646,71 @@ pub async fn resume_suspended_job(
|
||||
job_id,
|
||||
flow.id,
|
||||
value,
|
||||
approver.approver
|
||||
approver
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.execute(tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/* If the flow is currently waiting to be resumed (`FlowStatusModule::WaitingForEvents`)
|
||||
* the suspend column must be set to the number of resume messages waited on.
|
||||
*
|
||||
* The flow's queue row is locked in this transaction because to avoid race conditions around
|
||||
* the suspend column.
|
||||
* That is, a job needs one event but it hasn't arrived, a worker counts zero events before
|
||||
* entering WaitingForEvents. Then this message arrives but the job isn't in WaitingForEvents
|
||||
* yet so the suspend counter isn't updated. Then the job enters WaitingForEvents expecting
|
||||
* one event to arrive based on the count that is no longer correct. */
|
||||
if let Some(suspend) = (0 < flow.suspend).then(|| flow.suspend - 1) {
|
||||
let status =
|
||||
serde_json::from_value::<FlowStatus>(flow.flow_status.context("no flow status")?)
|
||||
.context("deserialize flow status")?;
|
||||
if matches!(status.current_step(), Some(FlowStatusModule::WaitingForEvents { job, .. }) if job == &job_id)
|
||||
{
|
||||
sqlx::query!(
|
||||
"UPDATE queue SET suspend = $1 WHERE id = $2",
|
||||
suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
}
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct FlowInfo {
|
||||
id: Uuid,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
suspend: i32,
|
||||
script_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_suspended_prent_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<FlowInfo> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
SELECT id, flow_status, suspend, script_path
|
||||
FROM queue
|
||||
WHERE id = ( SELECT parent_job FROM queue WHERE id = $1 UNION ALL SELECT parent_job FROM completed_job WHERE id = $1)
|
||||
FOR UPDATE
|
||||
"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
Ok(flow)
|
||||
}
|
||||
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<(FlowInfo, Uuid)> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
SELECT id, flow_status, suspend, script_path
|
||||
FROM queue
|
||||
WHERE id = $1
|
||||
"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let job_id = flow
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
.and_then(|s| match s.modules.get(s.step as usize) {
|
||||
Some(FlowStatusModule::WaitingForEvents { job, .. }) => Some(job.to_owned()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(job_id) = job_id {
|
||||
Ok((flow, job_id))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
pub async fn cancel_suspended_job(
|
||||
@@ -731,7 +900,7 @@ fn build_resume_url(
|
||||
approver: &str,
|
||||
base_url: &str,
|
||||
) -> String {
|
||||
format!("{base_url}/api/w/{w_id}/jobs/{op}/{job_id}/{resume_id}/{signature}{approver}")
|
||||
format!("{base_url}/api/w/{w_id}/jobs_u/{op}/{job_id}/{resume_id}/{signature}{approver}")
|
||||
}
|
||||
|
||||
pub async fn get_resume_urls(
|
||||
@@ -810,12 +979,12 @@ struct UnifiedJob {
|
||||
job_kind: JobKind,
|
||||
schedule_path: Option<String>,
|
||||
permissioned_as: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
is_flow_step: bool,
|
||||
language: Option<ScriptLang>,
|
||||
is_skipped: bool,
|
||||
email: String,
|
||||
visible_to_owner: bool,
|
||||
suspend: Option<i32>,
|
||||
}
|
||||
|
||||
impl From<UnifiedJob> for Job {
|
||||
@@ -835,6 +1004,7 @@ impl From<UnifiedJob> for Job {
|
||||
args: uj.args,
|
||||
result: None,
|
||||
logs: None,
|
||||
flow_status: None,
|
||||
deleted: uj.deleted,
|
||||
canceled: uj.canceled,
|
||||
canceled_by: uj.canceled_by,
|
||||
@@ -843,7 +1013,6 @@ impl From<UnifiedJob> for Job {
|
||||
job_kind: uj.job_kind,
|
||||
schedule_path: uj.schedule_path,
|
||||
permissioned_as: uj.permissioned_as,
|
||||
flow_status: uj.flow_status,
|
||||
raw_flow: None,
|
||||
is_flow_step: uj.is_flow_step,
|
||||
language: uj.language,
|
||||
@@ -864,6 +1033,7 @@ impl From<UnifiedJob> for Job {
|
||||
running: uj.running.unwrap(),
|
||||
scheduled_for: uj.scheduled_for.unwrap(),
|
||||
logs: None,
|
||||
flow_status: None,
|
||||
raw_code: None,
|
||||
raw_lock: None,
|
||||
canceled: uj.canceled,
|
||||
@@ -873,7 +1043,6 @@ impl From<UnifiedJob> for Job {
|
||||
job_kind: uj.job_kind,
|
||||
schedule_path: uj.schedule_path,
|
||||
permissioned_as: uj.permissioned_as,
|
||||
flow_status: uj.flow_status,
|
||||
raw_flow: None,
|
||||
is_flow_step: uj.is_flow_step,
|
||||
language: uj.language,
|
||||
@@ -881,6 +1050,7 @@ impl From<UnifiedJob> for Job {
|
||||
pre_run_error: None,
|
||||
email: uj.email,
|
||||
visible_to_owner: uj.visible_to_owner,
|
||||
suspend: uj.suspend,
|
||||
}),
|
||||
t => panic!("job type {} not valid", t),
|
||||
}
|
||||
@@ -909,20 +1079,19 @@ struct PreviewFlow {
|
||||
pub struct QueryOrBody<D>(pub Option<D>);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<D, B> FromRequest<B> for QueryOrBody<D>
|
||||
impl<S, D> FromRequest<S, axum::body::Body> for QueryOrBody<D>
|
||||
where
|
||||
D: DeserializeOwned,
|
||||
B: Send + axum::body::HttpBody,
|
||||
<B as axum::body::HttpBody>::Data: Send,
|
||||
<B as axum::body::HttpBody>::Error: Into<axum::BoxError>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(
|
||||
req: &mut axum::extract::RequestParts<B>,
|
||||
req: Request<axum::body::Body>,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
return if req.method() == axum::http::Method::GET {
|
||||
let Query(InPayload { payload }) = Query::from_request(req)
|
||||
let Query(InPayload { payload }) = Query::from_request(req, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
payload
|
||||
@@ -934,7 +1103,7 @@ where
|
||||
})
|
||||
.unwrap_or(Ok(QueryOrBody(None)))
|
||||
} else {
|
||||
Json::from_request(req)
|
||||
Json::from_request(req, state)
|
||||
.await
|
||||
.map(|Json(v)| QueryOrBody(Some(v)))
|
||||
.map_err(IntoResponse::into_response)
|
||||
@@ -946,7 +1115,9 @@ where
|
||||
}
|
||||
|
||||
fn decode_payload<D: DeserializeOwned, T: AsRef<[u8]>>(t: T) -> anyhow::Result<D> {
|
||||
let vec = base64::decode_config(&t, base64::URL_SAFE).context("invalid base64")?;
|
||||
let vec = base64::engine::general_purpose::URL_SAFE
|
||||
.decode(t)
|
||||
.context("invalid base64")?;
|
||||
serde_json::from_slice(vec.as_slice()).context("invalid json")
|
||||
}
|
||||
}
|
||||
@@ -955,9 +1126,9 @@ pub async fn run_flow_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, flow_path)): Path<(String, StripPath)>,
|
||||
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
let flow_path = flow_path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -971,7 +1142,7 @@ pub async fn run_flow_by_path(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
@@ -989,9 +1160,9 @@ pub async fn run_job_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, script_path)): Path<(String, StripPath)>,
|
||||
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
let script_path = script_path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -1006,7 +1177,7 @@ pub async fn run_job_by_path(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
@@ -1020,16 +1191,66 @@ pub async fn run_job_by_path(
|
||||
Ok((StatusCode::CREATED, uuid.to_string()))
|
||||
}
|
||||
|
||||
struct Guard {
|
||||
done: bool,
|
||||
id: Uuid,
|
||||
w_id: String,
|
||||
db: UserDB,
|
||||
authed: Authed,
|
||||
}
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
if !&self.done {
|
||||
let id = self.id;
|
||||
let username = self.authed.username.clone();
|
||||
let w_id = self.w_id.clone();
|
||||
let db = self.db.clone();
|
||||
let authed = self.authed.clone();
|
||||
|
||||
tracing::info!("http connection broke, marking job {id} as canceled");
|
||||
tokio::spawn(async move {
|
||||
let tx = db.begin(&authed).await.ok();
|
||||
if let Some(mut tx) = tx {
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE queue SET canceled = true, canceled_reason = 'http connection broke', canceled_by = $1 WHERE id = $2 AND workspace_id = $3",
|
||||
username,
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_wait_result<T>(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
timeout: i32,
|
||||
uuid: Uuid,
|
||||
Path((w_id, _)): Path<(String, T)>,
|
||||
) -> error::JsonResult<serde_json::Value> {
|
||||
let mut result = None;
|
||||
for i in 0..48 {
|
||||
let iters = if timeout <= 0 {
|
||||
20
|
||||
} else if timeout <= 1 {
|
||||
timeout * 10
|
||||
} else {
|
||||
10 + ((timeout - 1) * 2)
|
||||
};
|
||||
let mut g = Guard {
|
||||
done: false,
|
||||
id: uuid,
|
||||
w_id: w_id.clone(),
|
||||
db: user_db.clone(),
|
||||
authed: authed.clone(),
|
||||
};
|
||||
for i in 0..iters {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
|
||||
result = sqlx::query_scalar!(
|
||||
"SELECT result FROM completed_job WHERE id = $1 AND workspace_id = $2",
|
||||
uuid,
|
||||
@@ -1038,6 +1259,7 @@ async fn run_wait_result<T>(
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.flatten();
|
||||
drop(tx);
|
||||
|
||||
if result.is_some() {
|
||||
break;
|
||||
@@ -1046,20 +1268,45 @@ async fn run_wait_result<T>(
|
||||
tokio::time::sleep(core::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
if let Some(result) = result {
|
||||
g.done = true;
|
||||
Ok(Json(result))
|
||||
} else {
|
||||
Err(Error::ExecutionErr("timeout after 20s".to_string()))
|
||||
Err(Error::ExecutionErr(format!("timeout after {}s", timeout)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_queue_too_long(db: DB, queue_limit: Option<i64>) -> error::Result<()> {
|
||||
if let Some(limit) = queue_limit {
|
||||
let count = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM queue WHERE canceled = false AND (scheduled_for <= now()
|
||||
OR (suspend_until IS NOT NULL
|
||||
AND ( suspend <= 0
|
||||
OR suspend_until <= now())))",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if count > queue_limit.unwrap() {
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Number of queued job is too high: {count} > {limit}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn run_wait_result_job_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(timeout): Extension<Arc<TimeoutWaitResult>>,
|
||||
Extension(queue_limit): Extension<Arc<QueueLimitWaitResult>>,
|
||||
Path((w_id, script_path)): Path<(String, StripPath)>,
|
||||
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> error::JsonResult<serde_json::Value> {
|
||||
check_queue_too_long(db, queue_limit.0.or(run_query.queue_limit)).await?;
|
||||
let script_path = script_path.to_path();
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let job_payload = script_path_to_payload(script_path, &mut tx, &w_id).await?;
|
||||
@@ -1074,7 +1321,7 @@ pub async fn run_wait_result_job_by_path(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
@@ -1086,17 +1333,28 @@ pub async fn run_wait_result_job_by_path(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
run_wait_result(authed, Extension(user_db), uuid, Path((w_id, script_path))).await
|
||||
run_wait_result(
|
||||
authed,
|
||||
Extension(user_db),
|
||||
timeout.0,
|
||||
uuid,
|
||||
Path((w_id, script_path)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn run_wait_result_job_by_hash(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(timeout): Extension<Arc<TimeoutWaitResult>>,
|
||||
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
|
||||
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> error::JsonResult<serde_json::Value> {
|
||||
check_queue_too_long(db, run_query.queue_limit).await?;
|
||||
|
||||
let hash = script_hash.0;
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let path = get_path_for_hash(&mut tx, &w_id, hash).await?;
|
||||
@@ -1110,7 +1368,7 @@ pub async fn run_wait_result_job_by_hash(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
@@ -1122,7 +1380,14 @@ pub async fn run_wait_result_job_by_hash(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
run_wait_result(authed, Extension(user_db), uuid, Path((w_id, script_hash))).await
|
||||
run_wait_result(
|
||||
authed,
|
||||
Extension(user_db),
|
||||
timeout.0,
|
||||
uuid,
|
||||
Path((w_id, script_hash)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// a similar function exists on the worker
|
||||
@@ -1144,9 +1409,9 @@ async fn run_preview_job(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(preview): Json<Preview>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(preview): Json<Preview>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
|
||||
@@ -1164,7 +1429,7 @@ async fn run_preview_job(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
None,
|
||||
@@ -1182,9 +1447,9 @@ async fn run_preview_flow_job(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(raw_flow): Json<PreviewFlow>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(raw_flow): Json<PreviewFlow>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let scheduled_for = run_query.get_scheduled_for(&mut tx).await?;
|
||||
@@ -1197,7 +1462,7 @@ async fn run_preview_flow_job(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
None,
|
||||
@@ -1215,9 +1480,9 @@ pub async fn run_job_by_hash(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
|
||||
axum::Json(args): axum::Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
Query(run_query): Query<RunJobQuery>,
|
||||
headers: HeaderMap,
|
||||
Json(args): Json<Option<serde_json::Map<String, serde_json::Value>>>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
let hash = script_hash.0;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -1232,7 +1497,7 @@ pub async fn run_job_by_hash(
|
||||
args,
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
scheduled_for,
|
||||
None,
|
||||
run_query.parent_job,
|
||||
@@ -1370,7 +1635,9 @@ pub struct ListCompletedQuery {
|
||||
pub job_kinds: Option<String>,
|
||||
pub is_skipped: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
pub suspended: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_completed_jobs(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
@@ -1463,7 +1730,7 @@ async fn delete_completed_job(
|
||||
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let job_o = sqlx::query_as::<_, CompletedJob>(
|
||||
"UPDATE completed_job SET logs = '', deleted = true WHERE id = $1 AND workspace_id = $2 \
|
||||
"UPDATE completed_job SET logs = '', result = null, deleted = true WHERE id = $1 AND workspace_id = $2 \
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(id)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
use argon2::Argon2;
|
||||
use axum::{handler::Handler, middleware::from_extractor, routing::get, Extension, Router};
|
||||
use axum::{middleware::from_extractor, routing::get, Extension, Router};
|
||||
use db::DB;
|
||||
use git_version::git_version;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
@@ -21,6 +21,7 @@ use crate::{
|
||||
oauth2::{build_oauth_clients, SlackVerifier},
|
||||
tracing_init::{MyMakeSpan, MyOnResponse},
|
||||
users::{Authed, OptAuthed},
|
||||
webhook_util::{WebhookShared, WebhookUtil},
|
||||
};
|
||||
|
||||
mod apps;
|
||||
@@ -42,6 +43,7 @@ mod tracing_init;
|
||||
mod users;
|
||||
mod utils;
|
||||
mod variables;
|
||||
mod webhook_util;
|
||||
mod worker_ping;
|
||||
mod workspaces;
|
||||
|
||||
@@ -53,6 +55,8 @@ pub struct IsSecure(bool);
|
||||
pub struct CookieDomain(Option<String>);
|
||||
pub struct CloudHosted(bool);
|
||||
pub struct ContentSecurityPolicy(String);
|
||||
pub struct TimeoutWaitResult(i32);
|
||||
pub struct QueueLimitWaitResult(Option<i64>);
|
||||
|
||||
pub use users::delete_expired_items_perdiodically;
|
||||
|
||||
@@ -91,20 +95,21 @@ pub async fn run_server(
|
||||
.layer(Extension(auth_cache.clone()))
|
||||
.layer(Extension(basic_clients))
|
||||
.layer(Extension(Arc::new(BaseUrl(base_url.to_string()))))
|
||||
.layer(Extension(Arc::new(ContentSecurityPolicy(
|
||||
std::env::var("SERVE_CSP").unwrap_or("".to_owned()),
|
||||
))))
|
||||
.layer(Extension(Arc::new(CloudHosted(
|
||||
std::env::var("CLOUD_HOSTED").is_ok(),
|
||||
))))
|
||||
.layer(Extension(Arc::new(IsSecure(
|
||||
base_url.starts_with("https://"),
|
||||
))))
|
||||
.layer(Extension(Arc::new(ContentSecurityPolicy(
|
||||
std::env::var("SERVE_CSP").unwrap_or("".to_owned()),
|
||||
))))
|
||||
.layer(Extension(Arc::new(CookieDomain(
|
||||
std::env::var("COOKIE_DOMAIN").ok(),
|
||||
))))
|
||||
.layer(Extension(http_client))
|
||||
.layer(CookieManagerLayer::new());
|
||||
.layer(CookieManagerLayer::new())
|
||||
.layer(Extension(WebhookShared::new(rx.resubscribe())));
|
||||
// build our application with a route
|
||||
let app = Router::new()
|
||||
.nest(
|
||||
@@ -114,7 +119,21 @@ pub async fn run_server(
|
||||
"/w/:workspace_id",
|
||||
Router::new()
|
||||
.nest("/scripts", scripts::workspaced_service())
|
||||
.nest("/jobs", jobs::workspaced_service())
|
||||
.nest(
|
||||
"/jobs",
|
||||
jobs::workspaced_service()
|
||||
.layer(Extension(Arc::new(TimeoutWaitResult(
|
||||
std::env::var("TIMEOUT_WAIT_RESULT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok())
|
||||
.unwrap_or(20),
|
||||
))))
|
||||
.layer(Extension(Arc::new(QueueLimitWaitResult(
|
||||
std::env::var("QUEUE_LIMIT_WAIT_RESULT")
|
||||
.ok()
|
||||
.and_then(|x| x.parse().ok()),
|
||||
)))),
|
||||
)
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
@@ -127,11 +146,12 @@ pub async fn run_server(
|
||||
.nest("/audit", audit::workspaced_service())
|
||||
.nest("/acls", granular_acls::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service())
|
||||
.nest("/flows", flows::workspaced_service())
|
||||
.nest("/capture", capture::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service())
|
||||
.nest("/favorites", favorite::workspaced_service())
|
||||
.nest("/folders", folders::workspaced_service()),
|
||||
.nest("/folders", folders::workspaced_service())
|
||||
.route_layer(from_extractor::<WebhookUtil>()),
|
||||
)
|
||||
.nest("/workspaces", workspaces::global_service())
|
||||
.nest(
|
||||
@@ -141,15 +161,16 @@ pub async fn run_server(
|
||||
.nest("/workers", worker_ping::global_service())
|
||||
.nest("/scripts", scripts::global_service())
|
||||
.nest("/flows", flows::global_service())
|
||||
.nest("/apps", apps::global_service())
|
||||
.nest("/schedules", schedule::global_service())
|
||||
.route_layer(from_extractor::<Authed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
.nest(
|
||||
"/w/:workspace_id/apps",
|
||||
"/w/:workspace_id/apps_u",
|
||||
apps::unauthed_service().layer(from_extractor::<OptAuthed>()),
|
||||
)
|
||||
.nest("/w/:workspace_id/jobs", jobs::global_service())
|
||||
.nest("/w/:workspace_id/capture", capture::global_service())
|
||||
.nest("/w/:workspace_id/jobs_u", jobs::global_service())
|
||||
.nest("/w/:workspace_id/capture_u", capture::global_service())
|
||||
.nest(
|
||||
"/auth",
|
||||
users::make_unauthed_service().layer(Extension(argon2)),
|
||||
@@ -161,7 +182,7 @@ pub async fn run_server(
|
||||
.route("/version", get(git_v))
|
||||
.route("/openapi.yaml", get(openapi)),
|
||||
)
|
||||
.fallback(static_assets::static_handler.into_service())
|
||||
.fallback(static_assets::static_handler)
|
||||
.layer(middleware_stack);
|
||||
|
||||
let instance_name = rd_string(5);
|
||||
@@ -185,7 +206,7 @@ async fn git_v() -> &'static str {
|
||||
}
|
||||
|
||||
async fn openapi() -> &'static str {
|
||||
include_str!("../openapi.yaml")
|
||||
include_str!("../openapi-deref.yaml")
|
||||
}
|
||||
pub async fn migrate_db(db: &DB) -> anyhow::Result<()> {
|
||||
db::migrate(db).await?;
|
||||
|
||||
@@ -11,10 +11,12 @@ use std::{collections::HashMap, fmt::Debug};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use axum::{
|
||||
async_trait,
|
||||
body::Bytes,
|
||||
extract::{Extension, FromRequest, Path, Query, RequestParts},
|
||||
extract::{Extension, Path, Query},
|
||||
response::Redirect,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
@@ -485,10 +487,10 @@ struct VariablePath {
|
||||
async fn refresh_token(
|
||||
authed: Authed,
|
||||
Path((w_id, id)): Path<(String, i32)>,
|
||||
Json(VariablePath { path }): Json<VariablePath>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
Json(VariablePath { path }): Json<VariablePath>,
|
||||
) -> error::Result<String> {
|
||||
let tx = user_db.begin(&authed).await?;
|
||||
|
||||
@@ -606,9 +608,9 @@ pub struct OAuthCallback {
|
||||
async fn connect_callback(
|
||||
cookies: Cookies,
|
||||
Path(client_name): Path<String>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
) -> error::JsonResult<TokenResponse> {
|
||||
let client_w_scopes = &clients
|
||||
.connects
|
||||
@@ -628,10 +630,10 @@ async fn connect_slack_callback(
|
||||
Path(w_id): Path<String>,
|
||||
authed: Authed,
|
||||
cookies: Cookies,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
) -> error::Result<String> {
|
||||
let client = clients
|
||||
.slack
|
||||
@@ -656,19 +658,18 @@ async fn connect_slack_callback(
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO group_
|
||||
(workspace_id, name, summary)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
"INSERT INTO folder
|
||||
(workspace_id, name, owners, extra_perms)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
|
||||
&w_id,
|
||||
"slack",
|
||||
"The group that runs the script triggered by the slack /windmill command.
|
||||
Share scripts to this group to make them executable from slack and add
|
||||
members to this group to let them manage the slack related owner space."
|
||||
"slack_bot",
|
||||
&[],
|
||||
serde_json::json!({})
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
let token_path = "g/slack/bot_token";
|
||||
let token_path = "f/slack_bot/bot_token";
|
||||
let mc = build_crypt(&mut tx, &w_id).await?;
|
||||
let value = encrypt(&mc, &token.bot.bot_access_token);
|
||||
sqlx::query!(
|
||||
@@ -718,14 +719,17 @@ pub struct SlackSig {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<B> FromRequest<B> for SlackSig
|
||||
impl<S> FromRequestParts<S> for SlackSig
|
||||
where
|
||||
B: Send,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
||||
let hm = req.headers();
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
_state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
let hm = &parts.headers;
|
||||
Ok(Self {
|
||||
sig: hm
|
||||
.get("X-Slack-Signature")
|
||||
@@ -834,13 +838,13 @@ pub struct UserInfo {
|
||||
|
||||
async fn login_callback(
|
||||
Path(client_name): Path<String>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
cookies: Cookies,
|
||||
Extension(clients): Extension<Arc<AllClients>>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(http_client): Extension<Client>,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
Extension(cookie_domain): Extension<Arc<CookieDomain>>,
|
||||
Json(callback): Json<OAuthCallback>,
|
||||
) -> error::Result<String> {
|
||||
let client_w_config = &clients
|
||||
.logins
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -32,6 +33,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/exists/*path", get(exists_resource))
|
||||
.route("/get_value/*path", get(get_resource_value))
|
||||
.route("/update/*path", post(update_resource))
|
||||
.route("/update_value/*path", post(update_resource_value))
|
||||
.route("/delete/*path", delete(delete_resource))
|
||||
.route("/create", post(create_resource))
|
||||
.route("/type/list", get(list_resource_types))
|
||||
@@ -262,6 +264,7 @@ async fn check_path_conflict<'c>(
|
||||
async fn create_resource(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(resource): Json<CreateResource>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
@@ -292,6 +295,11 @@ async fn create_resource(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::CreateResource {
|
||||
workspace: w_id.clone(),
|
||||
path: resource.path.clone(),
|
||||
});
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("resource {} created", resource.path),
|
||||
@@ -301,6 +309,7 @@ async fn create_resource(
|
||||
async fn delete_resource(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
@@ -332,12 +341,18 @@ async fn delete_resource(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::DeleteResource {
|
||||
workspace: w_id.clone(),
|
||||
path: path.to_owned(),
|
||||
});
|
||||
|
||||
Ok(format!("resource {} deleted", path))
|
||||
}
|
||||
|
||||
async fn update_resource(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditResource>,
|
||||
@@ -364,11 +379,6 @@ async fn update_resource(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut tx).await?;
|
||||
|
||||
let npath = not_found_if_none(npath_o, "Resource", path)?;
|
||||
|
||||
if let Some(npath) = ns.path {
|
||||
if npath != path {
|
||||
check_path_conflict(&mut tx, &w_id, &npath).await?;
|
||||
@@ -387,6 +397,11 @@ async fn update_resource(
|
||||
}
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut tx).await?;
|
||||
|
||||
let npath = not_found_if_none(npath_o, "Resource", path)?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
@@ -399,16 +414,65 @@ async fn update_resource(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::UpdateResource {
|
||||
workspace: w_id.clone(),
|
||||
old_path: path.to_owned(),
|
||||
new_path: npath.clone(),
|
||||
});
|
||||
|
||||
Ok(format!("resource {} updated (npath: {:?})", path, npath))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Deserialize)]
|
||||
struct UpdateResource {
|
||||
value: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
async fn update_resource_value(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(nv): Json<UpdateResource>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE resource SET value = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
nv.value,
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
"resources.update",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::UpdateResource {
|
||||
workspace: w_id.clone(),
|
||||
old_path: path.to_owned(),
|
||||
new_path: path.to_owned(),
|
||||
});
|
||||
|
||||
Ok(format!("value of resource {} updated", path))
|
||||
}
|
||||
|
||||
async fn list_resource_types(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<ResourceType>> {
|
||||
let rows = sqlx::query_as!(
|
||||
ResourceType,
|
||||
"SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') ORDER \
|
||||
"SELECT * from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') ORDER \
|
||||
BY name",
|
||||
&w_id
|
||||
)
|
||||
@@ -423,7 +487,7 @@ async fn list_resource_types_names(
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let rows = sqlx::query_scalar!(
|
||||
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter') \
|
||||
"SELECT name from resource_type WHERE (workspace_id = $1 OR workspace_id = 'starter' OR workspace_id = 'admins') \
|
||||
ORDER BY name",
|
||||
&w_id
|
||||
)
|
||||
@@ -443,7 +507,7 @@ async fn get_resource_type(
|
||||
let resource_type_o = sqlx::query_as!(
|
||||
ResourceType,
|
||||
"SELECT * from resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter')",
|
||||
'starter' OR workspace_id = 'admins')",
|
||||
&name,
|
||||
&w_id
|
||||
)
|
||||
@@ -460,7 +524,8 @@ async fn exists_resource_type(
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> JsonResult<bool> {
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND workspace_id = $2)",
|
||||
"SELECT EXISTS(SELECT 1 FROM resource_type WHERE name = $1 AND (workspace_id = $2 OR workspace_id = \
|
||||
'starter' OR workspace_id = 'admins'))",
|
||||
name,
|
||||
w_id
|
||||
)
|
||||
@@ -474,6 +539,7 @@ async fn exists_resource_type(
|
||||
async fn create_resource_type(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(resource_type): Json<CreateResourceType>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
@@ -504,6 +570,8 @@ async fn create_resource_type(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::CreateResourceType { name: resource_type.name.clone() });
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("resource_type {} created", resource_type.name),
|
||||
@@ -535,6 +603,7 @@ async fn check_rt_path_conflict<'c>(
|
||||
async fn delete_resource_type(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
@@ -559,6 +628,7 @@ async fn delete_resource_type(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::DeleteResourceType { name: name.clone() });
|
||||
|
||||
Ok(format!("resource_type {} deleted", name))
|
||||
}
|
||||
@@ -566,6 +636,7 @@ async fn delete_resource_type(
|
||||
async fn update_resource_type(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, name)): Path<(String, String)>,
|
||||
Json(ns): Json<EditResourceType>,
|
||||
) -> Result<String> {
|
||||
@@ -595,6 +666,7 @@ async fn update_resource_type(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::UpdateResourceType { name: name.clone() });
|
||||
|
||||
Ok(format!("resource_type {} updated", name))
|
||||
}
|
||||
|
||||
@@ -383,7 +383,7 @@ pub struct PreviewPayload {
|
||||
}
|
||||
|
||||
fn get_offset(offset: Option<i32>) -> FixedOffset {
|
||||
FixedOffset::west(offset.unwrap_or(0) * 60)
|
||||
FixedOffset::west_opt(offset.unwrap_or(0) * 60).expect("Invalid offset")
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
schedule::clear_schedule,
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
};
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
@@ -36,7 +37,7 @@ use windmill_common::{
|
||||
to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Script, ScriptHash,
|
||||
ScriptKind, ScriptLang,
|
||||
},
|
||||
users::owner_to_token_owner,
|
||||
users::username_to_permissioned_as,
|
||||
utils::{
|
||||
list_elems_from_hub, not_found_if_none, paginate, require_admin, Pagination, StripPath,
|
||||
},
|
||||
@@ -72,6 +73,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get/h/:hash", get(get_script_by_hash))
|
||||
.route("/raw/h/:hash", get(raw_script_by_hash))
|
||||
.route("/deployment_status/h/:hash", get(get_deployment_status))
|
||||
.route("/list_paths", get(list_paths))
|
||||
}
|
||||
async fn list_scripts(
|
||||
authed: Authed,
|
||||
@@ -183,6 +185,7 @@ fn hash_script(ns: &NewScript) -> i64 {
|
||||
async fn create_script(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(ns): Json<NewScript>,
|
||||
@@ -373,7 +376,7 @@ async fn create_script(
|
||||
serde_json::Map::new(),
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
owner_to_token_owner(&authed.username, false),
|
||||
username_to_permissioned_as(&authed.username),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -399,6 +402,11 @@ async fn create_script(
|
||||
Some([("hash", hash.to_string().as_str())].into()),
|
||||
)
|
||||
.await?;
|
||||
webhook.send_message(WebhookMessage::UpdateScript {
|
||||
workspace: w_id.clone(),
|
||||
path: ns.path.clone(),
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
} else {
|
||||
audit_log(
|
||||
&mut tx,
|
||||
@@ -416,6 +424,11 @@ async fn create_script(
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
webhook.send_message(WebhookMessage::CreateScript {
|
||||
workspace: w_id.clone(),
|
||||
path: ns.path.clone(),
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
@@ -464,15 +477,30 @@ async fn get_script_by_path(
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
async fn list_paths(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<String>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let scripts = sqlx::query_scalar!(
|
||||
"SELECT distinct(path) FROM script WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_all(&mut tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(scripts))
|
||||
}
|
||||
|
||||
async fn raw_script_by_path(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path
|
||||
.to_path()
|
||||
.strip_suffix(".ts")
|
||||
.ok_or_else(|| Error::BadRequest("Raw script path must end with .ts".to_string()))?;
|
||||
let path = path.to_path().split(".").next().unwrap_or_default();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let content_o = sqlx::query_scalar!(
|
||||
@@ -584,6 +612,7 @@ async fn get_deployment_status(
|
||||
|
||||
async fn archive_script_by_path(
|
||||
authed: Authed,
|
||||
webhook: WebhookUtil,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
@@ -610,6 +639,10 @@ async fn archive_script_by_path(
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
webhook.send_message(WebhookMessage::DeleteScript {
|
||||
workspace: w_id.clone(),
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -617,6 +650,7 @@ async fn archive_script_by_path(
|
||||
async fn archive_script_by_hash(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||
) -> JsonResult<Script> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
@@ -641,12 +675,18 @@ async fn archive_script_by_hash(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::DeleteScript {
|
||||
workspace: w_id.clone(),
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
async fn delete_script_by_hash(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, hash)): Path<(String, ScriptHash)>,
|
||||
) -> JsonResult<Script> {
|
||||
@@ -675,6 +715,11 @@ async fn delete_script_by_hash(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::DeleteScript {
|
||||
workspace: w_id.clone(),
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
use axum::{
|
||||
body::{self, BoxBody},
|
||||
http::{header, response::Builder, Response, Uri},
|
||||
extract::OriginalUri,
|
||||
http::{header, response::Builder, Response},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
@@ -20,26 +21,28 @@ use std::sync::Arc;
|
||||
|
||||
// static_handler is a handler that serves static files from the
|
||||
pub async fn static_handler(
|
||||
uri: Uri,
|
||||
Extension(is_secure): Extension<Arc<IsSecure>>,
|
||||
Extension(is_cloud_hosted): Extension<Arc<CloudHosted>>,
|
||||
Extension(csp): Extension<Arc<ContentSecurityPolicy>>,
|
||||
) -> impl IntoResponse {
|
||||
let path = uri.path().trim_start_matches('/').to_string();
|
||||
OriginalUri(original_uri): OriginalUri,
|
||||
) -> StaticFile {
|
||||
let path = original_uri.path().trim_start_matches('/').to_string();
|
||||
StaticFile(path, is_secure.0, is_cloud_hosted.0, csp)
|
||||
}
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "../../frontend/build/"]
|
||||
struct Asset;
|
||||
pub struct StaticFile<T>(pub T, pub bool, pub bool, pub Arc<ContentSecurityPolicy>);
|
||||
pub struct StaticFile(
|
||||
pub String,
|
||||
pub bool,
|
||||
pub bool,
|
||||
pub Arc<ContentSecurityPolicy>,
|
||||
);
|
||||
|
||||
impl<T> IntoResponse for StaticFile<T>
|
||||
where
|
||||
T: Into<String>,
|
||||
{
|
||||
impl IntoResponse for StaticFile {
|
||||
fn into_response(self) -> Response<BoxBody> {
|
||||
let path = self.0.into();
|
||||
let path = self.0;
|
||||
let can_set_security_headers = self.1 && self.2;
|
||||
let csp = self.3;
|
||||
serve_path(path, can_set_security_headers, csp)
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::{
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{Extension, FromRequest, Path, Query, RequestParts},
|
||||
http,
|
||||
extract::{Extension, FromRequestParts, OriginalUri, Path, Query},
|
||||
http::{self, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
@@ -52,7 +52,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/exists", post(exists_username))
|
||||
.route("/update/:user", post(update_workspace_user))
|
||||
.route("/delete/:user", delete(delete_workspace_user))
|
||||
.route("/is_owner/:path", get(is_owner_of_path))
|
||||
.route("/is_owner/*path", get(is_owner_of_path))
|
||||
.route("/whois/:email", get(whois))
|
||||
.route("/whoami", get(whoami))
|
||||
.route("/leave", post(leave_workspace))
|
||||
@@ -177,7 +177,16 @@ impl AuthCache {
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
let groups = vec![];
|
||||
let folders = vec![];
|
||||
Some(Authed {
|
||||
email: email
|
||||
.unwrap_or_else(|| "missing@email.xyz".to_string()),
|
||||
username: owner,
|
||||
is_admin: super_admin,
|
||||
groups,
|
||||
folders,
|
||||
})
|
||||
}
|
||||
}
|
||||
(_, Some(email), super_admin) => {
|
||||
@@ -275,16 +284,16 @@ impl AuthCache {
|
||||
}
|
||||
}
|
||||
|
||||
async fn extract_token<B: Send>(req: &mut RequestParts<B>) -> Option<String> {
|
||||
let auth_header = req
|
||||
.headers()
|
||||
async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S) -> Option<String> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|s| s.strip_prefix("Bearer "));
|
||||
|
||||
let from_cookie = match auth_header {
|
||||
Some(x) => Some(x.to_owned()),
|
||||
None => Extension::<Cookies>::from_request(req)
|
||||
None => Extension::<Cookies>::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
|
||||
@@ -296,7 +305,7 @@ async fn extract_token<B: Send>(req: &mut RequestParts<B>) -> Option<String> {
|
||||
}
|
||||
match from_cookie {
|
||||
Some(token) => Some(token),
|
||||
None => Query::<Token>::from_request(req)
|
||||
None => Query::<Token>::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|token| token.token.clone()),
|
||||
@@ -309,21 +318,24 @@ pub struct Tokened {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<B> FromRequest<B> for Tokened
|
||||
impl<S> FromRequestParts<S> for Tokened
|
||||
where
|
||||
B: Send,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
||||
let already_tokened = req.extensions().get::<Tokened>();
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
let already_tokened = parts.extensions.get::<Tokened>();
|
||||
if let Some(tokened) = already_tokened {
|
||||
Ok(tokened.clone())
|
||||
} else {
|
||||
let token_o = extract_token(req).await;
|
||||
let token_o = extract_token(parts, state).await;
|
||||
if let Some(token) = token_o {
|
||||
let tokened = Self { token };
|
||||
req.extensions_mut().insert(tokened.clone());
|
||||
parts.extensions.insert(tokened.clone());
|
||||
Ok(tokened)
|
||||
} else {
|
||||
Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()))
|
||||
@@ -342,33 +354,44 @@ pub struct Authed {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<B> FromRequest<B> for Authed
|
||||
impl<S> FromRequestParts<S> for Authed
|
||||
where
|
||||
B: Send,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
||||
let already_authed = req.extensions().get::<Authed>();
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
let already_authed = parts.extensions.get::<Authed>();
|
||||
if let Some(authed) = already_authed {
|
||||
Ok(authed.clone())
|
||||
} else {
|
||||
let already_tokened = req.extensions().get::<Tokened>();
|
||||
let already_tokened = parts.extensions.get::<Tokened>();
|
||||
let token_o = if let Some(token) = already_tokened {
|
||||
Some(token.token.clone())
|
||||
} else {
|
||||
extract_token(req).await
|
||||
extract_token(parts, state).await
|
||||
};
|
||||
let path_vec: Vec<&str> = req.uri().path().split("/").collect();
|
||||
let workspace_id = if path_vec[0] == "" && path_vec[1] == "w" {
|
||||
Some(path_vec[2].to_owned())
|
||||
let original_uri = OriginalUri::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|x| x.0)
|
||||
.unwrap_or_default();
|
||||
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
|
||||
|
||||
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
|
||||
Some(path_vec[3].to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(token) = token_o {
|
||||
if let Ok(Extension(cache)) = Extension::<Arc<AuthCache>>::from_request(req).await {
|
||||
if let Ok(Extension(cache)) =
|
||||
Extension::<Arc<AuthCache>>::from_request_parts(parts, state).await
|
||||
{
|
||||
if let Some(authed) = cache.get_authed(workspace_id.clone(), &token).await {
|
||||
req.extensions_mut().insert(authed.clone());
|
||||
parts.extensions.insert(authed.clone());
|
||||
Span::current().record("username", &authed.username.as_str());
|
||||
Span::current().record("email", &authed.email);
|
||||
|
||||
@@ -388,14 +411,17 @@ where
|
||||
pub struct OptAuthed(pub Option<Authed>);
|
||||
|
||||
#[async_trait]
|
||||
impl<B> FromRequest<B> for OptAuthed
|
||||
impl<S> FromRequestParts<S> for OptAuthed
|
||||
where
|
||||
B: Send,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request(req: &mut RequestParts<B>) -> std::result::Result<Self, Self::Rejection> {
|
||||
Authed::from_request(req)
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
Authed::from_request_parts(parts, state)
|
||||
.await
|
||||
.map(|authed| Self(Some(authed)))
|
||||
.or_else(|_| Ok(Self(None)))
|
||||
@@ -877,20 +903,22 @@ pub async fn require_owner_of_path(
|
||||
path: &str,
|
||||
db: &DB,
|
||||
) -> Result<()> {
|
||||
let splitted = path.split("/").collect::<Vec<&str>>();
|
||||
if splitted[0] == "u" {
|
||||
if splitted[1] == username {
|
||||
return Ok(());
|
||||
} else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"only the owner {} is authorized to perform this operation",
|
||||
splitted[1]
|
||||
)));
|
||||
if !path.is_empty() {
|
||||
let splitted = path.split("/").collect::<Vec<&str>>();
|
||||
if splitted[0] == "u" {
|
||||
if splitted[1] == username {
|
||||
return Ok(());
|
||||
} else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"only the owner {} is authorized to perform this operation",
|
||||
splitted[1]
|
||||
)));
|
||||
}
|
||||
} else if splitted[0] == "g" {
|
||||
return crate::groups::require_is_owner(splitted[1], username, groups, w_id, db).await;
|
||||
} else if splitted[0] == "f" {
|
||||
return crate::folders::require_is_owner(splitted[1], username, groups, w_id, db).await;
|
||||
}
|
||||
} else if splitted[0] == "g" {
|
||||
return crate::groups::require_is_owner(w_id, username, groups, splitted[1], db).await;
|
||||
} else if splitted[0] == "f" {
|
||||
return crate::folders::require_is_owner(w_id, username, groups, splitted[1], db).await;
|
||||
}
|
||||
Err(Error::BadRequest(format!("not recognized owner kind")))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
db::{UserDB, DB},
|
||||
oauth2::{AllClients, _refresh_token},
|
||||
users::{require_owner_of_path, Authed},
|
||||
webhook_util::{WebhookMessage, WebhookUtil},
|
||||
BaseUrl,
|
||||
};
|
||||
/*
|
||||
@@ -224,6 +225,7 @@ async fn check_path_conflict<'c>(
|
||||
async fn create_variable(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path(w_id): Path<String>,
|
||||
Json(variable): Json<CreateVariable>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
@@ -265,6 +267,11 @@ async fn create_variable(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::CreateVariable {
|
||||
workspace: w_id.clone(),
|
||||
path: variable.path.clone(),
|
||||
});
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("variable {} created", variable.path),
|
||||
@@ -274,6 +281,7 @@ async fn create_variable(
|
||||
async fn delete_variable(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
let path = path.to_path();
|
||||
@@ -306,6 +314,11 @@ async fn delete_variable(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::DeleteVariable {
|
||||
workspace: w_id.clone(),
|
||||
path: path.to_owned(),
|
||||
});
|
||||
|
||||
Ok(format!("variable {} deleted", path))
|
||||
}
|
||||
|
||||
@@ -320,6 +333,7 @@ struct EditVariable {
|
||||
async fn update_variable(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
webhook: WebhookUtil,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ns): Json<EditVariable>,
|
||||
@@ -370,10 +384,6 @@ async fn update_variable(
|
||||
}
|
||||
sqlb.returning("path");
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut tx).await?;
|
||||
|
||||
if let Some(npath) = ns.path {
|
||||
if npath != path {
|
||||
check_path_conflict(&mut tx, &w_id, &npath).await?;
|
||||
@@ -391,6 +401,10 @@ async fn update_variable(
|
||||
}
|
||||
}
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::InternalErr(e.to_string()))?;
|
||||
|
||||
let npath_o: Option<String> = sqlx::query_scalar(&sql).fetch_optional(&mut tx).await?;
|
||||
|
||||
let npath = not_found_if_none(npath_o, "Variable", path)?;
|
||||
|
||||
audit_log(
|
||||
@@ -405,6 +419,12 @@ async fn update_variable(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
webhook.send_message(WebhookMessage::UpdateVariable {
|
||||
workspace: w_id.clone(),
|
||||
old_path: path.to_owned(),
|
||||
new_path: npath.clone(),
|
||||
});
|
||||
|
||||
Ok(format!("variable {} updated (npath: {:?})", path, npath))
|
||||
}
|
||||
|
||||
|
||||
156
backend/windmill-api/src/webhook_util.rs
Normal file
156
backend/windmill-api/src/webhook_util.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRequestParts, OriginalUri},
|
||||
http::request::Parts,
|
||||
Extension,
|
||||
};
|
||||
use hyper::StatusCode;
|
||||
use serde::Serialize;
|
||||
use tokio::{select, sync::mpsc};
|
||||
|
||||
use crate::db::DB;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
// TODO: these aren't synced, they should be moved into the queue abstraction once/if that happens.
|
||||
static ref WEBHOOK_REQUEST_COUNT: prometheus::Histogram = prometheus::register_histogram!(
|
||||
"webhook_request",
|
||||
"Histogram of webhook requests made"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum WebhookMessage {
|
||||
// See https://serde.rs/enum-representations.html#internally-tagged for how this looks in JSON
|
||||
CreateApp { workspace: String, path: String },
|
||||
DeleteApp { workspace: String, path: String },
|
||||
UpdateApp { workspace: String, old_path: String, new_path: String },
|
||||
CreateFlow { workspace: String, path: String },
|
||||
UpdateFlow { workspace: String, old_path: String, new_path: String },
|
||||
ArchiveFlow { workspace: String, path: String },
|
||||
CreateFolder { workspace: String, name: String },
|
||||
UpdateFolder { workspace: String, name: String },
|
||||
DeleteFolder { workspace: String, name: String },
|
||||
DeleteResource { workspace: String, path: String },
|
||||
CreateResource { workspace: String, path: String },
|
||||
UpdateResource { workspace: String, old_path: String, new_path: String },
|
||||
CreateResourceType { name: String },
|
||||
DeleteResourceType { name: String },
|
||||
UpdateResourceType { name: String },
|
||||
CreateScript { workspace: String, path: String, hash: String },
|
||||
UpdateScript { workspace: String, path: String, hash: String },
|
||||
DeleteScript { workspace: String, hash: String },
|
||||
CreateVariable { workspace: String, path: String },
|
||||
UpdateVariable { workspace: String, old_path: String, new_path: String },
|
||||
DeleteVariable { workspace: String, path: String },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebhookShared {
|
||||
pub channel: mpsc::UnboundedSender<(String, WebhookMessage)>,
|
||||
}
|
||||
|
||||
impl WebhookShared {
|
||||
pub fn new(mut shutdown_rx: tokio::sync::broadcast::Receiver<()>) -> Self {
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<(String, WebhookMessage)>();
|
||||
let _process = tokio::spawn(async move {
|
||||
let client = reqwest::Client::builder()
|
||||
// TODO: investigate pool timeouts and such if TCP load is high
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
loop {
|
||||
select! {
|
||||
biased;
|
||||
_ = shutdown_rx.recv() => break,
|
||||
r = rx.recv() => match r {
|
||||
Some((url, message)) => {
|
||||
let timer = WEBHOOK_REQUEST_COUNT.start_timer();
|
||||
let _ = client.post(url).json(&message).send().await;
|
||||
timer.stop_and_record();
|
||||
},
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Self { channel: tx }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WebhookUtil {
|
||||
webhook: Option<String>,
|
||||
shared: Extension<WebhookShared>,
|
||||
}
|
||||
|
||||
impl WebhookUtil {
|
||||
pub fn send_message(&self, message: WebhookMessage) {
|
||||
let Some(webhook) = &self.webhook else {
|
||||
return;
|
||||
};
|
||||
let _ = self.shared.channel.send((webhook.clone(), message));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for WebhookUtil
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = (StatusCode, String);
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
let original_uri = OriginalUri::from_request_parts(parts, state)
|
||||
.await
|
||||
.ok()
|
||||
.map(|x| x.0)
|
||||
.unwrap_or_default();
|
||||
let path_vec: Vec<&str> = original_uri.path().split("/").collect();
|
||||
let workspace_id = if path_vec.len() >= 4 && path_vec[0] == "" && path_vec[2] == "w" {
|
||||
Some(path_vec[3].to_owned())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let webhook = sqlx::query_scalar!(
|
||||
"SELECT webhook FROM workspace_settings WHERE workspace_id = $1",
|
||||
workspace_id
|
||||
)
|
||||
.fetch_one(
|
||||
&Extension::<DB>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Could not aquire DB while retrieving webhook".to_owned(),
|
||||
)
|
||||
})?
|
||||
.0,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Could not execute DB query {:?}", e),
|
||||
)
|
||||
})?;
|
||||
let shared = Extension::<WebhookShared>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Could not aquire shared process while retrieving webhook".to_owned(),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Self { webhook, shared })
|
||||
}
|
||||
}
|
||||
@@ -6,24 +6,28 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
db::{UserDB, DB},
|
||||
folders::Folder,
|
||||
resources::{Resource, ResourceType},
|
||||
users::{Authed, WorkspaceInvite},
|
||||
utils::require_super_admin,
|
||||
BaseUrl,
|
||||
};
|
||||
use axum::{
|
||||
body::StreamBody,
|
||||
extract::{Extension, Path, Query},
|
||||
headers,
|
||||
response::IntoResponse,
|
||||
response::{IntoResponse, Redirect},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use stripe::CustomerId;
|
||||
use windmill_audit::{audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
error::{Error, JsonResult, Result},
|
||||
error::{to_anyhow, Error, JsonResult, Result},
|
||||
flows::Flow,
|
||||
scripts::{Schema, Script, ScriptLang},
|
||||
utils::{paginate, rd_string, require_admin, Pagination},
|
||||
@@ -41,16 +45,19 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list_pending_invites", get(list_pending_invites))
|
||||
.route("/update", post(edit_workspace))
|
||||
.route("/delete", delete(delete_workspace))
|
||||
.route("/archive", post(archive_workspace))
|
||||
.route("/invite_user", post(invite_user))
|
||||
.route("/add_user", post(add_user))
|
||||
.route("/delete_invite", post(delete_invite))
|
||||
.route("/get_settings", get(get_settings))
|
||||
.route("/edit_slack_command", post(edit_slack_command))
|
||||
.route("/edit_webhook", post(edit_webhook))
|
||||
.route("/edit_auto_invite", post(edit_auto_invite))
|
||||
.route("/tarball", get(tarball_workspace))
|
||||
.route("/premium_info", get(premium_info))
|
||||
.route("/checkout", get(stripe_checkout))
|
||||
.route("/billing_portal", get(stripe_portal))
|
||||
}
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list_as_superadmin", get(list_workspaces_as_super_admin))
|
||||
@@ -60,6 +67,8 @@ pub fn global_service() -> Router {
|
||||
.route("/exists", post(exists_workspace))
|
||||
.route("/exists_username", post(exists_username))
|
||||
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
|
||||
.route("/unarchive/:workspace", post(unarchive_workspace))
|
||||
.route("/delete/:workspace", delete(delete_workspace))
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize)]
|
||||
@@ -80,6 +89,9 @@ pub struct WorkspaceSettings {
|
||||
pub slack_email: String,
|
||||
pub auto_invite_domain: Option<String>,
|
||||
pub auto_invite_operator: Option<bool>,
|
||||
pub customer_id: Option<String>,
|
||||
pub plan: Option<String>,
|
||||
pub webhook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Debug)]
|
||||
@@ -107,6 +119,11 @@ struct EditAutoInvite {
|
||||
operator: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EditWebhook {
|
||||
webhook: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateWorkspace {
|
||||
id: String,
|
||||
@@ -151,6 +168,14 @@ pub struct NewWorkspaceInvite {
|
||||
pub operator: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewWorkspaceUser {
|
||||
pub email: String,
|
||||
pub username: String,
|
||||
pub is_admin: bool,
|
||||
pub operator: bool,
|
||||
}
|
||||
|
||||
async fn list_pending_invites(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -191,6 +216,138 @@ async fn premium_info(
|
||||
Ok(Json(row))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlanQuery {
|
||||
plan: String,
|
||||
}
|
||||
|
||||
async fn stripe_checkout(
|
||||
authed: Authed,
|
||||
Path(w_id): Path<String>,
|
||||
Query(plan): Query<PlanQuery>,
|
||||
Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
) -> Result<Redirect> {
|
||||
// #[cfg(feature = "enterprise")]
|
||||
{
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
|
||||
let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url.0);
|
||||
let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url.0);
|
||||
let checkout_session = {
|
||||
let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd);
|
||||
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
|
||||
params.line_items = match plan.plan.as_str() {
|
||||
"team" => Some(vec![
|
||||
stripe::CreateCheckoutSessionLineItems {
|
||||
quantity: None,
|
||||
price: Some("price_1MSdSyGU3NdFi9eLMdV6cS6F".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
stripe::CreateCheckoutSessionLineItems {
|
||||
quantity: None,
|
||||
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
"enterprise" => Some(vec![
|
||||
stripe::CreateCheckoutSessionLineItems {
|
||||
quantity: None,
|
||||
price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
stripe::CreateCheckoutSessionLineItems {
|
||||
quantity: None,
|
||||
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
_ => Err(Error::BadRequest("invalid plan".to_string()))?,
|
||||
};
|
||||
params.customer_email = Some(&authed.email);
|
||||
params.client_reference_id = Some(&w_id);
|
||||
stripe::CheckoutSession::create(&client, params)
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let uri = checkout_session
|
||||
.url
|
||||
.ok_or_else(|| Error::InternalErr(format!("stripe checkout redirect issue")))?;
|
||||
Ok(Redirect::to(&uri))
|
||||
}
|
||||
}
|
||||
|
||||
async fn stripe_portal(
|
||||
authed: Authed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
) -> Result<Redirect> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let customer_id = sqlx::query_scalar!(
|
||||
"SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
|
||||
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
|
||||
let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
|
||||
let portal_session = {
|
||||
let customer_id = CustomerId::from_str(&customer_id).unwrap();
|
||||
let mut params = stripe::CreateBillingPortalSession::new(customer_id);
|
||||
params.return_url = Some(&success_rd);
|
||||
stripe::BillingPortalSession::create(&client, params)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
};
|
||||
Ok(Redirect::to(&portal_session.url))
|
||||
}
|
||||
|
||||
// async fn stripe_usage(
|
||||
// authed: Authed,
|
||||
// Path(w_id): Path<String>,
|
||||
// Extension(db): Extension<DB>,
|
||||
// Extension(base_url): Extension<Arc<BaseUrl>>,
|
||||
// ) -> Result<Redirect> {
|
||||
// require_admin(authed.is_admin, &authed.username)?;
|
||||
// let customer_id = sqlx::query_scalar!(
|
||||
// "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
|
||||
// w_id
|
||||
// )
|
||||
// .fetch_one(&db)
|
||||
// .await?
|
||||
// .ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
|
||||
// let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
|
||||
// let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
|
||||
// let portal_session = {
|
||||
// let customer_id = CustomerId::from_str(&customer_id).unwrap();
|
||||
// let subscriptions = stripe::Subscription::list(
|
||||
// &client,
|
||||
// stripe::ListSubscriptions { customer: Some(customer_id), ..Default::default() },
|
||||
// )
|
||||
// .await
|
||||
// .map_err(to_anyhow)?
|
||||
// .data[0];
|
||||
// let getUsage =
|
||||
// stripe::SubscriptionItem::list(
|
||||
// &client,
|
||||
// stripe::ListSubscriptionItems {
|
||||
// subscription: subscription.id,
|
||||
// ..Default::default()
|
||||
// },
|
||||
// )
|
||||
// .await
|
||||
// .map_err(to_anyhow)
|
||||
// };
|
||||
// let mut params = stripe::ListSubscriptionItems::new(customer_id);
|
||||
// params.return_url = Some(&success_rd);
|
||||
// stripe::BillingPortalSession::create(&client, params)
|
||||
// .await
|
||||
// .map_err(to_anyhow)?
|
||||
// };
|
||||
// }
|
||||
|
||||
async fn exists_workspace(
|
||||
authed: Authed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -353,7 +510,52 @@ async fn edit_auto_invite(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Edit command script {}", &w_id))
|
||||
Ok(format!(
|
||||
"Edit auto-invite for workspace {} to {}",
|
||||
&w_id, domain
|
||||
))
|
||||
}
|
||||
|
||||
async fn edit_webhook(
|
||||
authed: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Authed { is_admin, username, .. }: Authed,
|
||||
Json(ew): Json<EditWebhook>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
if let Some(webhook) = &ew.webhook {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET webhook = $1 WHERE workspace_id = $2",
|
||||
webhook,
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"UPDATE workspace_settings SET webhook = NULL WHERE workspace_id = $1",
|
||||
&w_id,
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
}
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&authed.username,
|
||||
"workspaces.edit_webhook",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&authed.email),
|
||||
Some([("webhook", &format!("{:?}", ew.webhook)[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Edit webhook for workspace {}", &w_id))
|
||||
}
|
||||
|
||||
async fn list_workspaces_as_super_admin(
|
||||
@@ -538,10 +740,62 @@ async fn edit_workspace(
|
||||
Ok(format!("Updated workspace {}", &w_id))
|
||||
}
|
||||
|
||||
async fn delete_workspace(
|
||||
async fn archive_workspace(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Authed { is_admin, username, email, .. }: Authed,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&username,
|
||||
"workspaces.archive",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&email),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Archived workspace {}", &w_id))
|
||||
}
|
||||
|
||||
async fn unarchive_workspace(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Authed { is_admin, username, email, .. }: Authed,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!("UPDATE workspace SET deleted = false WHERE id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut tx,
|
||||
&username,
|
||||
"workspaces.unarchive",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&email),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(format!("Unarchived workspace {}", &w_id))
|
||||
}
|
||||
|
||||
async fn delete_workspace(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Authed { username, email, .. }: Authed,
|
||||
) -> Result<String> {
|
||||
let w_id = match w_id.as_str() {
|
||||
"starter" => Err(Error::BadRequest(
|
||||
@@ -552,9 +806,68 @@ async fn delete_workspace(
|
||||
)),
|
||||
_ => Ok(w_id),
|
||||
}?;
|
||||
require_admin(is_admin, &username)?;
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query!("UPDATE workspace SET deleted = true WHERE id = $1", &w_id)
|
||||
require_super_admin(&mut tx, &email).await?;
|
||||
|
||||
sqlx::query!("DELETE FROM script WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM flow WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM app WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM variable WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM resource WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM schedule WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM completed_job WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM usr WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM workspace_invite WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM usr_to_group WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM group_ WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM folder WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM workspace_key WHERE workspace_id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
@@ -562,7 +875,7 @@ async fn delete_workspace(
|
||||
&mut tx,
|
||||
&username,
|
||||
"workspaces.delete",
|
||||
ActionKind::Update,
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&email),
|
||||
None,
|
||||
@@ -629,6 +942,37 @@ async fn invite_user(
|
||||
))
|
||||
}
|
||||
|
||||
async fn add_user(
|
||||
Authed { username, is_admin, .. }: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(nu): Json<NewWorkspaceUser>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO usr
|
||||
(workspace_id, email, username, is_admin, operator)
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
&w_id,
|
||||
nu.email,
|
||||
nu.username,
|
||||
nu.is_admin,
|
||||
nu.operator
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
format!("user with email {} added", nu.email),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_invite(
|
||||
Authed { username, is_admin, .. }: Authed,
|
||||
Extension(db): Extension<DB>,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use axum::{
|
||||
body::{self, BoxBody},
|
||||
response::IntoResponse,
|
||||
Json,
|
||||
response::Json,
|
||||
};
|
||||
|
||||
#[cfg(feature = "sqlx")]
|
||||
@@ -58,6 +58,8 @@ pub enum Error {
|
||||
ExitStatus(i32),
|
||||
#[error(transparent)]
|
||||
Anyhow(#[from] anyhow::Error),
|
||||
#[error("Error: {0:#?}")]
|
||||
JsonErr(serde_json::Value),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::{collections::HashMap, time::Duration};
|
||||
use serde::{self, Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
more_serde::{default_false, default_id, default_true, is_default},
|
||||
more_serde::{
|
||||
default_empty_string, default_false, default_id, default_null, default_true, is_default,
|
||||
},
|
||||
scripts::{Schema, ScriptHash, ScriptLang},
|
||||
};
|
||||
|
||||
@@ -172,8 +174,14 @@ impl FlowModule {
|
||||
rename_all(serialize = "lowercase", deserialize = "lowercase")
|
||||
)]
|
||||
pub enum InputTransform {
|
||||
Static { value: serde_json::Value },
|
||||
Javascript { expr: String },
|
||||
Static {
|
||||
#[serde(default = "default_null")]
|
||||
value: serde_json::Value,
|
||||
},
|
||||
Javascript {
|
||||
#[serde(default = "default_empty_string")]
|
||||
expr: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -18,6 +18,14 @@ pub fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn default_null() -> serde_json::Value {
|
||||
serde_json::Value::Null
|
||||
}
|
||||
|
||||
pub fn default_empty_string() -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub fn default_id() -> String {
|
||||
rd_string(6)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,11 @@ pub fn initialize_tracing() {
|
||||
|
||||
match json_fmt {
|
||||
true => ts_base
|
||||
.with(json_layer().with_filter(filter_fn(filter_metadata)))
|
||||
.with(
|
||||
json_layer()
|
||||
.flatten_event(true)
|
||||
.with_filter(filter_fn(filter_metadata)),
|
||||
)
|
||||
.init(),
|
||||
false => ts_base
|
||||
.with(
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
pub fn owner_to_token_owner(user: &str, is_group: bool) -> String {
|
||||
let prefix = if is_group { 'g' } else { 'u' };
|
||||
format!("{}/{}", prefix, user)
|
||||
pub fn username_to_permissioned_as(user: &str) -> String {
|
||||
if user.contains('@') {
|
||||
user.to_string()
|
||||
} else {
|
||||
format!("u/{}", user)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ use serde::Deserialize;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub const MAX_PER_PAGE: usize = 1000;
|
||||
pub const DEFAULT_PER_PAGE: usize = 100;
|
||||
pub const MAX_PER_PAGE: usize = 10000;
|
||||
pub const DEFAULT_PER_PAGE: usize = 1000;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Pagination {
|
||||
@@ -72,10 +72,6 @@ pub fn not_found_if_none<T, U: AsRef<str>>(opt: Option<T>, kind: &str, name: U)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_owner_from_path(path: &str) -> String {
|
||||
path.split('/').take(2).collect::<Vec<_>>().join("/")
|
||||
}
|
||||
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub async fn list_elems_from_hub(
|
||||
http_client: reqwest::Client,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Pool, Postgres, Transaction};
|
||||
use tracing::{instrument, Instrument};
|
||||
@@ -44,6 +45,7 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
const MAX_FREE_EXECS: i32 = 1000;
|
||||
const MAX_FREE_CONCURRENT_RUNS: i32 = 3;
|
||||
|
||||
pub async fn cancel_job<'c>(
|
||||
username: &str,
|
||||
@@ -172,7 +174,6 @@ pub async fn get_result_by_id(
|
||||
"Flow result by id",
|
||||
format!("{}, {}", flow_id, node_id),
|
||||
)?;
|
||||
println!("result_id: {:#?}, {node_id}", result_id);
|
||||
|
||||
let value = match result_id {
|
||||
JobResult::ListJob(x) => {
|
||||
@@ -318,10 +319,38 @@ pub async fn push<'c>(
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
if !is_super_admin && usage > MAX_FREE_EXECS {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
|
||||
if !is_super_admin {
|
||||
if usage > MAX_FREE_EXECS {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"User {email} has exceeded the free usage limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
|
||||
)));
|
||||
}
|
||||
let in_queue =
|
||||
sqlx::query_scalar!("SELECT COUNT(id) FROM queue WHERE email = $1", email)
|
||||
.fetch_one(&mut tx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if in_queue > MAX_FREE_EXECS.into() {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"User {email} has exceeded the jobs in queue limit of {MAX_FREE_EXECS} that applies outside of premium workspaces."
|
||||
)));
|
||||
}
|
||||
|
||||
let concurrent_runs = sqlx::query_scalar!(
|
||||
"SELECT COUNT(id) FROM queue WHERE running = true AND email = $1",
|
||||
email
|
||||
)
|
||||
.fetch_one(&mut tx)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if concurrent_runs > MAX_FREE_CONCURRENT_RUNS.into() {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"User {email} has exceeded the concurrent runs limit of {MAX_FREE_CONCURRENT_RUNS} that applies outside of premium workspaces."
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,7 +381,9 @@ pub async fn push<'c>(
|
||||
)
|
||||
}
|
||||
JobPayload::ScriptHub { path } => {
|
||||
let script = get_hub_script(path.clone(), email).await?;
|
||||
let script = get_hub_script(path.clone(), email)
|
||||
.await
|
||||
.context("error fetching hub script")?;
|
||||
(
|
||||
None,
|
||||
Some(path),
|
||||
@@ -453,7 +484,7 @@ pub async fn push<'c>(
|
||||
{
|
||||
let mut modules = flow.modules.clone();
|
||||
modules.push(FlowModule {
|
||||
id: "".to_string(),
|
||||
id: format!("{}-v", flow.modules[flow.modules.len() - 1].id),
|
||||
value: FlowModuleValue::Identity,
|
||||
input_transforms: HashMap::new(),
|
||||
stop_after_if: None,
|
||||
@@ -545,13 +576,13 @@ pub async fn push<'c>(
|
||||
Ok((uuid, tx))
|
||||
}
|
||||
|
||||
pub fn canceled_job_to_result(job: &QueuedJob) -> String {
|
||||
pub fn canceled_job_to_result(job: &QueuedJob) -> serde_json::Value {
|
||||
let reason = job
|
||||
.canceled_reason
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| "no reason given");
|
||||
let canceler = job.canceled_by.as_deref().unwrap_or_else(|| "unknown");
|
||||
format!("Job canceled: {reason} by {canceler}")
|
||||
serde_json::json!({"message": format!("Job canceled: {reason} by {canceler}"), "name": "Canceled", "reason": reason, "canceler": canceler})
|
||||
}
|
||||
|
||||
pub async fn get_hub_script(path: String, email: &str) -> error::Result<HubScript> {
|
||||
@@ -571,33 +602,50 @@ pub async fn get_hub_script(path: String, email: &str) -> error::Result<HubScrip
|
||||
pub struct QueuedJob {
|
||||
pub workspace_id: String,
|
||||
pub id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent_job: Option<Uuid>,
|
||||
pub created_by: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub scheduled_for: chrono::DateTime<chrono::Utc>,
|
||||
pub running: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub script_hash: Option<ScriptHash>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub script_path: Option<String>,
|
||||
pub args: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logs: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_lock: Option<String>,
|
||||
pub canceled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub canceled_by: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub canceled_reason: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_ping: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub job_kind: JobKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schedule_path: Option<String>,
|
||||
pub permissioned_as: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flow_status: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw_flow: Option<serde_json::Value>,
|
||||
pub is_flow_step: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub language: Option<ScriptLang>,
|
||||
pub same_worker: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pre_run_error: Option<String>,
|
||||
pub email: String,
|
||||
pub visible_to_owner: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suspend: Option<i32>,
|
||||
}
|
||||
|
||||
impl QueuedJob {
|
||||
|
||||
@@ -13,7 +13,8 @@ use sqlx::{query_scalar, Postgres, Transaction};
|
||||
use windmill_common::{
|
||||
error::{self, Result},
|
||||
schedule::Schedule,
|
||||
utils::{get_owner_from_path, now_from_db, StripPath},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{now_from_db, StripPath},
|
||||
};
|
||||
|
||||
use crate::{push, JobPayload};
|
||||
@@ -80,7 +81,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
args,
|
||||
&schedule_to_user(&schedule.path),
|
||||
&schedule.email,
|
||||
get_owner_from_path(&schedule.path),
|
||||
username_to_permissioned_as(&schedule.edited_by),
|
||||
Some(next),
|
||||
Some(schedule.path.clone()),
|
||||
None,
|
||||
|
||||
55
backend/windmill-worker/loader.py
Normal file
55
backend/windmill-worker/loader.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import sys
|
||||
import os
|
||||
from importlib.abc import MetaPathFinder, Loader
|
||||
from importlib.machinery import ModuleSpec, SourceFileLoader
|
||||
|
||||
|
||||
class WindmillLoader(Loader):
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
module.__path__ = self.path
|
||||
return None
|
||||
|
||||
|
||||
class WindmillFinder(MetaPathFinder):
|
||||
@classmethod
|
||||
def find_spec(cls, name, path, target=None):
|
||||
splitted = name.split(".")
|
||||
|
||||
if splitted[0] != "f" and splitted[0] != "u":
|
||||
return None
|
||||
l = len(splitted)
|
||||
if l <= 2:
|
||||
return ModuleSpec(name, WindmillLoader(name))
|
||||
elif l == 3:
|
||||
script_path = "/".join(splitted)
|
||||
import requests
|
||||
|
||||
url = f"{os.environ.get('BASE_INTERNAL_URL')}/api/w/{os.environ.get('WM_WORKSPACE')}/scripts/raw/p/{script_path}"
|
||||
|
||||
r = requests.get(
|
||||
url, headers={"Authorization": f"Bearer {os.environ.get('WM_TOKEN')}"}
|
||||
)
|
||||
|
||||
if r.status_code == 200:
|
||||
folder = os.getcwd() + "/tmp/" + "/".join(splitted[:-1])
|
||||
fullpath = folder + "/" + splitted[-1] + ".py"
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with open(fullpath, "w+") as f:
|
||||
f.write(r.text)
|
||||
return ModuleSpec(name, SourceFileLoader(name, fullpath))
|
||||
else:
|
||||
print(r.text, r.status_code)
|
||||
raise ImportError(f"Script {script_path} not found")
|
||||
else:
|
||||
raise ImportError(
|
||||
"Import can only be done at the top level of a folder or user space"
|
||||
)
|
||||
|
||||
|
||||
sys.meta_path.append(WindmillFinder)
|
||||
@@ -3,12 +3,11 @@ name: "bash run script"
|
||||
mode: ONCE
|
||||
hostname: "bash"
|
||||
log_level: ERROR
|
||||
time_limit: 300
|
||||
|
||||
rlimit_as: 2048
|
||||
rlimit_as: 4096
|
||||
rlimit_cpu: 1000
|
||||
rlimit_fsize: 1024
|
||||
rlimit_nofile: 64
|
||||
rlimit_fsize: 1000
|
||||
rlimit_nofile: 10000
|
||||
|
||||
cwd: "/tmp"
|
||||
|
||||
@@ -55,7 +54,7 @@ mount {
|
||||
dst: "/tmp"
|
||||
fstype: "tmpfs"
|
||||
rw: true
|
||||
options: "size=500000000"
|
||||
options: "size=800000000"
|
||||
}
|
||||
|
||||
mount {
|
||||
@@ -87,4 +86,4 @@ iface_no_lo: true
|
||||
|
||||
{SHARED_MOUNT}
|
||||
|
||||
|
||||
envar: "HOME=/tmp"
|
||||
|
||||
@@ -3,12 +3,11 @@ name: "deno run script"
|
||||
mode: ONCE
|
||||
hostname: "deno"
|
||||
log_level: ERROR
|
||||
time_limit: 300
|
||||
|
||||
rlimit_as: 16000
|
||||
rlimit_cpu: 1000
|
||||
rlimit_fsize: 1024
|
||||
rlimit_nofile: 64
|
||||
rlimit_fsize: 1000
|
||||
rlimit_nofile: 10000
|
||||
|
||||
cwd: "/tmp"
|
||||
|
||||
@@ -73,6 +72,12 @@ mount {
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "{JOB_DIR}/import_map.json"
|
||||
dst: "/tmp/import_map.json"
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "{JOB_DIR}/lock.json"
|
||||
dst: "/tmp/lock.json"
|
||||
|
||||
@@ -3,12 +3,11 @@ name: "go run script"
|
||||
mode: ONCE
|
||||
hostname: "go"
|
||||
log_level: ERROR
|
||||
time_limit: 300
|
||||
|
||||
rlimit_as: 2048
|
||||
rlimit_as: 4096
|
||||
rlimit_cpu: 1000
|
||||
rlimit_fsize: 1024
|
||||
rlimit_nofile: 64
|
||||
rlimit_fsize: 1000
|
||||
rlimit_nofile: 10000
|
||||
|
||||
cwd: "/tmp/go"
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ name: "python run script"
|
||||
mode: ONCE
|
||||
hostname: "python"
|
||||
log_level: ERROR
|
||||
time_limit: 300
|
||||
|
||||
rlimit_as: 2048
|
||||
rlimit_as: 4096
|
||||
rlimit_cpu: 1000
|
||||
rlimit_fsize: 1024
|
||||
rlimit_nofile: 64
|
||||
rlimit_fsize: 1000
|
||||
rlimit_nofile: 10000
|
||||
|
||||
cwd: "/tmp"
|
||||
|
||||
@@ -72,6 +71,13 @@ mount {
|
||||
is_bind: true
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "{JOB_DIR}/loader.py"
|
||||
dst: "/tmp/loader.py"
|
||||
is_bind: true
|
||||
mandatory: false
|
||||
}
|
||||
|
||||
mount {
|
||||
src: "{JOB_DIR}/main.py"
|
||||
dst: "/tmp/main.py"
|
||||
|
||||
@@ -6,44 +6,24 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::{Pool, Postgres};
|
||||
use sqlx::{Pool, Postgres, Transaction};
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
use windmill_common::{error::Error, flow_status::FlowStatusModule, schedule::Schedule};
|
||||
use windmill_queue::{delete_job, schedule::get_schedule_opt, JobKind, QueuedJob};
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn add_completed_job_error<E: ToString + std::fmt::Debug>(
|
||||
pub async fn add_completed_job_error(
|
||||
db: &Pool<Postgres>,
|
||||
queued_job: &QueuedJob,
|
||||
logs: String,
|
||||
e: E,
|
||||
e: serde_json::Value,
|
||||
metrics: Option<crate::worker::Metrics>,
|
||||
) -> Result<(Uuid, serde_json::Map<String, serde_json::Value>), Error> {
|
||||
) -> Result<serde_json::Value, Error> {
|
||||
metrics.map(|m| m.worker_execution_failed.inc());
|
||||
let mut output_map = Map::new();
|
||||
error_to_result(&mut output_map, &e);
|
||||
let a = add_completed_job(
|
||||
db,
|
||||
&queued_job,
|
||||
false,
|
||||
false,
|
||||
serde_json::Value::Object(output_map.clone()),
|
||||
logs,
|
||||
)
|
||||
.await?;
|
||||
Ok((a, output_map))
|
||||
}
|
||||
|
||||
pub fn error_to_result<E: ToString + std::fmt::Debug>(
|
||||
output_map: &mut Map<String, Value>,
|
||||
err: &E,
|
||||
) {
|
||||
output_map.insert(
|
||||
"error".to_string(),
|
||||
serde_json::Value::String(err.to_string()),
|
||||
);
|
||||
let result = serde_json::json!({ "error": e });
|
||||
let _ = add_completed_job(db, &queued_job, false, false, result.clone(), logs).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn flatten_jobs(modules: Vec<FlowStatusModule>) -> Vec<Uuid> {
|
||||
@@ -130,7 +110,7 @@ pub async fn add_completed_job(
|
||||
, email
|
||||
, visible_to_owner
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, EXTRACT(milliseconds FROM (now() - $6))), $7, $8, $9,\
|
||||
VALUES ($1, $2, $3, $4, $5, $6, COALESCE($26, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE($6, now()))))*1000), $7, $8, $9,\
|
||||
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $27, $28)
|
||||
ON CONFLICT (id) DO UPDATE SET success = $7, result = $11, logs = concat(cj.logs, $12)",
|
||||
queued_job.workspace_id,
|
||||
@@ -166,6 +146,21 @@ pub async fn add_completed_job(
|
||||
.await
|
||||
.map_err(|e| Error::InternalErr(format!("Could not add completed job {job_id}: {e}")))?;
|
||||
let _ = delete_job(db, &queued_job.workspace_id, job_id).await?;
|
||||
if !queued_job.is_flow_step
|
||||
&& queued_job.job_kind != JobKind::Flow
|
||||
&& queued_job.job_kind != JobKind::FlowPreview
|
||||
&& queued_job.schedule_path.is_some()
|
||||
&& queued_job.script_path.is_some()
|
||||
{
|
||||
tx = schedule_again_if_scheduled(
|
||||
tx,
|
||||
db,
|
||||
queued_job.schedule_path.as_ref().unwrap(),
|
||||
queued_job.script_path.as_ref().unwrap(),
|
||||
&queued_job.workspace_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
if cfg!(enterprise) && duration.unwrap_or(0) > 1000 {
|
||||
@@ -200,33 +195,18 @@ pub async fn add_completed_job(
|
||||
}
|
||||
}
|
||||
|
||||
if !queued_job.is_flow_step
|
||||
&& queued_job.job_kind != JobKind::Flow
|
||||
&& queued_job.job_kind != JobKind::FlowPreview
|
||||
&& queued_job.schedule_path.is_some()
|
||||
&& queued_job.script_path.is_some()
|
||||
{
|
||||
schedule_again_if_scheduled(
|
||||
db,
|
||||
queued_job.schedule_path.as_ref().unwrap(),
|
||||
queued_job.script_path.as_ref().unwrap(),
|
||||
&queued_job.workspace_id,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tracing::debug!("Added completed job {}", queued_job.id);
|
||||
Ok(queued_job.id)
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all)]
|
||||
pub async fn schedule_again_if_scheduled(
|
||||
pub async fn schedule_again_if_scheduled<'c>(
|
||||
mut tx: Transaction<'c, Postgres>,
|
||||
db: &Pool<Postgres>,
|
||||
schedule_path: &str,
|
||||
script_path: &str,
|
||||
w_id: &str,
|
||||
) -> windmill_common::error::Result<()> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
) -> windmill_common::error::Result<Transaction<'c, Postgres>> {
|
||||
let schedule = get_schedule_opt(&mut tx, w_id, schedule_path)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
@@ -258,19 +238,21 @@ pub async fn schedule_again_if_scheduled(
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(tx) => tx.commit().await?,
|
||||
Err(e) => {
|
||||
Ok(tx) => Ok(tx),
|
||||
Err(err) => {
|
||||
sqlx::query!(
|
||||
"UPDATE schedule SET enabled = false, error = $1 WHERE workspace_id = $2 AND path = $3",
|
||||
e.to_string(),
|
||||
err.to_string(),
|
||||
&schedule.workspace_id,
|
||||
&schedule.path
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
tracing::warn!("Could not schedule job for {}: {}", schedule_path, e);
|
||||
tracing::warn!("Could not schedule job for {}: {}", schedule_path, err);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok(tx)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -307,6 +307,19 @@ async fn op_get_id(args: Vec<String>) -> Result<Option<serde_json::Value>, anyho
|
||||
let node_id = &args[4];
|
||||
|
||||
let client = windmill_api_client::create_client(base_url, token.clone());
|
||||
let err = client
|
||||
.result_by_id(workspace, flow_job_id, node_id, Some(true))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
let res = match err {
|
||||
windmill_api_client::Error::UnexpectedResponse(e) => {
|
||||
tracing::error!("{:?}", e.text().await);
|
||||
anyhow::anyhow!("bar")
|
||||
}
|
||||
_ => anyhow::anyhow!("foo"),
|
||||
};
|
||||
tracing::error!("{:?}", res);
|
||||
let result = client
|
||||
.result_by_id(workspace, flow_job_id, node_id, Some(true))
|
||||
.await
|
||||
|
||||
@@ -9,8 +9,13 @@
|
||||
use const_format::concatcp;
|
||||
use git_version::git_version;
|
||||
use itertools::Itertools;
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use sqlx::{Pool, Postgres, Transaction};
|
||||
use std::{borrow::Borrow, collections::HashMap, io, panic, process::Stdio, time::Duration};
|
||||
use std::{
|
||||
borrow::Borrow, collections::HashMap, io, os::unix::process::ExitStatusExt, panic,
|
||||
process::Stdio, time::Duration,
|
||||
};
|
||||
use tracing::{trace_span, Instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -23,7 +28,7 @@ use windmill_common::{
|
||||
};
|
||||
use windmill_queue::{canceled_job_to_result, get_queued_job, pull, JobKind, QueuedJob};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use tokio::{
|
||||
fs::{metadata, symlink, DirBuilder, File},
|
||||
@@ -44,7 +49,7 @@ use futures::{
|
||||
use async_recursion::async_recursion;
|
||||
|
||||
use crate::{
|
||||
jobs::{add_completed_job, add_completed_job_error, error_to_result},
|
||||
jobs::{add_completed_job, add_completed_job_error},
|
||||
worker_flow::{
|
||||
handle_flow, update_flow_status_after_job_completion, update_flow_status_in_progress,
|
||||
},
|
||||
@@ -229,29 +234,27 @@ pub async fn create_token_for_owner<'c>(
|
||||
owner: &str,
|
||||
label: &str,
|
||||
expires_in: i32,
|
||||
username: &str,
|
||||
email: &str,
|
||||
) -> error::Result<(Transaction<'c, Postgres>, String)> {
|
||||
// TODO: Bad implementation. We should not have access to this DB here.
|
||||
let token: String = rd_string(30);
|
||||
let is_super_admin = username.contains('@')
|
||||
&& sqlx::query_scalar!(
|
||||
"SELECT super_admin FROM password WHERE email = $1",
|
||||
owner.split_once('/').map(|x| x.1).unwrap_or("")
|
||||
)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
let is_super_admin = owner.contains('@')
|
||||
&& sqlx::query_scalar!("SELECT super_admin FROM password WHERE email = $1", owner)
|
||||
.fetch_optional(&mut tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
sqlx::query_scalar!(
|
||||
"INSERT INTO token
|
||||
(workspace_id, token, owner, label, expiration, super_admin)
|
||||
VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6)",
|
||||
(workspace_id, token, owner, label, expiration, super_admin, email)
|
||||
VALUES ($1, $2, $3, $4, now() + ($5 || ' seconds')::interval, $6, $7)",
|
||||
&w_id,
|
||||
token,
|
||||
owner,
|
||||
label,
|
||||
expires_in.to_string(),
|
||||
is_super_admin
|
||||
is_super_admin,
|
||||
email
|
||||
)
|
||||
.execute(&mut tx)
|
||||
.await?;
|
||||
@@ -271,9 +274,10 @@ const NSJAIL_CONFIG_DOWNLOAD_PY_CONTENT: &str = include_str!("../nsjail/download
|
||||
const NSJAIL_CONFIG_RUN_PYTHON3_CONTENT: &str = include_str!("../nsjail/run.python3.config.proto");
|
||||
const NSJAIL_CONFIG_RUN_GO_CONTENT: &str = include_str!("../nsjail/run.go.config.proto");
|
||||
const NSJAIL_CONFIG_RUN_BASH_CONTENT: &str = include_str!("../nsjail/run.bash.config.proto");
|
||||
|
||||
const NSJAIL_CONFIG_RUN_DENO_CONTENT: &str = include_str!("../nsjail/run.deno.config.proto");
|
||||
const MAX_LOG_SIZE: u32 = 200000;
|
||||
|
||||
const RELATIVE_PYTHON_LOADER: &str = include_str!("../loader.py");
|
||||
|
||||
const GO_REQ_SPLITTER: &str = "//go.sum";
|
||||
const GIT_VERSION: &str = git_version!(args = ["--tag", "--always"], fallback = "unknown-version");
|
||||
|
||||
@@ -412,6 +416,17 @@ pub async fn run_worker(
|
||||
let pip_index_url = std::env::var("PIP_INDEX_URL").ok();
|
||||
let pip_extra_index_url = std::env::var("PIP_EXTRA_INDEX_URL").ok();
|
||||
let pip_trusted_host = std::env::var("PIP_TRUSTED_HOST").ok();
|
||||
let deno_auth_tokens = std::env::var("DENO_AUTH_TOKENS")
|
||||
.ok()
|
||||
.map(|x| format!(";{x}"))
|
||||
.unwrap_or_else(|| String::new());
|
||||
let max_log_size = std::env::var("MAX_LOG_SIZE")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i64>().ok())
|
||||
.unwrap_or(500000);
|
||||
let deno_flags = std::env::var("DENO_FLAGS")
|
||||
.ok()
|
||||
.map(|x| x.split(' ').map(|x| x.to_string()).collect());
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let tar_cache_rate = std::env::var("TAR_CACHE_RATE")
|
||||
@@ -429,6 +444,9 @@ pub async fn run_worker(
|
||||
pip_index_url,
|
||||
pip_extra_index_url,
|
||||
pip_trusted_host,
|
||||
max_log_size,
|
||||
deno_flags,
|
||||
deno_auth_tokens,
|
||||
};
|
||||
WORKER_STARTED.inc();
|
||||
|
||||
@@ -563,11 +581,11 @@ pub async fn run_worker(
|
||||
&job.permissioned_as,
|
||||
"ephemeral-script",
|
||||
timeout * 2,
|
||||
&job.created_by,
|
||||
&job.email,
|
||||
)
|
||||
.await.expect("could not create job token");
|
||||
tx.commit().await.expect("could not commit job token");
|
||||
let job_client = windmill_api_client::create_client(&worker_config.base_url, token.clone());
|
||||
let job_client = windmill_api_client::create_client(&worker_config.base_internal_url, token.clone());
|
||||
let is_flow = job.job_kind == JobKind::Flow || job.job_kind == JobKind::FlowPreview || job.job_kind == JobKind::FlowDependencies;
|
||||
|
||||
if let Some(err) = handle_queued_job(
|
||||
@@ -643,26 +661,29 @@ async fn handle_job_error(
|
||||
keep_job_dir: bool,
|
||||
base_internal_url: &str,
|
||||
) {
|
||||
add_completed_job_error(
|
||||
db,
|
||||
&job,
|
||||
format!("Unexpected error during job execution:\n{err}"),
|
||||
&err,
|
||||
metrics.clone(),
|
||||
)
|
||||
.await
|
||||
.map(|(_, m)| m)
|
||||
.unwrap_or_else(|_| Map::new());
|
||||
let err = match err {
|
||||
Error::JsonErr(err) => err,
|
||||
_ => json!({"message": err.to_string(), "name": "InternalErr"}),
|
||||
};
|
||||
|
||||
let update_job_future = || {
|
||||
add_completed_job_error(
|
||||
db,
|
||||
&job,
|
||||
format!("Unexpected error during job execution:\n{err}"),
|
||||
err.clone(),
|
||||
metrics.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
if job.is_flow_step || job.job_kind == JobKind::FlowPreview || job.job_kind == JobKind::Flow {
|
||||
let (flow, job_status_to_update) = if let Some(parent_job_id) = job.parent_job {
|
||||
let _ = update_job_future().await;
|
||||
(parent_job_id, job.id)
|
||||
} else {
|
||||
(job.id, Uuid::nil())
|
||||
};
|
||||
|
||||
let mut output_map = serde_json::Map::new();
|
||||
error_to_result(&mut output_map, &err);
|
||||
let updated_flow = update_flow_status_after_job_completion(
|
||||
db,
|
||||
client,
|
||||
@@ -670,7 +691,7 @@ async fn handle_job_error(
|
||||
&job_status_to_update,
|
||||
&job.workspace_id,
|
||||
false,
|
||||
serde_json::Value::Object(output_map),
|
||||
json!({ "error": err }),
|
||||
metrics.clone(),
|
||||
unrecoverable,
|
||||
same_worker_tx,
|
||||
@@ -680,9 +701,8 @@ async fn handle_job_error(
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
if let Err(err) = updated_flow {
|
||||
println!("error updating flow status: {}", err);
|
||||
|
||||
if let Err(err) = updated_flow {
|
||||
if let Some(parent_job_id) = job.parent_job {
|
||||
if let Ok(mut tx) = db.begin().await {
|
||||
if let Ok(Some(parent_job)) =
|
||||
@@ -692,7 +712,7 @@ async fn handle_job_error(
|
||||
db,
|
||||
&parent_job,
|
||||
format!("Unexpected error during flow job error handling:\n{err}"),
|
||||
err,
|
||||
json!({"message": err.to_string(), "name": "InternalErr"}),
|
||||
metrics.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -701,7 +721,10 @@ async fn handle_job_error(
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::error!(job_id = %job.id, err = err.alt(), "error handling job: {} {} {}", job.id, job.workspace_id, job.created_by);
|
||||
if job.parent_job.is_none() {
|
||||
let _ = update_job_future().await;
|
||||
}
|
||||
tracing::error!(job_id = %job.id, "error handling job: {err:#?} {} {} {}", job.id, job.workspace_id, job.created_by);
|
||||
}
|
||||
|
||||
async fn insert_initial_ping(
|
||||
@@ -731,8 +754,14 @@ struct Envs {
|
||||
pip_index_url: Option<String>,
|
||||
pip_extra_index_url: Option<String>,
|
||||
pip_trusted_host: Option<String>,
|
||||
deno_auth_tokens: String,
|
||||
deno_flags: Option<Vec<String>>,
|
||||
max_log_size: i64,
|
||||
}
|
||||
|
||||
fn extract_error_value(log_lines: &str) -> serde_json::Value {
|
||||
return json!({"message": log_lines.to_string().trim().to_string(), "name": "ExecutionErr"});
|
||||
}
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn handle_queued_job(
|
||||
job: QueuedJob,
|
||||
@@ -750,7 +779,7 @@ async fn handle_queued_job(
|
||||
base_internal_url: &str,
|
||||
) -> windmill_common::error::Result<()> {
|
||||
if job.canceled {
|
||||
return Err(Error::ExecutionErr(canceled_job_to_result(&job)))?;
|
||||
return Err(Error::JsonErr(canceled_job_to_result(&job)))?;
|
||||
}
|
||||
if let Some(e) = job.pre_run_error {
|
||||
return Err(Error::ExecutionErr(e));
|
||||
@@ -853,32 +882,36 @@ async fn handle_queued_job(
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_message = match e {
|
||||
let error_value = match e {
|
||||
Error::ExitStatus(_) => {
|
||||
let last_10_log_lines = logs
|
||||
.lines()
|
||||
.skip(logs.lines().count().max(13) - 13)
|
||||
.join("\n")
|
||||
.to_string()
|
||||
.replace("\n\n", "\n");
|
||||
let res = read_result(job_dir).await.ok();
|
||||
|
||||
let log_lines = last_10_log_lines
|
||||
.split("CODE EXECUTION ---")
|
||||
.last()
|
||||
.unwrap_or(&logs);
|
||||
format!("Error during execution of the script:\n{}", log_lines)
|
||||
if res.is_some() && res.clone().unwrap().is_object() {
|
||||
res.unwrap()
|
||||
} else {
|
||||
let last_10_log_lines = logs
|
||||
.lines()
|
||||
.skip(logs.lines().count().max(13) - 13)
|
||||
.join("\n")
|
||||
.to_string()
|
||||
.replace("\n\n", "\n");
|
||||
|
||||
let log_lines = last_10_log_lines
|
||||
.split("CODE EXECUTION ---")
|
||||
.last()
|
||||
.unwrap_or(&logs);
|
||||
|
||||
extract_error_value(log_lines)
|
||||
}
|
||||
}
|
||||
err @ _ => {
|
||||
json!({"message": format!("error during execution of the script:\n{}", err), "name": "ExecutionErr"})
|
||||
}
|
||||
err @ _ => format!("error before termination: {err:#?}"),
|
||||
};
|
||||
|
||||
let (_, output_map) = add_completed_job_error(
|
||||
db,
|
||||
&job,
|
||||
logs,
|
||||
error_message,
|
||||
Some(metrics.clone()),
|
||||
)
|
||||
.await?;
|
||||
let result =
|
||||
add_completed_job_error(db, &job, logs, error_value, Some(metrics.clone()))
|
||||
.await?;
|
||||
if job.is_flow_step {
|
||||
if let Some(parent_job) = job.parent_job {
|
||||
update_flow_status_after_job_completion(
|
||||
@@ -888,7 +921,7 @@ async fn handle_queued_job(
|
||||
&job.id,
|
||||
&job.workspace_id,
|
||||
false,
|
||||
serde_json::Value::Object(output_map),
|
||||
result,
|
||||
Some(metrics),
|
||||
false,
|
||||
same_worker_tx,
|
||||
@@ -918,6 +951,7 @@ async fn write_file(dir: &str, path: &str, content: &str) -> error::Result<File>
|
||||
|
||||
#[async_recursion]
|
||||
async fn transform_json_value(
|
||||
name: &str,
|
||||
client: &windmill_api_client::Client,
|
||||
workspace: &str,
|
||||
v: Value,
|
||||
@@ -928,32 +962,32 @@ async fn transform_json_value(
|
||||
let v = client
|
||||
.get_variable(workspace, path, Some(true))
|
||||
.await
|
||||
.map_err(to_anyhow)
|
||||
.map_err(|_| Error::NotFound(format!("Variable {path} not found for `{name}`")))
|
||||
.map(|v| v.into_inner())?
|
||||
.value
|
||||
.map_or_else(
|
||||
|| Err(Error::NotFound(format!("Variable not found at {path}"))),
|
||||
|e| Ok(e),
|
||||
)?;
|
||||
.unwrap_or_else(|| String::new());
|
||||
Ok(Value::String(v))
|
||||
}
|
||||
Value::String(y) if y.starts_with("$res:") => {
|
||||
let path = y.strip_prefix("$res:").unwrap();
|
||||
if path.split("/").count() < 2 {
|
||||
return Err(Error::InternalErr(
|
||||
format!("invalid resource path: {path}",),
|
||||
));
|
||||
return Err(Error::InternalErr(format!(
|
||||
"Argument `{name}` is an invalid resource path: {path}",
|
||||
)));
|
||||
}
|
||||
let v = client
|
||||
.get_resource_value(workspace, path)
|
||||
.await
|
||||
.map_err(to_anyhow)?
|
||||
.map_err(|_| Error::NotFound(format!("Resource {path} not found for `{name}`")))?
|
||||
.into_inner();
|
||||
transform_json_value(client, workspace, v).await
|
||||
transform_json_value(name, client, workspace, v).await
|
||||
}
|
||||
Value::Object(mut m) => {
|
||||
for (a, b) in m.clone().into_iter() {
|
||||
m.insert(a, transform_json_value(client, workspace, b).await?);
|
||||
m.insert(
|
||||
a.clone(),
|
||||
transform_json_value(&a, client, workspace, b).await?,
|
||||
);
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
}
|
||||
@@ -1116,7 +1150,7 @@ mount {{
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn handle_go_job(
|
||||
WorkerConfig { base_internal_url, disable_nuser, disable_nsjail, base_url, .. }: &WorkerConfig,
|
||||
Envs { nsjail_path, go_path, path_env, home_env, .. }: &Envs,
|
||||
Envs { nsjail_path, go_path, path_env, home_env, max_log_size, .. }: &Envs,
|
||||
logs: &mut String,
|
||||
job: &QueuedJob,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
@@ -1154,6 +1188,7 @@ async fn handle_go_job(
|
||||
job_dir,
|
||||
db,
|
||||
timeout,
|
||||
*max_log_size,
|
||||
go_path,
|
||||
true,
|
||||
skip_go_mod,
|
||||
@@ -1274,7 +1309,7 @@ func Run(req Req) (interface{{}}, error){{
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
handle_child(&job.id, db, logs, timeout, build_go).await?;
|
||||
handle_child(&job.id, db, logs, timeout, *max_log_size, build_go).await?;
|
||||
|
||||
Command::new(nsjail_path)
|
||||
.current_dir(job_dir)
|
||||
@@ -1300,14 +1335,14 @@ func Run(req Req) (interface{{}}, error){{
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?
|
||||
};
|
||||
handle_child(&job.id, db, logs, timeout, child).await?;
|
||||
handle_child(&job.id, db, logs, timeout, *max_log_size, child).await?;
|
||||
read_result(job_dir).await
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn handle_bash_job(
|
||||
WorkerConfig { base_internal_url, disable_nuser, disable_nsjail, base_url, .. }: &WorkerConfig,
|
||||
Envs { nsjail_path, path_env, home_env, .. }: &Envs,
|
||||
Envs { nsjail_path, path_env, home_env, max_log_size, .. }: &Envs,
|
||||
logs: &mut String,
|
||||
job: &QueuedJob,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
@@ -1379,7 +1414,7 @@ async fn handle_bash_job(
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?
|
||||
};
|
||||
handle_child(&job.id, db, logs, timeout, child).await?;
|
||||
handle_child(&job.id, db, logs, timeout, *max_log_size, child).await?;
|
||||
//for now bash jobs have an empty result object
|
||||
Ok(serde_json::json!(logs
|
||||
.lines()
|
||||
@@ -1399,7 +1434,7 @@ fn capitalize(s: &str) -> String {
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn handle_deno_job(
|
||||
WorkerConfig { base_internal_url, base_url, disable_nuser, disable_nsjail, .. }: &WorkerConfig,
|
||||
Envs { nsjail_path, deno_path, path_env, .. }: &Envs,
|
||||
Envs { nsjail_path, deno_path, path_env, max_log_size, deno_auth_tokens, deno_flags, .. }: &Envs,
|
||||
logs: &mut String,
|
||||
job: &QueuedJob,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
@@ -1442,16 +1477,30 @@ async function run() {{
|
||||
await Deno.writeTextFile("result.json", res_json);
|
||||
Deno.exit(0);
|
||||
}}
|
||||
run();
|
||||
run().catch(async (e) => {{
|
||||
await Deno.writeTextFile("result.json", JSON.stringify({{ message: e.message, name: e.name, stack: e.stack }}));
|
||||
Deno.exit(1);
|
||||
}});
|
||||
"#,
|
||||
);
|
||||
write_file(job_dir, "main.ts", &wrapper_content).await?;
|
||||
let w_id = job.workspace_id.clone();
|
||||
let import_map = format!(
|
||||
r#"{{
|
||||
"imports": {{
|
||||
"/": "{base_internal_url}/api/w/{w_id}/scripts/raw/p/",
|
||||
"./": "./"
|
||||
}}
|
||||
}}"#
|
||||
);
|
||||
write_file(job_dir, "import_map.json", &import_map).await?;
|
||||
let mut reserved_variables = get_reserved_variables(job, &token, &base_url, db).await?;
|
||||
reserved_variables.insert("RUST_LOG".to_string(), "info".to_string());
|
||||
|
||||
let hostname_base = base_url.split("://").last().unwrap_or("localhost");
|
||||
let hostname_internal = base_internal_url.split("://").last().unwrap_or("localhost");
|
||||
let deno_auth_tokens = format!("{token}@{hostname_base};{token}@{hostname_internal}");
|
||||
let deno_auth_tokens =
|
||||
format!("{token}@{hostname_base};{token}@{hostname_internal}{deno_auth_tokens}",);
|
||||
let child = async {
|
||||
Ok(if !disable_nsjail {
|
||||
let _ = write_file(
|
||||
@@ -1473,9 +1522,16 @@ run();
|
||||
if lockfile.is_some() {
|
||||
args.push("--lock=/tmp/lock.json");
|
||||
}
|
||||
args.push("--import-map");
|
||||
args.push("/tmp/import_map.json");
|
||||
args.push("--unstable");
|
||||
args.push("--v8-flags=--max-heap-size=2048");
|
||||
args.push("-A");
|
||||
if let Some(deno_flags) = deno_flags {
|
||||
for flag in deno_flags {
|
||||
args.push(flag);
|
||||
}
|
||||
} else {
|
||||
args.push("-A");
|
||||
}
|
||||
args.push("/tmp/main.ts");
|
||||
|
||||
Command::new(nsjail_path)
|
||||
@@ -1492,13 +1548,18 @@ run();
|
||||
} else {
|
||||
let mut args = Vec::new();
|
||||
let script_path = format!("{job_dir}/main.ts");
|
||||
let import_map_path = format!("{job_dir}/import_map.json");
|
||||
args.push("run");
|
||||
if lockfile.is_some() {
|
||||
args.push("--lock=/tmp/lock.json");
|
||||
}
|
||||
args.push("--import-map");
|
||||
args.push(&import_map_path);
|
||||
args.push("--unstable");
|
||||
args.push("--v8-flags=--max-heap-size=2048");
|
||||
args.push("-A");
|
||||
if let Some(deno_flags) = deno_flags {
|
||||
for flag in deno_flags {
|
||||
args.push(flag);
|
||||
}
|
||||
} else {
|
||||
args.push("-A");
|
||||
}
|
||||
args.push(&script_path);
|
||||
Command::new(deno_path)
|
||||
.current_dir(job_dir)
|
||||
@@ -1517,7 +1578,7 @@ run();
|
||||
}
|
||||
.instrument(trace_span!("create_deno_jail"))
|
||||
.await?;
|
||||
handle_child(&job.id, db, logs, timeout, child).await?;
|
||||
handle_child(&job.id, db, logs, timeout, *max_log_size, child).await?;
|
||||
read_result(job_dir).await
|
||||
}
|
||||
|
||||
@@ -1528,7 +1589,7 @@ async fn create_args_and_out_file(
|
||||
job_dir: &str,
|
||||
) -> Result<(), Error> {
|
||||
let args = if let Some(args) = &job.args {
|
||||
Some(transform_json_value(client, &job.workspace_id, args.clone()).await?)
|
||||
Some(transform_json_value("args", client, &job.workspace_id, args.clone()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1538,10 +1599,14 @@ async fn create_args_and_out_file(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref RELATIVE_IMPORT_REGEX: Regex = Regex::new(r#"(import|from)\s(u|f)\."#).unwrap();
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "trace", skip_all)]
|
||||
async fn handle_python_job(
|
||||
WorkerConfig { base_internal_url, base_url, disable_nuser, disable_nsjail, .. }: &WorkerConfig,
|
||||
envs @ Envs { nsjail_path, python_path, path_env, .. }: &Envs,
|
||||
envs @ Envs { nsjail_path, python_path, path_env, max_log_size, .. }: &Envs,
|
||||
requirements_o: Option<String>,
|
||||
job_dir: &str,
|
||||
worker_dir: &str,
|
||||
@@ -1608,12 +1673,17 @@ async fn handle_python_job(
|
||||
|
||||
set_logs(logs, &job.id, db).await;
|
||||
|
||||
let relative_imports = RELATIVE_IMPORT_REGEX.is_match(&inner_content);
|
||||
|
||||
let _ = write_file(job_dir, "inner.py", inner_content).await?;
|
||||
if relative_imports {
|
||||
let _ = write_file(job_dir, "loader.py", RELATIVE_PYTHON_LOADER).await?;
|
||||
}
|
||||
|
||||
let sig = windmill_parser_py::parse_python_signature(inner_content)?;
|
||||
let transforms = sig
|
||||
.args
|
||||
.into_iter()
|
||||
.iter()
|
||||
.map(|x| match x.typ {
|
||||
windmill_parser::Typ::Bytes => {
|
||||
format!(
|
||||
@@ -1636,11 +1706,37 @@ async fn handle_python_job(
|
||||
.join("");
|
||||
create_args_and_out_file(client, job, job_dir).await?;
|
||||
|
||||
let import_loader = if relative_imports {
|
||||
"import loader"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let import_base64 = if sig
|
||||
.args
|
||||
.iter()
|
||||
.any(|x| x.typ == windmill_parser::Typ::Bytes)
|
||||
{
|
||||
"import base64"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let import_datetime = if sig
|
||||
.args
|
||||
.iter()
|
||||
.any(|x| x.typ == windmill_parser::Typ::Datetime)
|
||||
{
|
||||
"from datetime import datetime"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let wrapper_content: String = format!(
|
||||
r#"
|
||||
import json
|
||||
import base64
|
||||
from datetime import datetime
|
||||
{import_loader}
|
||||
{import_base64}
|
||||
{import_datetime}
|
||||
import traceback
|
||||
import sys
|
||||
|
||||
inner_script = __import__("inner")
|
||||
|
||||
@@ -1650,10 +1746,18 @@ for k, v in list(kwargs.items()):
|
||||
if v == '<function call>':
|
||||
del kwargs[k]
|
||||
{transforms}
|
||||
res = inner_script.main(**kwargs)
|
||||
res_json = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
|
||||
with open("result.json", 'w') as f:
|
||||
f.write(res_json)
|
||||
try:
|
||||
res = inner_script.main(**kwargs)
|
||||
res_json = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
|
||||
with open("result.json", 'w') as f:
|
||||
f.write(res_json)
|
||||
except Exception as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
tb = traceback.format_tb(exc_traceback)
|
||||
with open("result.json", 'w') as f:
|
||||
err_json = json.dumps({{ "message": str(e), "name": e.__class__.__name__, "stack": '\n'.join(tb[1:]) }}, separators=(',', ':'), default=str).replace('\n', '')
|
||||
f.write(err_json)
|
||||
sys.exit(1)
|
||||
"#,
|
||||
);
|
||||
write_file(job_dir, "main.py", &wrapper_content).await?;
|
||||
@@ -1733,7 +1837,7 @@ mount {{
|
||||
.spawn()?
|
||||
};
|
||||
|
||||
handle_child(&job.id, db, logs, timeout, child).await?;
|
||||
handle_child(&job.id, db, logs, timeout, *max_log_size, child).await?;
|
||||
read_result(job_dir).await
|
||||
}
|
||||
|
||||
@@ -1929,7 +2033,7 @@ async fn generate_deno_lock(
|
||||
job_dir: &str,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
timeout: i32,
|
||||
Envs { deno_path, .. }: &Envs,
|
||||
Envs { deno_path, max_log_size, .. }: &Envs,
|
||||
) -> error::Result<String> {
|
||||
let _ = write_file(job_dir, "main.ts", code).await?;
|
||||
|
||||
@@ -1947,7 +2051,7 @@ async fn generate_deno_lock(
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
handle_child(job_id, db, logs, timeout, child).await?;
|
||||
handle_child(job_id, db, logs, timeout, *max_log_size, child).await?;
|
||||
|
||||
let path_lock = format!("{job_dir}/lock.json");
|
||||
let mut file = File::open(path_lock).await?;
|
||||
@@ -1978,6 +2082,7 @@ async fn capture_dependency_job(
|
||||
job_dir,
|
||||
db,
|
||||
timeout,
|
||||
envs.max_log_size,
|
||||
&envs.go_path,
|
||||
false,
|
||||
false,
|
||||
@@ -1996,7 +2101,7 @@ async fn pip_compile(
|
||||
requirements: &str,
|
||||
logs: &mut String,
|
||||
job_dir: &str,
|
||||
Envs { pip_extra_index_url, pip_index_url, pip_trusted_host, .. }: &Envs,
|
||||
Envs { pip_extra_index_url, pip_index_url, pip_trusted_host, max_log_size, .. }: &Envs,
|
||||
db: &Pool<Postgres>,
|
||||
timeout: i32,
|
||||
) -> error::Result<String> {
|
||||
@@ -2022,7 +2127,7 @@ async fn pip_compile(
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
handle_child(job_id, db, logs, timeout, child)
|
||||
handle_child(job_id, db, logs, timeout, *max_log_size, child)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Lock file generation failed: {e:?}")))?;
|
||||
let path_lock = format!("{job_dir}/requirements.txt");
|
||||
@@ -2044,6 +2149,7 @@ async fn install_go_dependencies(
|
||||
job_dir: &str,
|
||||
db: &sqlx::Pool<sqlx::Postgres>,
|
||||
timeout: i32,
|
||||
max_log_size: i64,
|
||||
go_path: &str,
|
||||
preview: bool,
|
||||
skip_go_mod: bool,
|
||||
@@ -2057,7 +2163,7 @@ async fn install_go_dependencies(
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
handle_child(job_id, db, logs, timeout, child).await?;
|
||||
handle_child(job_id, db, logs, timeout, max_log_size, child).await?;
|
||||
}
|
||||
let child = Command::new(go_path)
|
||||
.current_dir(job_dir)
|
||||
@@ -2065,7 +2171,7 @@ async fn install_go_dependencies(
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
handle_child(job_id, db, logs, timeout, child)
|
||||
handle_child(job_id, db, logs, timeout, max_log_size, child)
|
||||
.await
|
||||
.map_err(|e| Error::ExecutionErr(format!("Lock file generation failed: {e:?}")))?;
|
||||
|
||||
@@ -2147,6 +2253,7 @@ async fn handle_child(
|
||||
db: &Pool<Postgres>,
|
||||
logs: &mut String,
|
||||
timeout: i32,
|
||||
max_log_size: i64,
|
||||
mut child: Child,
|
||||
) -> error::Result<()> {
|
||||
let timeout = Duration::from_secs(u64::try_from(timeout).expect("invalid timeout"));
|
||||
@@ -2238,7 +2345,7 @@ async fn handle_child(
|
||||
/* a future that reads output from the child and appends to the database */
|
||||
let lines = async move {
|
||||
/* log_remaining is zero when output limit was reached */
|
||||
let mut log_remaining = (MAX_LOG_SIZE as usize).saturating_sub(logs.chars().count());
|
||||
let mut log_remaining = (max_log_size as usize).saturating_sub(logs.chars().count());
|
||||
let mut result = io::Result::Ok(());
|
||||
let mut output = output;
|
||||
/* `do_write` resolves the task, but does not contain the Result.
|
||||
@@ -2269,7 +2376,7 @@ async fn handle_child(
|
||||
tracing::info!(%job_id, "Too many logs lines for job {job_id}");
|
||||
let _ = set_too_many_logs.send(true);
|
||||
joined.push_str(&format!(
|
||||
"Job logs or result reached character limit of {MAX_LOG_SIZE}; killing job."
|
||||
"Job logs or result reached character limit of {max_log_size}; killing job."
|
||||
));
|
||||
/* stop reading and drop our streams fairly quickly */
|
||||
break;
|
||||
@@ -2353,18 +2460,21 @@ async fn handle_child(
|
||||
kill_tx.send(()).expect("send should always work");
|
||||
|
||||
match wait_result {
|
||||
_ if *too_many_logs.borrow() => Err(Error::ExecutionErr(
|
||||
"logs or result reached limit".to_string(),
|
||||
)),
|
||||
_ if *too_many_logs.borrow() => Err(Error::ExecutionErr(format!(
|
||||
"logs or result reached limit. Set MAX_LOG_SIZE higher (current: {max_log_size})"
|
||||
))),
|
||||
Ok(Ok(status)) => {
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else if let Some(code) = status.code() {
|
||||
Err(error::Error::ExitStatus(code))
|
||||
} else {
|
||||
Err(error::Error::ExecutionErr(
|
||||
"process terminated by signal".to_string(),
|
||||
))
|
||||
Err(error::Error::ExecutionErr(format!(
|
||||
"process terminated by signal: {:#?}, stopped_signal: {:#?}, core_dumped: {}",
|
||||
status.signal(),
|
||||
status.stopped_signal(),
|
||||
status.core_dumped()
|
||||
)))
|
||||
}
|
||||
}
|
||||
Ok(Err(kill_reason)) => Err(Error::ExecutionErr(format!(
|
||||
@@ -2531,7 +2641,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, timeout: i32, base_url: &str) {
|
||||
&job.permissioned_as,
|
||||
"ephemeral-zombie-jobs",
|
||||
timeout * 2,
|
||||
&job.created_by,
|
||||
&job.email,
|
||||
)
|
||||
.await
|
||||
.expect("could not create job token");
|
||||
@@ -2564,6 +2674,7 @@ async fn handle_python_reqs(
|
||||
pip_extra_index_url,
|
||||
pip_trusted_host,
|
||||
nsjail_path,
|
||||
max_log_size,
|
||||
..
|
||||
}: &Envs,
|
||||
job: &QueuedJob,
|
||||
@@ -2658,7 +2769,7 @@ async fn handle_python_reqs(
|
||||
.spawn()?
|
||||
};
|
||||
|
||||
let child = handle_child(&job.id, db, logs, timeout, child).await;
|
||||
let child = handle_child(&job.id, db, logs, timeout, *max_log_size, child).await;
|
||||
tracing::info!(
|
||||
worker_name = %worker_name,
|
||||
job_id = %job.id,
|
||||
|
||||
@@ -303,7 +303,6 @@ pub async fn update_flow_status_after_job_completion(
|
||||
.fetch_one(&mut tx)
|
||||
.await?;
|
||||
|
||||
tracing::info!("setting failure module for flow XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX {parent_module:?}");
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE queue
|
||||
@@ -403,18 +402,17 @@ pub async fn update_flow_status_after_job_completion(
|
||||
{
|
||||
true
|
||||
}
|
||||
false if has_failure_module(flow, &mut tx).await? => true,
|
||||
false if has_failure_module(flow, &mut tx).await? && !is_failure_step => true,
|
||||
false => false,
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
if old_status.step == 0
|
||||
&& !flow_job.is_flow_step
|
||||
&& flow_job.schedule_path.is_some()
|
||||
&& flow_job.script_path.is_some()
|
||||
{
|
||||
schedule_again_if_scheduled(
|
||||
tx = schedule_again_if_scheduled(
|
||||
tx,
|
||||
db,
|
||||
flow_job.schedule_path.as_ref().unwrap(),
|
||||
flow_job.script_path.as_ref().unwrap(),
|
||||
@@ -422,21 +420,24 @@ pub async fn update_flow_status_after_job_completion(
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
|
||||
let done = if !should_continue_flow {
|
||||
let logs = if flow_job.canceled {
|
||||
"Flow job canceled".to_string()
|
||||
} else if stop_early {
|
||||
format!("Flow job stopped early because of a stop early predicate returning true")
|
||||
} else if success {
|
||||
"Flow job completed with success".to_string()
|
||||
} else {
|
||||
"Flow job completed".to_string()
|
||||
"Flow job completed with error".to_string()
|
||||
};
|
||||
if flow_job.canceled {
|
||||
add_completed_job_error(
|
||||
db,
|
||||
&flow_job,
|
||||
logs,
|
||||
&canceled_job_to_result(&flow_job),
|
||||
canceled_job_to_result(&flow_job),
|
||||
metrics.clone(),
|
||||
)
|
||||
.await?;
|
||||
@@ -469,7 +470,7 @@ pub async fn update_flow_status_after_job_completion(
|
||||
db,
|
||||
&flow_job,
|
||||
"Unexpected error during flow chaining:\n".to_string(),
|
||||
err,
|
||||
json!({"message": err.to_string(), "name": "InternalError"}),
|
||||
metrics.clone(),
|
||||
)
|
||||
.await;
|
||||
@@ -687,13 +688,20 @@ async fn transform_input(
|
||||
}
|
||||
}
|
||||
|
||||
let lresult = last_result.clone();
|
||||
let error = if let Some(error) = lresult.as_object() {
|
||||
error.get("error")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (key, val) in input_transforms.into_iter() {
|
||||
match val {
|
||||
InputTransform::Static { value: _ } => (),
|
||||
InputTransform::Javascript { expr } => {
|
||||
let flow_input = flow_args.clone().unwrap_or_else(|| json!({}));
|
||||
let previous_result = last_result.clone();
|
||||
let context = vec![
|
||||
let mut context = vec![
|
||||
("params".to_string(), json!(mapped)),
|
||||
("previous_result".to_string(), previous_result),
|
||||
("flow_input".to_string(), flow_input),
|
||||
@@ -705,6 +713,10 @@ async fn transform_input(
|
||||
("approvers".to_string(), json!(approvers.clone())),
|
||||
];
|
||||
|
||||
if error.is_some() {
|
||||
context.push(("error".to_string(), error.unwrap().clone()));
|
||||
}
|
||||
|
||||
let v = eval_timeout(
|
||||
expr.to_string(),
|
||||
context,
|
||||
@@ -976,7 +988,7 @@ async fn push_next_flow_job(
|
||||
let success = false;
|
||||
let skipped = false;
|
||||
let logs = "Timed out waiting to be resumed".to_string();
|
||||
let result = json!({ "error": logs });
|
||||
let result = json!({ "error": {"message": logs, "name": "SuspendedTimeout"}});
|
||||
let _uuid =
|
||||
add_completed_job(db, &flow_job, success, skipped, result, logs).await?;
|
||||
|
||||
@@ -1871,7 +1883,7 @@ async fn get_transform_context(
|
||||
&flow_job.permissioned_as,
|
||||
"transform-input",
|
||||
10,
|
||||
&flow_job.created_by,
|
||||
&flow_job.email,
|
||||
)
|
||||
.await?;
|
||||
//we need to commit asap otherwise the token won't be valid for auth to check outside of this transaction
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// windmill
|
||||
export { setClient } from "https://deno.land/x/windmill@v1.56.0/mod.ts";
|
||||
export * from "https://deno.land/x/windmill@v1.56.0/windmill-api/index.t";
|
||||
export * from "https://deno.land/x/windmill@v1.56.0/windmill-api/index.ts";
|
||||
|
||||
// cliffy
|
||||
export { Command } from "https://deno.land/x/cliffy@v0.25.6/command/command.ts";
|
||||
|
||||
@@ -6,9 +6,9 @@ import { GlobalOptions } from "./types.ts";
|
||||
async function pull(opts: GlobalOptions) {
|
||||
const workspace = await resolveWorkspace(opts);
|
||||
|
||||
if (workspace.workspaceId !== "starter") {
|
||||
if (workspace.workspaceId !== "admins") {
|
||||
console.log(
|
||||
"Should only sync to starter workspace, but current is not starter.",
|
||||
"Should only sync to admins workspace, but current is not admins.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import hub from "./hub.ts";
|
||||
import { tryResolveVersion } from "./context.ts";
|
||||
import { GlobalOptions } from "./types.ts";
|
||||
|
||||
const VERSION = "v1.56.0";
|
||||
const VERSION = "v1.60.0";
|
||||
|
||||
const command = new Command()
|
||||
.name("wmill")
|
||||
@@ -66,9 +66,8 @@ try {
|
||||
} catch (e) {
|
||||
if (e.name === "ApiError") {
|
||||
console.log("Server failed. " + e.statusText + ": " + e.body);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
export default command;
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
import { GlobalOptions } from "./types.ts";
|
||||
import { getRootStore } from "./store.ts";
|
||||
import { loginInteractive, tryGetLoginInfo } from "./login.ts";
|
||||
import { colors, Command, DelimiterStream, Input, Table } from "./deps.ts";
|
||||
import {
|
||||
colors,
|
||||
Command,
|
||||
DelimiterStream,
|
||||
Input,
|
||||
setClient,
|
||||
Table,
|
||||
WorkspaceService,
|
||||
} from "./deps.ts";
|
||||
import { requireLogin } from "./context.ts";
|
||||
|
||||
export type Workspace = {
|
||||
remote: string;
|
||||
@@ -132,7 +141,11 @@ async function switchC(opts: GlobalOptions, workspaceName: string) {
|
||||
}
|
||||
|
||||
export async function add(
|
||||
opts: GlobalOptions,
|
||||
opts: GlobalOptions & {
|
||||
create: boolean;
|
||||
createWorkspaceName: string | undefined;
|
||||
createUsername: string;
|
||||
},
|
||||
workspaceName: string | undefined,
|
||||
workspaceId: string | undefined,
|
||||
remote: string | undefined,
|
||||
@@ -178,6 +191,25 @@ export async function add(
|
||||
token = await loginInteractive(remote);
|
||||
}
|
||||
|
||||
if (opts.create) {
|
||||
setClient(token, remote.endsWith('/') ? remote.substring(0, remote.length - 1) : remote);
|
||||
|
||||
if (
|
||||
!await WorkspaceService.existsWorkspace({
|
||||
requestBody: { id: workspaceId },
|
||||
})
|
||||
) {
|
||||
console.log(colors.yellow("Workspace does not exist. Creating..."));
|
||||
await WorkspaceService.createWorkspace({
|
||||
requestBody: {
|
||||
id: workspaceId,
|
||||
name: opts.createWorkspaceName ?? workspaceId,
|
||||
username: opts.createUsername,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await addWorkspace({
|
||||
name: workspaceName,
|
||||
remote: remote,
|
||||
@@ -224,6 +256,18 @@ const command = new Command()
|
||||
.command("add")
|
||||
.description("Add a workspace")
|
||||
.arguments("[workspace_name:string] [workspace_id:string] [remote:string]")
|
||||
.option("-c --create", "Create the workspace if it does not exist")
|
||||
.option(
|
||||
"--create-workspace-name <workspace_name:string>",
|
||||
"Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.",
|
||||
)
|
||||
.option(
|
||||
"--create-username <username:string>",
|
||||
"Specify your own username in the newly created workspace. Ignored if --create is not specified or the workspace already exists.",
|
||||
{
|
||||
default: "admin",
|
||||
},
|
||||
)
|
||||
.action(add as any)
|
||||
.command("remove")
|
||||
.description("Remove a workspace")
|
||||
|
||||
@@ -7,6 +7,12 @@ export {
|
||||
UserService, WorkspaceService
|
||||
} from './windmill-api/index.ts'
|
||||
|
||||
|
||||
// @ts-ignore: Otherwise BigInt is not supported for export
|
||||
BigInt.prototype.toJSON = function () {
|
||||
return this.toString();
|
||||
};
|
||||
|
||||
export { pgSql, pgClient } from './pg.ts'
|
||||
export { mySql, mysqlClient } from './mysql.ts'
|
||||
|
||||
@@ -72,7 +78,7 @@ export async function setResource(value: any, path?: string, initializeToTypeIfN
|
||||
path = path ?? getStatePath()
|
||||
const workspace = getWorkspace()
|
||||
if (await ResourceService.existsResource({ workspace, path })) {
|
||||
await ResourceService.updateResource({ workspace, path, requestBody: { value } })
|
||||
await ResourceService.updateResourceValue({ workspace, path, requestBody: { value } })
|
||||
} else if (initializeToTypeIfNotExist) {
|
||||
await ResourceService.createResource({ workspace, requestBody: { path, value, resource_type: initializeToTypeIfNotExist } })
|
||||
} else {
|
||||
|
||||
1
depot.json
Normal file
1
depot.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "00zvxx9420" }
|
||||
@@ -3,6 +3,9 @@ FROM ghcr.io/windmill-labs/windmill-ee
|
||||
RUN /usr/local/bin/python3 -m pip install nltk
|
||||
RUN mkdir -p /nsjail_data/python && HOME=/nsjail_data/python /usr/local/bin/python3 -m nltk.downloader vader_lexicon
|
||||
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y tesseract-ocr \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -1 +1 @@
|
||||
v17.9.0
|
||||
v16.15.0
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:80 {
|
||||
http://localhost {
|
||||
bind {$ADDRESS}
|
||||
reverse_proxy /api/* http://localhost:8000
|
||||
reverse_proxy /* http://localhost:3000
|
||||
reverse_proxy /ws/* http://localhost:3001 {
|
||||
lb_policy header "Authorization"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
https://localhost {
|
||||
bind {$ADDRESS}
|
||||
reverse_proxy /ws/* http://localhost:3001
|
||||
}
|
||||
|
||||
2695
frontend/package-lock.json
generated
2695
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "windmill",
|
||||
"version": "1.56.0",
|
||||
"version": "1.60.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
@@ -9,66 +9,65 @@
|
||||
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
|
||||
"format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. .",
|
||||
"package": "svelte-kit package && cd package && rm README.md && rm README_DEV.md && sed -i -e 's/windmill/windmill-components/g' package.json",
|
||||
"package": "svelte-package && cd package && rm README.md && rm README_DEV.md && sed -i -e 's/windmill/windmill-components/g' package.json",
|
||||
"generate-backend-client": "openapi --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions && sed -i '213 i \\ request.referrerPolicy = \"no-referrer\"\n' src/lib/gen/core/request.ts",
|
||||
"pretest": "tsc --incremental -p tests/tsconfig.json",
|
||||
"test": "playwright test --config=tests-out/playwright.config.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.28.0",
|
||||
"@sveltejs/adapter-static": "^1.0.0-next.48",
|
||||
"@sveltejs/kit": "next",
|
||||
"@playwright/test": "^1.29.2",
|
||||
"@sveltejs/adapter-static": "^1.0.0",
|
||||
"@sveltejs/kit": "^1.0.0-next.589",
|
||||
"@sveltejs/package": "^1.0.1",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.8",
|
||||
"@types/vscode": "~1.68.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.46.1",
|
||||
"@typescript-eslint/parser": "^5.46.1",
|
||||
"@windmill-labs/svelvet": "^4.0.6",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/vscode": "~1.74.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.49.0",
|
||||
"@typescript-eslint/parser": "^5.48.0",
|
||||
"@windmill-labs/svelvet": "^4.0.20",
|
||||
"@zerodevx/svelte-toast": "^0.8.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cssnano": "^5.1.14",
|
||||
"d3-dag": "^0.11.5",
|
||||
"eslint": "^8.28.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"lucide-svelte": "^0.104.0",
|
||||
"openapi-typescript-codegen": "^0.23.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"postcss": "^8.4.18",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier": "^2.8.3",
|
||||
"prettier-plugin-svelte": "^2.9.0",
|
||||
"simple-svelte-autocomplete": "^2.5.0",
|
||||
"simple-svelte-autocomplete": "^2.5.1",
|
||||
"stylelint-config-recommended": "^9.0.0",
|
||||
"svelte": "^3.53.1",
|
||||
"svelte-awesome": "^3.0.0",
|
||||
"svelte-check": "^2.9.2",
|
||||
"svelte-dnd-action": "^0.9.21",
|
||||
"svelte-check": "^3.0.2",
|
||||
"svelte-dnd-action": "^0.9.22",
|
||||
"svelte-grid": "^5.1.1",
|
||||
"svelte-heros": "^2.3.5",
|
||||
"svelte-highlight": "^6.2.1",
|
||||
"svelte-markdown": "^0.2.3",
|
||||
"svelte-overlay": "^1.4.1",
|
||||
"svelte-popperjs": "^1.3.2",
|
||||
"svelte-preprocess": "^4.9.8",
|
||||
"svelte-preprocess": "^5.0.0",
|
||||
"svelte-splitpanes": "^0.7.3",
|
||||
"svelte2tsx": "^0.5.22",
|
||||
"tailwindcss": "^3.2.2",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.3"
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.3"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.0",
|
||||
"@leeoniya/ufuzzy": "^0.8.0",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.2.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.2.1",
|
||||
"@leeoniya/ufuzzy": "^0.9.1",
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@tanstack/svelte-table": "^8.7.2",
|
||||
"@types/autosize": "^4.0.1",
|
||||
"@types/node": "^18.11.9",
|
||||
"@tanstack/svelte-table": "^8.7.6",
|
||||
"async-mutex": "^0.4.0",
|
||||
"autosize": "^5.0.2",
|
||||
"chartjs-adapter-date-fns": "^2.0.0",
|
||||
"chartjs-adapter-date-fns": "^3.0.0",
|
||||
"chartjs-plugin-zoom": "^2.0.0",
|
||||
"date-fns": "^2.29.3",
|
||||
"highlight.js": "^11.7.0",
|
||||
@@ -76,10 +75,13 @@
|
||||
"monaco-editor-workers": "0.33.0",
|
||||
"monaco-languageclient": "2.1.0",
|
||||
"monaco-yaml": "^4.0.2",
|
||||
"set-interval-async": "^3.0.2",
|
||||
"svelte-autosize": "^1.0.1",
|
||||
"svelte-chartjs": "^3.1.0",
|
||||
"svelte-select": "^5.0.0-beta.40",
|
||||
"svelte-portal": "^2.2.0",
|
||||
"svelte-select": "^5.0.2",
|
||||
"vscode-ws-jsonrpc": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^3.53.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body class="outline-none focus:outline-none">
|
||||
%sveltekit.body%
|
||||
<body data-sveltekit-preload-code="viewport" class="outline-none focus:outline-none">
|
||||
<div style="display: contents">
|
||||
%sveltekit.body%
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
67
frontend/src/global.d.ts
vendored
67
frontend/src/global.d.ts
vendored
@@ -8,3 +8,70 @@ declare namespace svelte.JSX {
|
||||
onfinalize?: (event: CustomEvent<DndEvent<ItemType>> & { target: EventTarget & T }) => void
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'svelte-grid' {
|
||||
import type { SvelteComponentTyped } from 'svelte'
|
||||
|
||||
export interface Size {
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface Positon {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface ItemLayout extends Size, Positon {
|
||||
fixed?: boolean
|
||||
resizable?: boolean
|
||||
draggable?: boolean
|
||||
customDragger?: boolean
|
||||
customResizer?: boolean
|
||||
min?: Size
|
||||
max?: Size
|
||||
}
|
||||
|
||||
export type Item<T> = T & { [width: number]: ItemLayout; data: any }
|
||||
export type FilledItem<T> = T & { [width: number]: Required<ItemLayout>; data: any }
|
||||
|
||||
export interface Props<T> {
|
||||
fillSpace?: boolean
|
||||
items: FilledItem<T>[],
|
||||
rowHeight: number
|
||||
cols: [number, number][]
|
||||
gap?: [number, number]
|
||||
fastStart?: boolean
|
||||
throttleUpdate?: number
|
||||
throttleResize?: number
|
||||
|
||||
scroller?: undefined
|
||||
sensor?: number
|
||||
}
|
||||
|
||||
export interface Slots<T> {
|
||||
default: { item: ItemLayout; dataItem: Item<T> }
|
||||
}
|
||||
|
||||
export default class Grid<T = {}> extends SvelteComponentTyped<
|
||||
Props<T>,
|
||||
{
|
||||
pointerup: CustomEvent<{ id: string }>
|
||||
},
|
||||
Slots<T>
|
||||
> { }
|
||||
}
|
||||
|
||||
declare module 'svelte-grid/build/helper/index.mjs' {
|
||||
import { ItemLayout } from 'svelte-grid'
|
||||
|
||||
const x: {
|
||||
normalize(items: any[], col: any): unknown[]
|
||||
adjust(items: any[], col: any): unknown[]
|
||||
findSpace(item: any, items: any, cols: any): unknown
|
||||
|
||||
item<T>(obj: ItemLayout): Required<ItemLayout>
|
||||
}
|
||||
|
||||
export default x
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
whitespace: normal !important;
|
||||
}
|
||||
|
||||
pre code.hljs {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
svelte-virtual-list-row {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -40,7 +44,7 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.templatable-editor span.mtk20 {
|
||||
.Template-editor span.mtk20 {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
72
frontend/src/lib/components/AddUser.svelte
Normal file
72
frontend/src/lib/components/AddUser.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Button, ToggleButton, ToggleButtonGroup } from './common'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let email: string
|
||||
let username: string
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
if (key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addUser()
|
||||
}
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
await WorkspaceService.addUser({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
email,
|
||||
username,
|
||||
is_admin: selected == 'admin',
|
||||
operator: selected == 'operator'
|
||||
}
|
||||
})
|
||||
sendUserToast(`Added ${email}`)
|
||||
dispatch('new')
|
||||
}
|
||||
|
||||
let selected: 'operator' | 'author' | 'admin' = 'author'
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row">
|
||||
<input type="email" on:keyup={handleKeyUp} placeholder="email" bind:value={email} class="mr-4" />
|
||||
<input
|
||||
type="text"
|
||||
on:keyup={handleKeyUp}
|
||||
placeholder="username"
|
||||
bind:value={username}
|
||||
class="mr-4"
|
||||
/>
|
||||
<ToggleButtonGroup bind:selected>
|
||||
<ToggleButton position="left" value="operator" size="sm"
|
||||
>Operator <Tooltip
|
||||
>An operator can only execute and view scripts/flows/apps from your workspace, and only
|
||||
those that he has visibility on</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="center" value="author" size="sm"
|
||||
>Author <Tooltip
|
||||
>An Author can execute and view scripts/flows/apps, but he can also create new ones</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="right" value="admin" size="sm">Admin</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="!ml-8"
|
||||
on:click={addUser}
|
||||
disabled={email === undefined}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1,35 +1,48 @@
|
||||
<script lang="ts" context="module">
|
||||
const apiTokenApps: Record<string, { img?: string; instructions: string[]; key?: string }> = {
|
||||
airtable: {
|
||||
img: 'airtable_connect.png',
|
||||
img: '/airtable_connect.png',
|
||||
instructions: ['Click on the top-right avatar', 'Click on Account', 'Find "Api"']
|
||||
},
|
||||
discord_webhook: {
|
||||
img: 'discord_webhook.png',
|
||||
img: '/discord_webhook.png',
|
||||
instructions: ['Click on Server Settings', 'Click on Integration', 'Find "Webhooks"'],
|
||||
key: 'webhook_url'
|
||||
},
|
||||
toggl: {
|
||||
img: 'toggl_connect.png',
|
||||
img: '/toggl_connect.png',
|
||||
instructions: [
|
||||
'Go to <a href="https://track.toggl.com/profile" target="_blank" rel=”noopener noreferrer”>https://track.toggl.com/profile</a>',
|
||||
'Find "API Token"'
|
||||
]
|
||||
},
|
||||
mailchimp: {
|
||||
img: 'mailchimp_connect.png',
|
||||
img: '/mailchimp_connect.png',
|
||||
instructions: [
|
||||
'Go to <a href="https://admin.mailchimp.com/account/api" target="_blank" rel=”noopener noreferrer”>https://admin.mailchimp.com/account/api</a>',
|
||||
'Find "Your API Keys"'
|
||||
]
|
||||
},
|
||||
sendgrid: {
|
||||
img: 'sendgrid_connect.png',
|
||||
img: '/sendgrid_connect.png',
|
||||
instructions: [
|
||||
'Go to <a href="https://app.sendgrid.com/settings/api_keys" target="_blank" rel=”noopener noreferrer”>https://app.sendgrid.com/settings/api_keys</a>',
|
||||
'Create an API key',
|
||||
'Copy your key'
|
||||
]
|
||||
},
|
||||
supabase: {
|
||||
img: '/supabase_connect.png',
|
||||
instructions: ['Go to the API Settings of your app to find the project URL and key']
|
||||
},
|
||||
|
||||
square: {
|
||||
img: '/square_connect.gif',
|
||||
instructions: [
|
||||
'Go to <a href="https://developer.squareup.com/apps" target="_blank" rel=”noopener noreferrer”>https://developer.squareup.com/apps</a>',
|
||||
'In the left pane, choose Credentials',
|
||||
'At the top of the page, choose Production mode for a production access token or Sandbox mode for a Sandbox access token.'
|
||||
]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -126,6 +139,7 @@
|
||||
const availableRts = await ResourceService.listResourceTypeNames({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
|
||||
connectsManual = availableRts
|
||||
.filter((x) => !Object.keys(connects ?? {}).includes(x))
|
||||
.map((x) => [
|
||||
@@ -151,6 +165,7 @@
|
||||
window.location.href = url.toString()
|
||||
} else {
|
||||
window.open(url.toString(), '_blank')
|
||||
drawer.closeDrawer()
|
||||
}
|
||||
} else {
|
||||
let exists = await VariableService.existsVariable({
|
||||
@@ -382,9 +397,6 @@
|
||||
namePlaceholder="my_{resource_type}"
|
||||
kind="resource"
|
||||
/>
|
||||
<h2 class="mt-4 mb-2">Description</h2>
|
||||
|
||||
<textarea type="text" autocomplete="off" use:autosize bind:value={description} />
|
||||
|
||||
{#if apiTokenApps[resource_type]}
|
||||
<h2 class="mt-4 mb-2">Instructions</h2>
|
||||
@@ -404,10 +416,13 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h2 class="mt-8">Value</h2>
|
||||
<h2 class="mt-4">Value</h2>
|
||||
<div class="mt-4">
|
||||
<ApiConnectForm password={key ?? ''} {resource_type} bind:args bind:isValid />
|
||||
</div>
|
||||
|
||||
<h2 class="mt-4 mb-2">Description</h2>
|
||||
<textarea type="text" autocomplete="off" use:autosize bind:value={description} />
|
||||
{:else}
|
||||
<Path
|
||||
initialPath=""
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import { setInputCat as computeInputCat, type InputCat } from '$lib/utils'
|
||||
import { Button } from './common'
|
||||
import { Badge, Button } from './common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import FieldHeader from './FieldHeader.svelte'
|
||||
@@ -24,6 +24,8 @@
|
||||
import Password from './Password.svelte'
|
||||
import type VariableEditor from './VariableEditor.svelte'
|
||||
import type ItemPicker from './ItemPicker.svelte'
|
||||
import NumberTypeNarrowing from './NumberTypeNarrowing.svelte'
|
||||
import Range from './Range.svelte'
|
||||
|
||||
export let label: string = ''
|
||||
export let value: any
|
||||
@@ -53,6 +55,7 @@
|
||||
export let variableEditor: VariableEditor | undefined = undefined
|
||||
export let itemPicker: ItemPicker | undefined = undefined
|
||||
export let noMargin = false
|
||||
export let extra: Record<string, any> = {}
|
||||
|
||||
let seeEditable: boolean = enum_ != undefined || pattern != undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -142,20 +145,22 @@
|
||||
$: {
|
||||
if (value == undefined || value == null) {
|
||||
value = defaultValue
|
||||
if (defaultValue === undefined || defaultValue === null)
|
||||
if (defaultValue === undefined || defaultValue === null) {
|
||||
if (inputCat === 'string') {
|
||||
value = ''
|
||||
} else if (inputCat == 'enum') {
|
||||
value = enum_?.[0]
|
||||
} else if (inputCat == 'boolean') {
|
||||
value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export let inputCat: InputCat = 'string'
|
||||
$: inputCat = computeInputCat(type, format, itemsType?.type, enum_, contentEncoding)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col w-full min-w-[250px]">
|
||||
<div>
|
||||
{#if displayHeader}
|
||||
<FieldHeader {label} {required} {type} {contentEncoding} {format} {itemsType} />
|
||||
@@ -183,8 +188,10 @@
|
||||
bind:value={description}
|
||||
placeholder="Field description"
|
||||
/>
|
||||
{#if type == 'string' && !contentEncoding && format != 'date-time'}
|
||||
{#if type == 'string' && format != 'date-time'}
|
||||
<StringTypeNarrowing bind:format bind:pattern bind:enum_ bind:contentEncoding />
|
||||
{:else if type == 'number'}
|
||||
<NumberTypeNarrowing bind:min={extra['min']} bind:max={extra['max']} />
|
||||
{:else if type == 'object'}
|
||||
<ObjectTypeNarrowing bind:format />
|
||||
{:else if type == 'array'}
|
||||
@@ -212,20 +219,40 @@
|
||||
|
||||
<div class="flex space-x-1">
|
||||
{#if inputCat == 'number'}
|
||||
<input
|
||||
{autofocus}
|
||||
on:focus
|
||||
{disabled}
|
||||
type="number"
|
||||
class={valid
|
||||
? ''
|
||||
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
||||
placeholder={defaultValue ?? ''}
|
||||
bind:value
|
||||
on:input={() => dispatch('input', { value, isRaw: true })}
|
||||
/>
|
||||
{#if extra['min'] != undefined && extra['max'] != undefined}
|
||||
<div class="flex w-full gap-1">
|
||||
<span>{extra['min']}</span>
|
||||
<div class="grow">
|
||||
<Range bind:value min={extra['min']} max={extra['max']} />
|
||||
</div>
|
||||
<span>{extra['max']}</span>
|
||||
<span class="mx-2"><Badge large color="blue">{value}</Badge></span>
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
{autofocus}
|
||||
on:focus={(e) => {
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
dispatch('focus')
|
||||
}}
|
||||
{disabled}
|
||||
type="number"
|
||||
class={valid
|
||||
? ''
|
||||
: 'border border-red-700 border-opacity-30 focus:border-red-700 focus:border-opacity-30 bg-red-100'}
|
||||
placeholder={defaultValue ?? ''}
|
||||
bind:value
|
||||
min={extra['min']}
|
||||
max={extra['max']}
|
||||
on:input={() => dispatch('input', { value, isRaw: true })}
|
||||
/>
|
||||
{/if}
|
||||
{:else if inputCat == 'boolean'}
|
||||
<Toggle
|
||||
on:pointerdown={(e) => {
|
||||
e?.stopPropagation()
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
}}
|
||||
{disabled}
|
||||
class={valid
|
||||
? ''
|
||||
@@ -303,7 +330,10 @@
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={el}
|
||||
on:focus
|
||||
on:focus={(e) => {
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
dispatch('focus')
|
||||
}}
|
||||
{autofocus}
|
||||
{disabled}
|
||||
use:autosize
|
||||
@@ -319,25 +349,36 @@
|
||||
/>
|
||||
{/if}
|
||||
{:else if inputCat == 'enum'}
|
||||
<select {disabled} class="px-6" bind:value>
|
||||
<select
|
||||
on:focus={(e) => {
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
dispatch('focus')
|
||||
}}
|
||||
{disabled}
|
||||
class="px-6"
|
||||
bind:value
|
||||
>
|
||||
{#each enum_ ?? [] as e}
|
||||
<option>{e}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if inputCat == 'date'}
|
||||
<input {autofocus} class="inline-block" type="datetime-local" bind:value />
|
||||
{:else if inputCat == 'sql'}
|
||||
<div class="border rounded mb-4 w-full border-gray-700">
|
||||
{:else if inputCat == 'sql' || inputCat == 'yaml'}
|
||||
<div class="border my-1 mb-4 w-full border-gray-400">
|
||||
<SimpleEditor
|
||||
on:focus={() => dispatch('focus')}
|
||||
on:focus={(e) => {
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
dispatch('focus')
|
||||
}}
|
||||
on:blur={() => dispatch('blur')}
|
||||
bind:this={editor}
|
||||
lang="sql"
|
||||
lang={inputCat}
|
||||
bind:code={value}
|
||||
class="few-lines-editor"
|
||||
on:change={async () => {
|
||||
dispatch('input', { rawValue: value, isRaw: false })
|
||||
}}
|
||||
autoHeight
|
||||
/>
|
||||
</div>
|
||||
{:else if inputCat == 'base64'}
|
||||
@@ -365,7 +406,10 @@
|
||||
{autofocus}
|
||||
rows="1"
|
||||
bind:this={el}
|
||||
on:focus={() => dispatch('focus')}
|
||||
on:focus={(e) => {
|
||||
window.dispatchEvent(new Event('pointerup'))
|
||||
dispatch('focus')
|
||||
}}
|
||||
on:blur={() => dispatch('blur')}
|
||||
use:autosize
|
||||
type="text"
|
||||
@@ -381,16 +425,13 @@
|
||||
/>
|
||||
{#if itemPicker}
|
||||
<div class="ml-1 relative">
|
||||
<Button
|
||||
{disabled}
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="min-w-min items-center leading-4 py-0"
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="min-w-min min-h-[34px] items-center leading-4 px-3 text-blue-500 cursor-pointer border border-blue-500 rounded center-center"
|
||||
on:click={() => {
|
||||
pickForField = label
|
||||
itemPicker?.openDrawer?.()
|
||||
}}><Icon data={faDollarSign} /></Button
|
||||
}}><Icon data={faDollarSign} /></div
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { SettingsService } from '$lib/gen'
|
||||
import { onMount } from 'svelte'
|
||||
import WindmillIcon from './icons/WindmillIcon.svelte'
|
||||
|
||||
export let subtitle: string | undefined = undefined
|
||||
export let title = 'Windmill'
|
||||
export let disableLogo = false
|
||||
let version = ''
|
||||
|
||||
SettingsService.backendVersion().then((x) => {
|
||||
version = x
|
||||
onMount(async () => {
|
||||
version = await SettingsService.backendVersion()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center min-h-screen pt-10 relative bg-[#3b4252]">
|
||||
<div class="w-10/12 md:w-7/12 lg:w-6/12 xl:4/12 m-auto z-10">
|
||||
<div class="border rounded-md py-4 px-16 shadow-md bg-gray-50">
|
||||
<h1 class="justify-center text-center font-medium pb-4">
|
||||
<div class="center-center min-h-screen p-4 relative bg-[#3b4252]">
|
||||
<div class="border rounded-md shadow-md bg-white w-full max-w-[640px]
|
||||
p-4 sm:py-8 sm:px-10 mb-6 md:mb-20 z-10">
|
||||
<div class="mb-10">
|
||||
<h1 class="text-center">
|
||||
{title}
|
||||
</h1>
|
||||
{#if subtitle}
|
||||
<p class="justify-center text-center font-medium pb-2 text-xs">
|
||||
<p class="text-sm text-center text-gray-600 mt-2">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- empty row to make the form a little bit above vertical centering-->
|
||||
<div class="py-12" />
|
||||
<slot />
|
||||
</div>
|
||||
<div class="hidden lg:block absolute top-10 right-50">
|
||||
<div class="animate-[spin_100s_linear_infinite] ">
|
||||
<WindmillIcon white height="100px" width="100px" />
|
||||
{#if !disableLogo}
|
||||
<div class="hidden lg:block absolute top-10 right-50">
|
||||
<div class="animate-[spin_100s_linear_infinite] ">
|
||||
<WindmillIcon white height="100px" width="100px" />
|
||||
</div>
|
||||
<h2 class="text-center pt-2 text-gray-100">Windmill</h2>
|
||||
</div>
|
||||
<h2 class="text-center pt-2 text-gray-100">Windmill</h2>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-0 right-0 text-2xs text-gray-300 italic px-3 py-1">
|
||||
<span class="font-mono">{version}</span>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
return 'jpeg'
|
||||
} else if (keys.length == 1 && keys[0] == 'file') {
|
||||
return 'file'
|
||||
} else if (keys.length == 1 && keys[0] == 'error' && typeof result['error'] == 'string') {
|
||||
} else if (keys.length == 1 && keys[0] == 'error') {
|
||||
return 'error'
|
||||
} else if (
|
||||
keys.length == 3 &&
|
||||
@@ -88,7 +88,7 @@
|
||||
class="grid grid-flow-col-dense border border-gray-200 rounded-md "
|
||||
>
|
||||
{#each Object.keys(result) as col}
|
||||
<div class="flex flex-col max-h-40 min-w-full overflow-auto">
|
||||
<div class="flex flex-col max-h-40 min-w-full">
|
||||
<div
|
||||
class="px-12 text-left uppercase border-b bg-gray-50 overflow-hidden rounded-t-md "
|
||||
>
|
||||
@@ -96,7 +96,7 @@
|
||||
</div>
|
||||
{#if Array.isArray(result[col])}
|
||||
{#each result[col] as item}
|
||||
<div class="px-12 text-left whitespace-nowrap">
|
||||
<div class="px-12 text-left text-xs whitespace-nowrap">
|
||||
{typeof item === 'string' ? item : JSON.stringify(item)}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -105,14 +105,14 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !forceJson && resultKind == 'table-row'}<div
|
||||
class="grid grid-flow-col-dense border border-gray-200 rounded-md "
|
||||
class="grid grid-flow-col-dense border border-gray-200 "
|
||||
>
|
||||
<TableCustom>
|
||||
<tbody slot="body">
|
||||
{#each asListOfList(result) as row}
|
||||
<tr>
|
||||
{#each row as v}
|
||||
<td>{truncate(JSON.stringify(v), 200) ?? ''}</td>
|
||||
<td class="!text-xs">{truncate(JSON.stringify(v), 200) ?? ''}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -141,8 +141,11 @@
|
||||
>Download</a
|
||||
>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind == 'error'}<div
|
||||
><pre class="text-sm text-red-500 whitespace-pre-wrap">{result.error}</pre>
|
||||
{:else if !forceJson && resultKind == 'error'}<div>
|
||||
<span class="text-red-500 font-semibold text-sm whitespace-pre-wrap"
|
||||
>{result.error.name}: {result.error.message}</span
|
||||
>
|
||||
<pre class="text-sm whitespace-pre-wrap text-gray-900">{result.error.stack ?? ''}</pre>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind == 'approval'}<div class="flex flex-col gap-1 mx-4">
|
||||
<Button
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import type { DropdownItem } from '$lib/utils'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
|
||||
import { MoreHorizontal } from 'lucide-svelte'
|
||||
import { Button, Menu } from './common'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
type Alignment = 'start' | 'end'
|
||||
type Side = 'top' | 'bottom'
|
||||
@@ -12,14 +13,15 @@
|
||||
export let dropdownItems: DropdownItem[]
|
||||
export let name: string | undefined = undefined
|
||||
export let placement: Placement = 'bottom-start'
|
||||
export let btnClasses = '!text-blue-500 bg-transparent'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Menu {placement} let:close>
|
||||
<Button color="light" size="xs" btnClasses="!text-blue-500 bg-transparent" slot="trigger">
|
||||
<Button nonCaptureEvent color="light" size="xs" {btnClasses} {...$$restProps} slot="trigger">
|
||||
{#if !$$slots.default}
|
||||
<Icon data={faEllipsisH} scale={1.2} />
|
||||
<MoreHorizontal size={20} />
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
@@ -28,8 +30,7 @@
|
||||
{#each dropdownItems as item, i}
|
||||
{#if item.action}
|
||||
<button
|
||||
on:click={(event) => {
|
||||
event.preventDefault()
|
||||
on:click|preventDefault|stopPropagation={(event) => {
|
||||
if (!item.disabled) {
|
||||
close()
|
||||
item.action && item.action(event)
|
||||
@@ -38,7 +39,7 @@
|
||||
}}
|
||||
class="block w-full whitespace-nowrap hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30
|
||||
px-4 py-2 text-sm text-gray-700 text-left
|
||||
{item.disabled ? 'bg-gray-100' : ''}
|
||||
{item.disabled ? 'bg-gray-200' : ''}
|
||||
{item.separatorTop ? 'border-t' : ''} {item.separatorBottom ? 'border-b' : ''} {item.type ==
|
||||
'delete'
|
||||
? 'text-red-500'
|
||||
@@ -60,14 +61,9 @@
|
||||
{:else if item.href && !item.disabled}
|
||||
<a
|
||||
href={item.href}
|
||||
on:click={(e) => {
|
||||
if (!item.disabled) {
|
||||
close()
|
||||
} else {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
class="block w-full px-4 font-semibold py-2 text-sm text-gray-700 hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30"
|
||||
on:click|stopPropagation|preventDefault={() => goto(item.href ?? '')}
|
||||
class="block w-full px-4 font-semibold text-left py-2 text-sm text-gray-700 hover:drop-shadow-sm hover:bg-gray-50 hover:bg-opacity-30
|
||||
{item.disabled ? 'bg-gray-200' : ''}"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
id="user-menu-item-{name}-{i}}"
|
||||
@@ -85,11 +81,11 @@
|
||||
{:else}
|
||||
<span
|
||||
class:bg-gray-50={item.disabled}
|
||||
class="block px-4 py-2 text-sm text-gray-700 cursor-auto"
|
||||
class="block text-left px-4 py-2 text-sm text-gray-700 cursor-auto"
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
id="user-menu-item-{name}-{i}}"
|
||||
on:click|preventDefault
|
||||
on:click|preventDefault|stopPropagation
|
||||
>
|
||||
{#if item.icon}
|
||||
<Icon
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
<script lang="ts" context="module">
|
||||
import * as monaco from 'monaco-editor'
|
||||
import getMessageServiceOverride from 'vscode/service-override/messages'
|
||||
import { StandaloneServices } from 'vscode/services'
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.Latest,
|
||||
allowNonTsExtensions: true,
|
||||
noLib: true
|
||||
})
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: true,
|
||||
noSuggestionDiagnostics: true,
|
||||
noSyntaxValidation: true
|
||||
})
|
||||
monaco.editor.defineTheme('myTheme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editorLineNumber.foreground': '#999',
|
||||
'editorGutter.background': '#F9FAFB'
|
||||
}
|
||||
})
|
||||
monaco.editor.setTheme('myTheme')
|
||||
try {
|
||||
StandaloneServices.initialize({
|
||||
StandaloneServices?.initialize({
|
||||
...getMessageServiceOverride(document.body)
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -31,7 +12,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser, dev } from '$app/env'
|
||||
import { browser, dev } from '$app/environment'
|
||||
import { page } from '$app/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
|
||||
@@ -45,9 +26,33 @@
|
||||
MessageTransports,
|
||||
MonacoLanguageClient
|
||||
} from 'monaco-languageclient'
|
||||
import getMessageServiceOverride from 'vscode/service-override/messages'
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
|
||||
import { languages, editor as meditor, KeyCode, KeyMod, Uri as mUri } from 'monaco-editor'
|
||||
|
||||
languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
target: languages.typescript.ScriptTarget.Latest,
|
||||
allowNonTsExtensions: true,
|
||||
noLib: true
|
||||
})
|
||||
languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: true,
|
||||
noSuggestionDiagnostics: true,
|
||||
noSyntaxValidation: true
|
||||
})
|
||||
languages.typescript.typescriptDefaults.setExtraLibs([])
|
||||
|
||||
meditor.defineTheme('myTheme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editorLineNumber.foreground': '#999',
|
||||
'editorGutter.background': '#F9FAFB'
|
||||
}
|
||||
})
|
||||
meditor.setTheme('myTheme')
|
||||
|
||||
import {
|
||||
BASH_INIT_CODE,
|
||||
DENO_INIT_CODE_CLEAR,
|
||||
@@ -61,10 +66,9 @@
|
||||
updateOptions
|
||||
} from '$lib/editorUtils'
|
||||
import { dirtyStore } from './common/confirmationModal/dirtyStore'
|
||||
import { StandaloneServices } from 'vscode/services'
|
||||
|
||||
let divEl: HTMLDivElement | null = null
|
||||
let editor: monaco.editor.IStandaloneCodeEditor
|
||||
let editor: meditor.IStandaloneCodeEditor
|
||||
|
||||
export let lang: 'typescript' | 'python' | 'go' | 'shell'
|
||||
export let code: string = ''
|
||||
@@ -139,7 +143,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function format() {
|
||||
export function format() {
|
||||
if (editor) {
|
||||
code = getCode()
|
||||
editor.getAction('editor.action.formatDocument').run()
|
||||
@@ -199,7 +203,6 @@
|
||||
isTrusted: true
|
||||
},
|
||||
|
||||
// workspaceFolder: { uri: Uri.parse(`/tmp/${name}`), name: 'tmp', index: 0 },
|
||||
initializationOptions,
|
||||
middleware: {
|
||||
workspace: {
|
||||
@@ -404,13 +407,13 @@
|
||||
}
|
||||
|
||||
async function loadMonaco() {
|
||||
const model = monaco.editor.createModel(code, lang, monaco.Uri.parse(uri))
|
||||
const model = meditor.createModel(code, lang, mUri.parse(uri))
|
||||
|
||||
model.updateOptions(updateOptions)
|
||||
editor = monaco.editor.create(
|
||||
divEl as HTMLDivElement,
|
||||
editorConfig(model, code, lang, automaticLayout, fixedOverflowWidgets)
|
||||
)
|
||||
editor = meditor.create(divEl as HTMLDivElement, {
|
||||
...editorConfig(model, code, lang, automaticLayout, fixedOverflowWidgets),
|
||||
tabSize: lang == 'python' ? 4 : 2
|
||||
})
|
||||
|
||||
let timeoutModel: NodeJS.Timeout | undefined = undefined
|
||||
editor.onDidChangeModelContent((event) => {
|
||||
@@ -419,19 +422,19 @@
|
||||
timeoutModel && clearTimeout(timeoutModel)
|
||||
timeoutModel = setTimeout(() => {
|
||||
code = getCode()
|
||||
dispatch('change')
|
||||
dispatch('change', code)
|
||||
}, 500)
|
||||
})
|
||||
|
||||
editor.onDidFocusEditorText(() => {
|
||||
dispatch('focus')
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, function () {
|
||||
editor.addCommand(KeyMod.CtrlCmd | KeyCode.KeyS, function () {
|
||||
code = getCode()
|
||||
shouldBindKey && format && format()
|
||||
})
|
||||
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, function () {
|
||||
editor.addCommand(KeyMod.CtrlCmd | KeyCode.Enter, function () {
|
||||
code = getCode()
|
||||
shouldBindKey && cmdEnterAction && cmdEnterAction()
|
||||
})
|
||||
@@ -467,7 +470,7 @@
|
||||
export function addAction(
|
||||
id: string,
|
||||
label: string,
|
||||
callback: (editor: monaco.editor.IStandaloneCodeEditor) => void,
|
||||
callback: (editor: meditor.IStandaloneCodeEditor) => void,
|
||||
keybindings: number[] = []
|
||||
) {
|
||||
editor.addAction({
|
||||
@@ -476,7 +479,7 @@
|
||||
keybindings,
|
||||
contextMenuGroupId: 'navigation',
|
||||
|
||||
run: function (editor: monaco.editor.IStandaloneCodeEditor) {
|
||||
run: function (editor: meditor.IStandaloneCodeEditor) {
|
||||
callback(editor)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
import { getScriptByPath, sendUserToast } from '$lib/utils'
|
||||
|
||||
import {
|
||||
faBroom,
|
||||
faCube,
|
||||
faDollarSign,
|
||||
faEye,
|
||||
faPlus,
|
||||
faRotate,
|
||||
faRotateLeft
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
@@ -163,11 +165,12 @@
|
||||
variant="border"
|
||||
color="blue"
|
||||
size="sm"
|
||||
startIcon={{ icon: faPlus }}
|
||||
on:click={() => {
|
||||
variableEditor.initNew()
|
||||
}}
|
||||
>
|
||||
Create a new variable
|
||||
New variable
|
||||
</Button>
|
||||
</div>
|
||||
</ItemPicker>
|
||||
@@ -307,6 +310,20 @@
|
||||
</span>
|
||||
</Button></div
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
btnClasses="!font-medium"
|
||||
size="xs"
|
||||
spacingSize="md"
|
||||
color="light"
|
||||
on:click={editor.format}
|
||||
startIcon={{ icon: faBroom }}
|
||||
>
|
||||
{#if !iconOnly}
|
||||
Format (Ctrl+S)
|
||||
{/if}
|
||||
</Button></div
|
||||
>
|
||||
</div>
|
||||
<div class="py-1">
|
||||
<div>
|
||||
|
||||
@@ -3,19 +3,12 @@
|
||||
import { page } from '$app/stores'
|
||||
import { FlowService, ScheduleService, type Flow } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
encodeState,
|
||||
formatCron,
|
||||
loadHubScripts,
|
||||
sendUserToast,
|
||||
setQueryWithoutLoad
|
||||
} from '$lib/utils'
|
||||
import { encodeState, formatCron, loadHubScripts, sendUserToast } from '$lib/utils'
|
||||
import { faCalendarAlt, faEye, faPen, faSave } from '@fortawesome/free-solid-svg-icons'
|
||||
import { setContext } from 'svelte'
|
||||
import { Icon } from 'svelte-awesome'
|
||||
import { writable } from 'svelte/store'
|
||||
import CenteredPage from './CenteredPage.svelte'
|
||||
import { Badge, Button, Drawer, DrawerContent } from './common'
|
||||
import { Button, Drawer, DrawerContent } from './common'
|
||||
import { dirtyStore } from './common/confirmationModal/dirtyStore'
|
||||
import UnsavedConfirmationModal from './common/confirmationModal/UnsavedConfirmationModal.svelte'
|
||||
import { OFFSET } from './CronInput.svelte'
|
||||
@@ -61,8 +54,8 @@
|
||||
const flow = cleanInputs($flowStore)
|
||||
const { cron, args, enabled } = $scheduleStore
|
||||
$dirtyStore = false
|
||||
|
||||
if (initialPath === '') {
|
||||
localStorage.removeItem('flow')
|
||||
await FlowService.createFlow({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
@@ -77,6 +70,7 @@
|
||||
await createSchedule(flow.path)
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(`flow-${initialPath}`)
|
||||
await FlowService.updateFlow({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath,
|
||||
@@ -125,25 +119,26 @@
|
||||
let timeout: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
$: {
|
||||
if ($flowStore && $flowStateStore) {
|
||||
setUrl()
|
||||
if ($flowStore || $selectedIdStore) {
|
||||
saveDraft()
|
||||
}
|
||||
}
|
||||
|
||||
function setUrl() {
|
||||
function saveDraft() {
|
||||
timeout && clearTimeout(timeout)
|
||||
timeout = setTimeout(
|
||||
() =>
|
||||
setQueryWithoutLoad(
|
||||
$page.url,
|
||||
'state',
|
||||
timeout = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
initialPath ? `flow-${initialPath}` : 'flow',
|
||||
encodeState({
|
||||
flow: $flowStore,
|
||||
selectedId: $selectedIdStore
|
||||
})
|
||||
),
|
||||
500
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const selectedIdStore = writable<string>(selectedId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
|
||||
@@ -6,6 +7,7 @@
|
||||
export let logs: string
|
||||
export let col: boolean = false
|
||||
export let noBorder = false
|
||||
export let loading
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -14,7 +16,13 @@
|
||||
>
|
||||
<div class="bg-white max-h-80 h-full p-1 overflow-auto relative">
|
||||
<span class="text-gray-500">Result</span>
|
||||
<DisplayResult {result} />
|
||||
{#if result}
|
||||
<DisplayResult {result} />
|
||||
{:else if loading}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:else}
|
||||
<div class="text-gray-400">No result (result is undefined)</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-auto max-h-80 h-full relative">
|
||||
<LogViewer content={logs ?? ''} isLoading={false} />
|
||||
|
||||
@@ -12,6 +12,19 @@
|
||||
faBarsStaggered
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import ScheduleEditor from './ScheduleEditor.svelte'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
let time = Date.now()
|
||||
let interval
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
time = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
interval && clearInterval(interval)
|
||||
})
|
||||
|
||||
export let job: Job
|
||||
const SMALL_ICON_SCALE = 0.7
|
||||
@@ -23,14 +36,19 @@
|
||||
<div class="rounded-md p-3 bg-gray-50 shadow-sm sm:text-sm md:text-base" style="min-height: 150px;">
|
||||
<JobStatus {job} />
|
||||
<div>
|
||||
<Icon class="text-gray-700" data={faClock} scale={SMALL_ICON_SCALE} /><span class="mx-2">
|
||||
Created {displayDaysAgo(job.created_at ?? '')}</span
|
||||
<Icon class="text-gray-700" data={faClock} scale={SMALL_ICON_SCALE} /><span
|
||||
class="mx-2 text-2xs text-gray-600"
|
||||
>
|
||||
{#key time}
|
||||
Received job {displayDaysAgo(job.created_at ?? '')}
|
||||
{/key}</span
|
||||
>
|
||||
</div>
|
||||
{#if job && 'started_at' in job && job.started_at}
|
||||
<div>
|
||||
<Icon class="text-gray-700" data={faClock} scale={SMALL_ICON_SCALE} /><span class="mx-2">
|
||||
Started {displayDaysAgo(job.started_at ?? '')}</span
|
||||
{#key time}
|
||||
Started {displayDaysAgo(job.started_at ?? '')}{/key}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import FlowProgressBar from './flows/FlowProgressBar.svelte'
|
||||
import { flowStateStore } from './flows/flowState'
|
||||
import CapturePayload from './flows/content/CapturePayload.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
|
||||
let capturePayload: CapturePayload
|
||||
export let previewMode: 'upTo' | 'whole'
|
||||
@@ -20,7 +21,7 @@
|
||||
|
||||
export let jobId: string | undefined = undefined
|
||||
export let job: Job | undefined = undefined
|
||||
let isValid: boolean = false
|
||||
let isValid: boolean = true
|
||||
let isRunning: boolean = false
|
||||
let jobProgressReset: () => void
|
||||
|
||||
@@ -48,12 +49,13 @@
|
||||
return m
|
||||
})
|
||||
}
|
||||
|
||||
function extractFlow(previewMode: 'upTo' | 'whole'): Flow {
|
||||
if (previewMode === 'whole') {
|
||||
return $flowStore
|
||||
} else {
|
||||
const flow: Flow = JSON.parse(JSON.stringify($flowStore))
|
||||
const idOrders = dfs(flow.value.modules)
|
||||
const idOrders = dfs(flow.value.modules, (x) => x.id)
|
||||
let upToIndex = idOrders.indexOf($selectedId)
|
||||
|
||||
if (upToIndex != -1) {
|
||||
@@ -96,7 +98,7 @@
|
||||
<div class="flex flex-row justify-between w-full items-center gap-x-2">
|
||||
<button
|
||||
on:click={() => dispatch('close')}
|
||||
class="hover:bg-gray-200 bg-gray-100 rounded-full w-8 h-8 flex items-center justify-center transition-all"
|
||||
class="hover:bg-gray-200 bg-gray-100 rounded-full w-16 h-8 flex items-center justify-center transition-all"
|
||||
>
|
||||
<Icon data={faClose} class="text-gray-500" />
|
||||
</button>
|
||||
@@ -118,8 +120,9 @@
|
||||
jobId = undefined
|
||||
}}
|
||||
size="md"
|
||||
btnClasses="w-full"
|
||||
btnClasses="w-full max-w-lg"
|
||||
>
|
||||
<Loader2 class="animate-spin mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
{:else}
|
||||
@@ -128,7 +131,7 @@
|
||||
startIcon={{ icon: isRunning ? faRefresh : faPlay }}
|
||||
color="blue"
|
||||
size="sm"
|
||||
btnClasses="w-full"
|
||||
btnClasses="w-full max-w-lg"
|
||||
disabled={!isValid}
|
||||
on:click={() => runPreview($previewArgs)}
|
||||
>
|
||||
@@ -146,15 +149,18 @@
|
||||
</div>
|
||||
<FlowProgressBar {job} bind:reset={jobProgressReset} />
|
||||
|
||||
<div class="overflow-y-auto grow flex-col flex divide-y divide-gray-600 ">
|
||||
<SchemaForm
|
||||
compact
|
||||
class="py-4"
|
||||
schema={$flowStore.schema}
|
||||
bind:isValid
|
||||
bind:args={$previewArgs}
|
||||
/>
|
||||
<div class="h-full pt-4 grow">
|
||||
<div class="overflow-y-auto grow divide-y divide-gray-600 ">
|
||||
<div class="max-h-1/2 overflow-auto border-b border-gray-700">
|
||||
<SchemaForm
|
||||
noVariablePicker
|
||||
compact
|
||||
class="py-4 max-w-3xl"
|
||||
schema={$flowStore.schema}
|
||||
bind:isValid
|
||||
bind:args={$previewArgs}
|
||||
/>
|
||||
</div>
|
||||
<div class="pt-4 grow">
|
||||
{#if jobId}
|
||||
<FlowStatusViewer bind:flowState={$flowStateStore} {jobId} bind:job />
|
||||
{:else}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { FlowStatusModule, Job, JobService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import FlowJobResult from './FlowJobResult.svelte'
|
||||
import FlowPreviewStatus from './preview/FlowPreviewStatus.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faChevronDown, faChevronUp, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import type { FlowState } from './flows/flowState'
|
||||
import { Button, Tab } from './common'
|
||||
import { Badge, Button, Tab } from './common'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import { FlowGraph, type GraphModuleState } from './graph'
|
||||
import ModuleStatus from './ModuleStatus.svelte'
|
||||
import { displayDate, truncateRev } from '$lib/utils'
|
||||
import { displayDate, emptyString, isOwner, pluralize, truncateRev } from '$lib/utils'
|
||||
import JobArgs from './JobArgs.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -33,6 +36,9 @@
|
||||
|
||||
let localFlowModuleStates: Record<string, GraphModuleState> = {}
|
||||
export let retry_status: Record<string, number> = {}
|
||||
export let suspend_status: number | undefined = undefined
|
||||
|
||||
export let is_owner = false
|
||||
|
||||
let selectedNode: string | undefined = undefined
|
||||
|
||||
@@ -64,6 +70,7 @@
|
||||
}
|
||||
|
||||
$: updateFailCount(job?.flow_status?.retry?.fail_count)
|
||||
$: suspend_status = job?.flow_status?.modules?.[job?.flow_status.step]?.count
|
||||
|
||||
function updateFailCount(count?: number) {
|
||||
if (count) {
|
||||
@@ -133,11 +140,12 @@
|
||||
|
||||
$: job && dispatch('jobsLoaded', job)
|
||||
|
||||
function updateJobId() {
|
||||
async function updateJobId() {
|
||||
if (jobId !== job?.id) {
|
||||
retry_status = {}
|
||||
localFlowModuleStates = {}
|
||||
loadJobInProgress()
|
||||
await loadJobInProgress()
|
||||
job?.script_path && loadOwner(job.script_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +157,13 @@
|
||||
timeout && clearTimeout(timeout)
|
||||
})
|
||||
|
||||
async function loadOwner(path: string) {
|
||||
is_owner = await isOwner(path, $userStore!, $workspaceStore!)
|
||||
}
|
||||
|
||||
let selected: 'graph' | 'sequence' = 'graph'
|
||||
|
||||
let payload: string = '"a test payload in json"'
|
||||
</script>
|
||||
|
||||
{#if job}
|
||||
@@ -166,12 +180,77 @@
|
||||
<FlowPreviewStatus {job} />
|
||||
{#if `result` in job}
|
||||
<div class="w-full h-full">
|
||||
<FlowJobResult result={job.result} logs={job.logs ?? ''} />
|
||||
<FlowJobResult
|
||||
loading={job['running'] == true}
|
||||
result={job.result}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === FlowStatusModule.type.WAITING_FOR_EVENTS}
|
||||
<div class="w-full h-full mt-2 text-sm text-gray-600">
|
||||
<p>Waiting to be resumed</p>
|
||||
<div>
|
||||
{#if is_owner}
|
||||
<div class="flex flex-row gap-2 mt-2">
|
||||
<div>
|
||||
<Button
|
||||
color="green"
|
||||
variant="border"
|
||||
on:click={async () =>
|
||||
await JobService.resumeSuspendedFlowAsOwner({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: job?.id ?? '',
|
||||
requestBody: JSON.parse(payload)
|
||||
})}
|
||||
>Resume <Tooltip
|
||||
>Since you are an owner of this flow, you can send resume events without
|
||||
necessarily knowing the resume id sent by the approval step</Tooltip
|
||||
></Button
|
||||
>
|
||||
</div>
|
||||
<div class="w-full border rounded-lg border-gray-600 p-2">
|
||||
<SimpleEditor automaticLayout lang="json" bind:code={payload} autoHeight />
|
||||
</div>
|
||||
<Tooltip
|
||||
>The payload is optional, it is passed to the following step through the
|
||||
`resume` variable</Tooltip
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
You cannot resume the flow yourself without receiving the resume secret since you
|
||||
are not an owner of {job.script_path}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if job.logs}
|
||||
<div class="text-xs p-4 bg-gray-50 overflow-auto max-h-80 border">
|
||||
<pre class="w-full">{job.logs}</pre>
|
||||
</div>
|
||||
{:else if innerModules?.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each innerModules as mod, i (mod.id)}
|
||||
{#if mod.type == FlowStatusModule.type.IN_PROGRESS}
|
||||
{@const rawMod = job.raw_flow?.modules[i]}
|
||||
|
||||
<div
|
||||
><span class="inline-flex gap-1"
|
||||
><Badge color="indigo">{mod.id}</Badge>
|
||||
<span class="font-medium text-gray-900">
|
||||
{#if !emptyString(rawMod?.summary)}
|
||||
{rawMod?.summary ?? ''}
|
||||
{:else if rawMod?.value.type == 'script'}
|
||||
{rawMod.value.path ?? ''}
|
||||
{:else if rawMod?.value.type}
|
||||
{rawMod?.value.type}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<Loader2 class="animate-spin" /></span
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -217,6 +296,7 @@
|
||||
</Button>
|
||||
<div class="border p-6" class:hidden={forloop_selected != loopJobId}>
|
||||
<svelte:self
|
||||
bind:suspend_status
|
||||
bind:retry_status
|
||||
bind:flowState
|
||||
bind:flowModuleStates={localFlowModuleStates}
|
||||
@@ -288,6 +368,7 @@
|
||||
<li class="w-full border border-gray-600 p-6 space-y-2 bg-blue-50/50">
|
||||
{#if [FlowStatusModule.type.IN_PROGRESS, FlowStatusModule.type.SUCCESS, FlowStatusModule.type.FAILURE].includes(mod.type)}
|
||||
<svelte:self
|
||||
bind:suspend_status
|
||||
bind:retry_status
|
||||
bind:flowState
|
||||
bind:flowModuleStates={localFlowModuleStates}
|
||||
@@ -348,6 +429,11 @@
|
||||
Retry in progress, # of failed attempts: {count}
|
||||
</span>
|
||||
{/each}
|
||||
{#if suspend_status}
|
||||
<span class="text-sm">
|
||||
Flow suspended, waiting for {pluralize(suspend_status, 'approval')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<FlowGraph
|
||||
@@ -369,7 +455,13 @@
|
||||
{#if selectedNode}
|
||||
{@const node = localFlowModuleStates[selectedNode]}
|
||||
{#if selectedNode == 'end'}
|
||||
<FlowJobResult noBorder col result={job['result'] ?? {}} logs={job.logs ?? ''} />
|
||||
<FlowJobResult
|
||||
loading={job['running']}
|
||||
noBorder
|
||||
col
|
||||
result={job['result']}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
{:else if selectedNode == 'start'}
|
||||
{#if job.args}
|
||||
<div class="p-2">
|
||||
@@ -396,7 +488,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<FlowJobResult noBorder col result={node.result ?? {}} logs={node.logs ?? ''} />
|
||||
<FlowJobResult
|
||||
loading={job['running'] == true}
|
||||
noBorder
|
||||
col
|
||||
result={node.result}
|
||||
logs={node.logs ?? ''}
|
||||
/>
|
||||
{:else}
|
||||
<p class="p-2 text-gray-600 italic"
|
||||
>The execution of this node has no information attached to it. The job likely did
|
||||
|
||||
@@ -137,7 +137,7 @@
|
||||
{#if perms}
|
||||
<TableCustom>
|
||||
<tr slot="header-row">
|
||||
<th>user</th>
|
||||
<th>user/group</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
placeholder="Summary of the group"
|
||||
/>
|
||||
<Button
|
||||
disabled={!can_write}
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await GroupService.updateGroup({
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
export let width = '24px'
|
||||
export let center = false
|
||||
|
||||
$: name_prefix = name.split('_')[0]
|
||||
$: iconComponent = APP_TO_ICON_COMPONENT[name] || APP_TO_ICON_COMPONENT[name.split('_')[0]]
|
||||
</script>
|
||||
|
||||
<div class=" truncate flex flex-row gap-2 {center ? 'justify-center items-center' : ''} -pl-2">
|
||||
<div class="truncate flex flex-row gap-2 {center ? 'justify-center items-center' : ''} -pl-2">
|
||||
{#if !silent && !after}
|
||||
{name}
|
||||
{/if}
|
||||
{#if APP_TO_ICON_COMPONENT[name_prefix]}
|
||||
<span class="text-gray-700">
|
||||
<svelte:component this={APP_TO_ICON_COMPONENT[name_prefix]} {height} {width} />
|
||||
{#if iconComponent}
|
||||
<span class="text-gray-700 grayscale">
|
||||
<svelte:component this={iconComponent} {height} {width} />
|
||||
</span>
|
||||
{:else}
|
||||
<span style="width: {width}" class="bg-gray-100 rounded-full" />
|
||||
<span style="width: {width}; height: {height}" class="bg-gray-100 rounded-full" />
|
||||
{/if}
|
||||
{#if !silent && after}
|
||||
{name}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import type { InputTransform } from '$lib/gen'
|
||||
import TemplateEditor from './TemplateEditor.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { escape } from 'svelte/internal'
|
||||
import { setInputCat as computeInputCat } from '$lib/utils'
|
||||
|
||||
export let schema: Schema
|
||||
export let arg: InputTransform | any
|
||||
@@ -27,11 +27,19 @@
|
||||
export let pickForField: string | undefined = undefined
|
||||
export let variableEditor: VariableEditor | undefined = undefined
|
||||
export let itemPicker: ItemPicker | undefined = undefined
|
||||
export let noDynamicToggle = false
|
||||
|
||||
export let monaco: SimpleEditor | undefined = undefined
|
||||
let monaco: SimpleEditor | undefined = undefined
|
||||
let monacoTemplate: TemplateEditor | undefined = undefined
|
||||
let argInput: ArgInput | undefined = undefined
|
||||
|
||||
let inputCat: InputCat = 'object'
|
||||
$: inputCat = computeInputCat(
|
||||
schema.properties[argName].type,
|
||||
schema.properties[argName].format,
|
||||
schema.properties[argName].items?.type,
|
||||
schema.properties[argName].enum,
|
||||
schema.properties[argName].contentEncoding
|
||||
)
|
||||
let propertyType = getPropertyType(arg)
|
||||
|
||||
function getPropertyType(arg: InputTransform | any): 'static' | 'javascript' {
|
||||
@@ -66,13 +74,14 @@
|
||||
}
|
||||
|
||||
function isStaticTemplate(inputCat: InputCat) {
|
||||
return inputCat === 'string' || inputCat === 'sql'
|
||||
return inputCat === 'string' || inputCat === 'sql' || inputCat == 'yaml'
|
||||
}
|
||||
|
||||
function connectProperty(rawValue: string) {
|
||||
if (isStaticTemplate(inputCat)) {
|
||||
arg.value = `\$\{${rawValue}}`
|
||||
setPropertyType(arg.value)
|
||||
monacoTemplate?.setCode(arg.value)
|
||||
} else {
|
||||
arg.expr = getDefaultExpr(undefined, previousModuleId, rawValue)
|
||||
arg.type = 'javascript'
|
||||
@@ -86,6 +95,7 @@
|
||||
focusProp(argName, 'append', (path) => {
|
||||
const toAppend = `\$\{${path}}`
|
||||
arg.value = `${arg.value ?? ''}${toAppend}`
|
||||
monacoTemplate?.setCode(arg.value)
|
||||
setPropertyType(arg.value)
|
||||
argInput?.focus()
|
||||
return false
|
||||
@@ -103,13 +113,17 @@
|
||||
|
||||
const { focusProp, propPickerConfig } = getContext<PropPickerWrapperContext>('PropPickerWrapper')
|
||||
|
||||
$: isStaticTemplate(inputCat) && propertyType == 'static' && setPropertyType(arg.value)
|
||||
$: isStaticTemplate(inputCat) && propertyType == 'static' && setPropertyType(arg?.value)
|
||||
const openBracket = '${'
|
||||
const closeBracket = '}'
|
||||
|
||||
$: schema.properties[argName].default &&
|
||||
!arg?.value &&
|
||||
monacoTemplate?.setCode(schema.properties[argName].default)
|
||||
</script>
|
||||
|
||||
{#if arg != undefined}
|
||||
<div class="flex flex-row justify-between gap-1 mb-1">
|
||||
<div class="flex flex-row justify-between gap-1 pb-1">
|
||||
<div class="flex items-center flex-wrap grow">
|
||||
<FieldHeader
|
||||
label={argName}
|
||||
@@ -131,21 +145,8 @@
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-row-reverse gap-x-4 gap-y-1 flex-wrap">
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
focusProp(argName, 'connect', (path) => {
|
||||
connectProperty(path)
|
||||
return true
|
||||
})
|
||||
}}>Connect →</Button
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
{#if !noDynamicToggle}
|
||||
<div class="flex flex-row gap-x-4 gap-y-1 flex-wrap">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={propertyType}
|
||||
on:selected={(e) => {
|
||||
@@ -156,24 +157,29 @@
|
||||
argName,
|
||||
previousModuleId,
|
||||
staticTemplate
|
||||
? `\`${arg.value ?? ''}\``
|
||||
? `\`${arg?.value ?? ''}\``
|
||||
: arg.value
|
||||
? JSON.stringify(arg.value, null, 4)
|
||||
? JSON.stringify(arg?.value, null, 4)
|
||||
: ''
|
||||
)
|
||||
}
|
||||
|
||||
arg.value = undefined
|
||||
if (arg) {
|
||||
arg.value = undefined
|
||||
}
|
||||
propertyType = 'javascript'
|
||||
arg.type = 'javascript'
|
||||
} else {
|
||||
if (staticTemplate) {
|
||||
arg.value = codeToStaticTemplate(arg.expr)
|
||||
setPropertyType(arg.value)
|
||||
if (arg) {
|
||||
arg.value = codeToStaticTemplate(arg.expr)
|
||||
}
|
||||
setPropertyType(arg?.value)
|
||||
} else {
|
||||
arg.type = 'static'
|
||||
arg.value = undefined
|
||||
arg.expr = undefined
|
||||
if (arg) {
|
||||
arg.type = 'static'
|
||||
arg.value = undefined
|
||||
arg.expr = undefined
|
||||
}
|
||||
}
|
||||
propertyType = 'static'
|
||||
}
|
||||
@@ -181,7 +187,7 @@
|
||||
>
|
||||
{#if isStaticTemplate(inputCat)}
|
||||
<ToggleButton light position="left" value="static" size="xs">
|
||||
{'${} '}Templatable <Tooltip
|
||||
{'${} '}Template <Tooltip
|
||||
>Write javascript expressions between "{openBracket}" and "{closeBracket}". You may
|
||||
refer to contextual objects like 'flow_input', or 'result' or functions like
|
||||
'resource' and 'variable'
|
||||
@@ -201,13 +207,25 @@
|
||||
Dynamic (JS)
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
on:click={() => {
|
||||
focusProp(argName, 'connect', (path) => {
|
||||
connectProperty(path)
|
||||
return true
|
||||
})
|
||||
}}>Connect →</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="max-w-xs" />
|
||||
|
||||
<div
|
||||
class="relative {$propPickerConfig?.propName == argName
|
||||
class="relative mb-6 {$propPickerConfig?.propName == argName
|
||||
? 'outline outline-offset-0 outline-2 outline-blue-500 rounded-md'
|
||||
: ''}"
|
||||
>
|
||||
@@ -218,9 +236,17 @@
|
||||
Connect input →
|
||||
</span>
|
||||
{/if}
|
||||
{#if isStaticTemplate(inputCat) && propertyType == 'static'}
|
||||
<div class="py-1">
|
||||
<TemplateEditor {extraLib} on:focus={onFocus} bind:code={arg.value} />
|
||||
{#if isStaticTemplate(inputCat) && propertyType == 'static' && !noDynamicToggle}
|
||||
<div class="py-1 mt-2 min-h-[28px] rounded border border-1 border-gray-500">
|
||||
{#if arg}
|
||||
<TemplateEditor
|
||||
bind:this={monacoTemplate}
|
||||
{extraLib}
|
||||
on:focus={onFocus}
|
||||
bind:code={arg.value}
|
||||
fontSize={14}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if propertyType === undefined || propertyType == 'static'}
|
||||
<ArgInput
|
||||
@@ -243,7 +269,6 @@
|
||||
bind:itemsType={schema.properties[argName].items}
|
||||
properties={schema.properties[argName].properties}
|
||||
displayHeader={false}
|
||||
bind:inputCat
|
||||
{variableEditor}
|
||||
{itemPicker}
|
||||
bind:pickForField
|
||||
@@ -271,7 +296,6 @@
|
||||
Not recognized input type {argName}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mb-6" />
|
||||
{:else}
|
||||
<p class="text-sm text-gray-700">Arg at {argName} is undefined</p>
|
||||
<p class="text-sm text-gray-700">Argument at {argName} is undefined</p>
|
||||
{/if}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let email: string
|
||||
let is_admin = false
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
const key = event.key
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, Skeleton } from './common'
|
||||
import { Button, Drawer, Skeleton } from './common'
|
||||
import DrawerContent from './common/drawer/DrawerContent.svelte'
|
||||
import NoItemFound from './home/NoItemFound.svelte'
|
||||
import IconedResourceType from './IconedResourceType.svelte'
|
||||
import SearchItems from './SearchItems.svelte'
|
||||
|
||||
@@ -14,7 +15,6 @@
|
||||
/** Displayed if the load function returns no items. */
|
||||
export let noItemMessage = 'There are no items in the list'
|
||||
/** Displayed if the search returns no items. */
|
||||
export let emptySearchMessage = 'No items found'
|
||||
export let buttons: Record<string, (x: string) => void> = {}
|
||||
|
||||
let loading = false
|
||||
@@ -48,12 +48,12 @@
|
||||
/>
|
||||
|
||||
<Drawer bind:this={drawer} size="600px">
|
||||
<DrawerContent title="Search a {itemName}" on:close={drawer.closeDrawer}>
|
||||
<div>
|
||||
<DrawerContent overflow_y={false} title="Search {itemName}s" on:close={drawer.closeDrawer}>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-12/12 pb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search {itemName}"
|
||||
placeholder="Search {itemName}s"
|
||||
bind:value={filter}
|
||||
class="search-item"
|
||||
/>
|
||||
@@ -67,72 +67,78 @@
|
||||
{@html noItemMessage}
|
||||
</div>
|
||||
{:else if filteredItems?.length}
|
||||
<ul class="divide-y divide-gray-200">
|
||||
<div class="border rounded-md divide-y divide-gray-200 w-full overflow-auto pb-12 grow">
|
||||
{#each filteredItems as obj}
|
||||
<li class="flex flex-row w-full">
|
||||
<button
|
||||
class="py-4 px-1 gap-1 flex flex-row grow border border-gray-300 border-opacity-0
|
||||
hover:bg-white hover:border-opacity-100 text-black"
|
||||
on:click={() => {
|
||||
if (closeOnClick) {
|
||||
drawer.closeDrawer()
|
||||
}
|
||||
pickCallback(obj['path'], obj[extraField])
|
||||
}}
|
||||
>
|
||||
{#if `app` in obj}
|
||||
<div class="mr-2 text-sm text-left truncate w-24">
|
||||
<IconedResourceType after={true} silent={false} name={obj['app']} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if `resource_type` in obj}
|
||||
<div class="mr-2 truncate text-left w-24 text-sm">
|
||||
<IconedResourceType after={true} name={obj['resource_type']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
{#if obj.marked}
|
||||
<div class="text-sm font-semibold">
|
||||
{@html obj.marked}
|
||||
<div
|
||||
class="hover:bg-gray-50 w-full flex items-center p-4 gap-4 first-of-type:!border-t-0
|
||||
first-of-type:rounded-t-md last-of-type:rounded-b-md"
|
||||
>
|
||||
<div class="inline-flex items-center grow">
|
||||
<button
|
||||
class="py-2 px-1 gap-1 flex grow border-gray-300 border-opacity-0
|
||||
text-black"
|
||||
on:click={() => {
|
||||
if (closeOnClick) {
|
||||
drawer.closeDrawer()
|
||||
}
|
||||
pickCallback(obj['path'], obj[extraField])
|
||||
}}
|
||||
>
|
||||
{#if `app` in obj}
|
||||
<div class="mr-2 text-sm text-left center-center w-30">
|
||||
<IconedResourceType after={true} silent={false} name={obj['app']} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm font-semibold flex flex-col">
|
||||
<span class="mr-2 text-left">{obj[extraField] ?? ''}</span>
|
||||
{#if extraField != 'path'}
|
||||
<span class="font-normal text-xs text-left italic overflow-hidden"
|
||||
>{obj['path'] ?? ''}</span
|
||||
{/if}
|
||||
{#if `resource_type` in obj}
|
||||
<div class="mr-2 text-left w-30 center-center text-sm">
|
||||
<IconedResourceType after={true} name={obj['resource_type']} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex grow flex-col break-all overflow-hidden">
|
||||
{#if obj.marked}
|
||||
<div class="text-sm font-semibold text-left">
|
||||
{@html obj.marked}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm font-semibold flex flex-col">
|
||||
<span class="mr-2 text-left">{obj[extraField] ?? ''}</span>
|
||||
{#if extraField != 'path'}
|
||||
<span class="font-normal text-xs text-left italic"
|
||||
>{obj['path'] ?? ''}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extraField != 'description'}
|
||||
<div class="text-xs font-light italic text-left"
|
||||
>{obj['description'] ?? ''}</div
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if extraField != 'description'}
|
||||
<div class="text-xs font-light italic text-left"
|
||||
>{obj['description'] ?? ''}</div
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{#if buttons}
|
||||
<div class="flex flex-row">
|
||||
<div class="flex flex-row items-center">
|
||||
{#each Object.entries(buttons) as [name, button]}
|
||||
<button
|
||||
class="py-4 px-1 gap-1 flex flex-row grow text-black"
|
||||
on:click={() => {
|
||||
button(obj['path'] ?? '')
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
button(obj['path'] ?? '')
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
</div>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="text-center text-sm text-gray-600 mt-2">
|
||||
{@html emptySearchMessage}
|
||||
</div>
|
||||
{:else}
|
||||
<NoItemFound />
|
||||
{/if}
|
||||
</div>
|
||||
<svelte:fragment slot="actions">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user