mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-21 15:15:30 +02:00
Compare commits
355 Commits
patches-im
...
v0.28.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c92d84ef | ||
|
|
72974e00a6 | ||
|
|
d96e2bbeb7 | ||
|
|
a45d8ee8f4 | ||
|
|
9067452a38 | ||
|
|
1fa4d5b2ba | ||
|
|
bade36ea9d | ||
|
|
0c22041623 | ||
|
|
cccee05173 | ||
|
|
9f9c8fccf2 | ||
|
|
ad2e53a67a | ||
|
|
00f3853bd7 | ||
|
|
2880327e94 | ||
|
|
827b84f57e | ||
|
|
11aa8fe0c5 | ||
|
|
b9ac720d99 | ||
|
|
77b0ff7bbf | ||
|
|
e7af2c0ebd | ||
|
|
6a1bedb90f | ||
|
|
a2f142174b | ||
|
|
f4ce304a04 | ||
|
|
bb521f3e7e | ||
|
|
baaa470234 | ||
|
|
4871520dbb | ||
|
|
dad49ec96f | ||
|
|
ce4e37c75b | ||
|
|
c317ec39cb | ||
|
|
a4e9c6e890 | ||
|
|
72fb85f616 | ||
|
|
1e7a6f2071 | ||
|
|
5ffd664570 | ||
|
|
947100c041 | ||
|
|
5410a56638 | ||
|
|
8127dc4536 | ||
|
|
2f37235aea | ||
|
|
290267bca4 | ||
|
|
8eace173b9 | ||
|
|
c9a9ed8164 | ||
|
|
30428053e8 | ||
|
|
1c0dbbcfd6 | ||
|
|
178f4fbdf7 | ||
|
|
2c07a4b2e3 | ||
|
|
75a797097b | ||
|
|
2879816e41 | ||
|
|
3501996b9e | ||
|
|
47556a6486 | ||
|
|
e554adc376 | ||
|
|
1804b935f6 | ||
|
|
985c9102da | ||
|
|
2e03cf3d48 | ||
|
|
33532d3cf7 | ||
|
|
a6999b1cf2 | ||
|
|
f5d18d6f9b | ||
|
|
e3ff7ef3e3 | ||
|
|
b84bc9b7c6 | ||
|
|
de201d0b0a | ||
|
|
6866e2b63a | ||
|
|
3e4a1b92eb | ||
|
|
b9ca6ea9db | ||
|
|
f1d4543d5e | ||
|
|
d8c7c1eaf4 | ||
|
|
4330d7bd99 | ||
|
|
6e67864204 | ||
|
|
2102840bb9 | ||
|
|
30f061e774 | ||
|
|
c00aa6acbf | ||
|
|
8e9ab98a7a | ||
|
|
ce82e2322b | ||
|
|
ec7df05990 | ||
|
|
75a4e8e8ef | ||
|
|
b4319c7ea2 | ||
|
|
e9787b753d | ||
|
|
b419294b09 | ||
|
|
922b4d58f1 | ||
|
|
dc8ff78ee5 | ||
|
|
735c9952d8 | ||
|
|
21821295e3 | ||
|
|
a8467e80e8 | ||
|
|
95e14b4199 | ||
|
|
076262e479 | ||
|
|
c4f4db3ebc | ||
|
|
4882bd25ad | ||
|
|
7a8f2e53d5 | ||
|
|
50182a8048 | ||
|
|
35d35028f6 | ||
|
|
a5a4a1a818 | ||
|
|
c106d13ab5 | ||
|
|
808001d8de | ||
|
|
ce24eadbb4 | ||
|
|
b87f8cc5d8 | ||
|
|
f650200771 | ||
|
|
f961dc6e7a | ||
|
|
4be25da185 | ||
|
|
675c1d7a7d | ||
|
|
28cc361c47 | ||
|
|
cedec5239f | ||
|
|
2f4cbbd3ac | ||
|
|
38b20450dc | ||
|
|
49f43ab3fb | ||
|
|
2eae756cec | ||
|
|
70c261d021 | ||
|
|
9ae2ebff46 | ||
|
|
8ce880d108 | ||
|
|
34304526b1 | ||
|
|
a16c4c1294 | ||
|
|
d1c4ac20e3 | ||
|
|
0195119a86 | ||
|
|
48a577e792 | ||
|
|
bf7a75dd9f | ||
|
|
d316aa4401 | ||
|
|
f1b2cc35b3 | ||
|
|
d2fabc998d | ||
|
|
7185047eb7 | ||
|
|
7121fbe50a | ||
|
|
36cf3a69fc | ||
|
|
c34a01a173 | ||
|
|
9ac147a140 | ||
|
|
20f79ac655 | ||
|
|
6f21f1cc1f | ||
|
|
af76548482 | ||
|
|
13638d0f04 | ||
|
|
edceebec7e | ||
|
|
7599565e73 | ||
|
|
08c9113405 | ||
|
|
1014d4674c | ||
|
|
39b40c58bb | ||
|
|
1861e10b2a | ||
|
|
964e3c4150 | ||
|
|
e05f31d8c6 | ||
|
|
cc3b902d1e | ||
|
|
6c1f2372ed | ||
|
|
7da69862e1 | ||
|
|
612e73bb80 | ||
|
|
a360a259f5 | ||
|
|
149293f4d3 | ||
|
|
a8a5e1c6f1 | ||
|
|
4ede21eda9 | ||
|
|
e275e9162e | ||
|
|
60a6dc5fab | ||
|
|
705c5bc1c9 | ||
|
|
8d56544c1d | ||
|
|
ca527ab6ff | ||
|
|
439fa17292 | ||
|
|
096c04486c | ||
|
|
c9e1079076 | ||
|
|
e29a86a85f | ||
|
|
f9dedd979e | ||
|
|
1ba0eb0c2e | ||
|
|
d7dc10993e | ||
|
|
2a5d3975e8 | ||
|
|
9f3356ddb4 | ||
|
|
f5674f5bf8 | ||
|
|
17a617e585 | ||
|
|
f50eea9e05 | ||
|
|
81ee8f653a | ||
|
|
9507745cc0 | ||
|
|
d33e164876 | ||
|
|
7e6e815375 | ||
|
|
8ab139e222 | ||
|
|
55c04b1323 | ||
|
|
29fc8bfa97 | ||
|
|
e90d8c0ac4 | ||
|
|
98d09f187e | ||
|
|
f96a6a509b | ||
|
|
df27b8b748 | ||
|
|
5c915bc1b9 | ||
|
|
a188ed3914 | ||
|
|
0094b67c13 | ||
|
|
0b9dc3a1a2 | ||
|
|
f8628269b9 | ||
|
|
345023f090 | ||
|
|
3352f7a1c9 | ||
|
|
9e1406e6c8 | ||
|
|
798bfc2b92 | ||
|
|
9d07903e71 | ||
|
|
4baf77c740 | ||
|
|
0adfa51174 | ||
|
|
7a25a9f5b4 | ||
|
|
3d81b98f48 | ||
|
|
9b4a37eebf | ||
|
|
a467920410 | ||
|
|
e62bb7593a | ||
|
|
4af0bdc27b | ||
|
|
b9da9e6367 | ||
|
|
1ea1f6b603 | ||
|
|
677fedbbc0 | ||
|
|
234862e5b6 | ||
|
|
b8bfee7f87 | ||
|
|
2a3ae89f5e | ||
|
|
c22e006e30 | ||
|
|
66ef8a0a5f | ||
|
|
d29de8fcba | ||
|
|
42eeade121 | ||
|
|
8e893e6c64 | ||
|
|
e0c5273eb3 | ||
|
|
a1ebb804fe | ||
|
|
b2218efce6 | ||
|
|
b027d21589 | ||
|
|
2d0874d499 | ||
|
|
e92ba584c0 | ||
|
|
53b612534d | ||
|
|
7b6ece0b65 | ||
|
|
3b7dcaca7a | ||
|
|
0d0383f84a | ||
|
|
331b12c7d8 | ||
|
|
7c534d62b6 | ||
|
|
df648eccf6 | ||
|
|
fca10c135a | ||
|
|
03969b8f45 | ||
|
|
d8e08558cc | ||
|
|
293ad3862a | ||
|
|
6cc646c974 | ||
|
|
d00ec952a9 | ||
|
|
74461c860e | ||
|
|
9d09e51cf7 | ||
|
|
e3aadf1908 | ||
|
|
69d3286aaf | ||
|
|
0b1c1e8b8c | ||
|
|
5435e1dac4 | ||
|
|
999e5fabd3 | ||
|
|
6a2098d522 | ||
|
|
5d3e05536e | ||
|
|
3e461f642e | ||
|
|
7a62f47e43 | ||
|
|
daf700429d | ||
|
|
781bf5e116 | ||
|
|
66190434a7 | ||
|
|
2b42ef7829 | ||
|
|
97374f736e | ||
|
|
28fc58d898 | ||
|
|
32a14be564 | ||
|
|
e874b2c459 | ||
|
|
660bc3cd00 | ||
|
|
c98548fa51 | ||
|
|
0d4d60953e | ||
|
|
f7079f51de | ||
|
|
15a1a5d0aa | ||
|
|
d99e0bf4dd | ||
|
|
20acc8bce5 | ||
|
|
5ef431b9e9 | ||
|
|
3439b758df | ||
|
|
97f9e8ad25 | ||
|
|
5faa319b69 | ||
|
|
42e8320866 | ||
|
|
b0c6b1338d | ||
|
|
309a411718 | ||
|
|
ef65e0934e | ||
|
|
84dd6458aa | ||
|
|
37e5c52cbe | ||
|
|
49914c5d92 | ||
|
|
9df4398c8f | ||
|
|
a83a742bf3 | ||
|
|
713aa5fd58 | ||
|
|
b210c48eaa | ||
|
|
d70c865dc7 | ||
|
|
df2221a4bd | ||
|
|
831584550b | ||
|
|
c688311580 | ||
|
|
b9c62cc515 | ||
|
|
605931861b | ||
|
|
4e8d37bff7 | ||
|
|
be35709cea | ||
|
|
6c3230648a | ||
|
|
756d276f47 | ||
|
|
1d5ab71bd5 | ||
|
|
9880c71dba | ||
|
|
33c3a4ed4e | ||
|
|
3689a82ec5 | ||
|
|
b818d661fd | ||
|
|
1302d705e7 | ||
|
|
685a4c0b69 | ||
|
|
b58f2b236f | ||
|
|
6350a8ddd3 | ||
|
|
46e1bed5e9 | ||
|
|
8aba7b08cf | ||
|
|
9eeac50642 | ||
|
|
2db4c448d4 | ||
|
|
c89f2e302b | ||
|
|
1c25ab4303 | ||
|
|
46ac272f3f | ||
|
|
9818e3c3ba | ||
|
|
20320639ce | ||
|
|
88f387dd83 | ||
|
|
752f90c330 | ||
|
|
0fc043d0ad | ||
|
|
13b94ed3be | ||
|
|
7747929cdf | ||
|
|
06fd561bb1 | ||
|
|
62fb117ecf | ||
|
|
8713d3e1aa | ||
|
|
76038f6db6 | ||
|
|
a511f4db40 | ||
|
|
95a944c4e5 | ||
|
|
6d6cf18108 | ||
|
|
32ed0c7285 | ||
|
|
923466b4fa | ||
|
|
d5163322fb | ||
|
|
714849883e | ||
|
|
407ce3f425 | ||
|
|
49a189fcbf | ||
|
|
7e8d3b7162 | ||
|
|
24010af265 | ||
|
|
33192ce4d1 | ||
|
|
02a695c6af | ||
|
|
e5f51fd7be | ||
|
|
620e4c4835 | ||
|
|
125c23e2c0 | ||
|
|
51e005701d | ||
|
|
c04dd63db8 | ||
|
|
4fd06b00a0 | ||
|
|
1f9335ad5d | ||
|
|
2cd3c27ae9 | ||
|
|
53ae08cec4 | ||
|
|
8aab8dd2a5 | ||
|
|
e8bec0ae03 | ||
|
|
389a69484e | ||
|
|
f656e624f7 | ||
|
|
f5635f6645 | ||
|
|
81a04d0777 | ||
|
|
b63c22a7df | ||
|
|
05ad6d812c | ||
|
|
aa579977e3 | ||
|
|
2788323e01 | ||
|
|
3b74425d35 | ||
|
|
edbc98aea7 | ||
|
|
60f5ab304a | ||
|
|
8291c6d835 | ||
|
|
7928d117b3 | ||
|
|
eec4e21751 | ||
|
|
343a84d6bc | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 | ||
|
|
5c3b7acd54 | ||
|
|
44f8590fe8 | ||
|
|
2be92d20bb | ||
|
|
2be938a695 | ||
|
|
95dd9ddeb6 | ||
|
|
33fb21bfe1 | ||
|
|
5ca4d8366e | ||
|
|
cc49db63da | ||
|
|
f5f21ef195 | ||
|
|
464d58daaa | ||
|
|
50b0a5d61c |
21
.devcontainer/Dockerfile
Normal file
21
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Dockerfile for DevContainer
|
||||||
|
FROM node:24.4.0-bullseye-slim
|
||||||
|
|
||||||
|
# Install essential packages
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
curl \
|
||||||
|
bash \
|
||||||
|
git \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set up PNPM
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
|
# Create workspace directory
|
||||||
|
WORKDIR /workspaces/dokploy
|
||||||
|
|
||||||
|
# Set up user permissions
|
||||||
|
USER node
|
||||||
53
.devcontainer/devcontainer.json
Normal file
53
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "Dokploy development container",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
"moby": true,
|
||||||
|
"version": "latest"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/git:1": {
|
||||||
|
"ppa": true,
|
||||||
|
"version": "latest"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/go:1": {
|
||||||
|
"version": "1.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-vscode.vscode-typescript-next",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"ms-vscode.vscode-json",
|
||||||
|
"biomejs.biome",
|
||||||
|
"golang.go",
|
||||||
|
"redhat.vscode-xml",
|
||||||
|
"github.vscode-github-actions",
|
||||||
|
"github.copilot",
|
||||||
|
"github.copilot-chat"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardPorts": [3000, 5432, 6379],
|
||||||
|
"portsAttributes": {
|
||||||
|
"3000": {
|
||||||
|
"label": "Dokploy App",
|
||||||
|
"onAutoForward": "notify"
|
||||||
|
},
|
||||||
|
"5432": {
|
||||||
|
"label": "PostgreSQL",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
},
|
||||||
|
"6379": {
|
||||||
|
"label": "Redis",
|
||||||
|
"onAutoForward": "silent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "node",
|
||||||
|
"workspaceFolder": "/workspaces/dokploy",
|
||||||
|
"runArgs": ["--name", "dokploy-devcontainer"]
|
||||||
|
}
|
||||||
40
.github/workflows/deploy.yml
vendored
40
.github/workflows/deploy.yml
vendored
@@ -13,6 +13,17 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set tag and version
|
||||||
|
id: meta-cloud
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -25,8 +36,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.cloud
|
file: ./Dockerfile.cloud
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-cloud.outputs.tags }}
|
||||||
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
|
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
|
||||||
@@ -40,6 +50,16 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set tag and version
|
||||||
|
id: meta-schedule
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -52,8 +72,7 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.schedule
|
file: ./Dockerfile.schedule
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-schedule.outputs.tags }}
|
||||||
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|
||||||
build-and-push-server-image:
|
build-and-push-server-image:
|
||||||
@@ -63,6 +82,16 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set tag and version
|
||||||
|
id: meta-server
|
||||||
|
run: |
|
||||||
|
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||||
|
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||||
|
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
@@ -75,6 +104,5 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.server
|
file: ./Dockerfile.server
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: ${{ steps.meta-server.outputs.tags }}
|
||||||
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
22
.github/workflows/pr-quality.yml
vendored
Normal file
22
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
name: PR Quality
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
anti-slop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: peakoss/anti-slop@v0
|
||||||
|
with:
|
||||||
|
max-failures: 4
|
||||||
|
blocked-commit-authors: "claude,copilot"
|
||||||
|
require-description: true
|
||||||
|
min-account-age: 5
|
||||||
2
.github/workflows/pull-request.yml
vendored
2
.github/workflows/pull-request.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.16.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install Nixpacks
|
- name: Install Nixpacks
|
||||||
|
|||||||
2
.github/workflows/sync-openapi-docs.yml
vendored
2
.github/workflows/sync-openapi-docs.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.16.0
|
node-version: 24.4.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,6 +44,3 @@ yarn-error.log*
|
|||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
# Development environment
|
|
||||||
.devcontainer
|
|
||||||
@@ -53,7 +53,7 @@ feat: add new feature
|
|||||||
|
|
||||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||||
|
|
||||||
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
|
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dokploy/dokploy.git
|
git clone https://github.com/dokploy/dokploy.git
|
||||||
@@ -165,10 +165,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
|||||||
|
|
||||||
### Important Considerations for Pull Requests
|
### Important Considerations for Pull Requests
|
||||||
|
|
||||||
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
|
||||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||||
|
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
||||||
|
|
||||||
Thank you for your contribution!
|
Thank you for your contribution!
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/dokploy run build
|
RUN pnpm --filter=./apps/dokploy run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/dokploy run build
|
RUN pnpm --filter=./apps/dokploy run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/schedules run build
|
RUN pnpm --filter=./apps/schedules run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
|
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
|
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.16.0-slim AS base
|
FROM node:24.4.0-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.12.0 --activate
|
RUN corepack prepare pnpm@10.22.0 --activate
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY . /usr/src/app
|
COPY . /usr/src/app
|
||||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
|||||||
RUN pnpm --filter=@dokploy/server build
|
RUN pnpm --filter=@dokploy/server build
|
||||||
RUN pnpm --filter=./apps/api run build
|
RUN pnpm --filter=./apps/api run build
|
||||||
|
|
||||||
RUN pnpm --filter=./apps/api --prod deploy /prod/api
|
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
|
||||||
|
|
||||||
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
|
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
LEMON_SQUEEZY_API_KEY=""
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
LEMON_SQUEEZY_STORE_ID=""
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
|
|
||||||
|
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
||||||
|
# INNGEST_BASE_URL="http://localhost:8288"
|
||||||
|
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
||||||
|
# INNGEST_SIGNING_KEY="your-signing-key"
|
||||||
|
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
||||||
|
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
||||||
|
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
||||||
|
# INNGEST_JOBS_MAX_EVENTS=100
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||||
"build": "tsc --project tsconfig.json",
|
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"inngest": "3.40.1",
|
"inngest": "3.40.1",
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.7.6",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.11.7",
|
"hono": "^4.11.7",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
@@ -20,18 +20,19 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.0",
|
"@types/node": "^24.4.0",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"rimraf": "6.1.3",
|
||||||
"tsx": "^4.16.2",
|
"tsx": "^4.16.2",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.12.0",
|
"packageManager": "pnpm@10.22.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.16.0",
|
"node": "^24.4.0",
|
||||||
"pnpm": ">=9.12.0"
|
"pnpm": ">=10.22.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type DeployJob,
|
type DeployJob,
|
||||||
deployJobSchema,
|
deployJobSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
import { fetchDeploymentJobs } from "./service.js";
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
|||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error", error);
|
|
||||||
logger.error("Failed to send deployment event", error);
|
logger.error("Failed to send deployment event", error);
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
|||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||||
|
app.get("/jobs", async (c) => {
|
||||||
|
const serverId = c.req.query("serverId");
|
||||||
|
if (!serverId) {
|
||||||
|
return c.json({ message: "serverId is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await fetchDeploymentJobs(serverId);
|
||||||
|
return c.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes("INNGEST_BASE_URL")) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||||
|
503,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||||
|
return c.json([], 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Serve Inngest functions endpoint
|
// Serve Inngest functions endpoint
|
||||||
app.on(
|
app.on(
|
||||||
["GET", "POST", "PUT"],
|
["GET", "POST", "PUT"],
|
||||||
|
|||||||
239
apps/api/src/service.ts
Normal file
239
apps/api/src/service.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
|
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||||
|
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_EVENTS = 500;
|
||||||
|
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
||||||
|
|
||||||
|
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
||||||
|
type InngestEventRow = {
|
||||||
|
internal_id?: string;
|
||||||
|
accountID?: string;
|
||||||
|
environmentID?: string;
|
||||||
|
source?: string;
|
||||||
|
sourceID?: string | null;
|
||||||
|
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
||||||
|
receivedAt?: string;
|
||||||
|
received_at?: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
user?: unknown;
|
||||||
|
ts: number;
|
||||||
|
v?: string | null;
|
||||||
|
metadata?: {
|
||||||
|
fetchedAt: string;
|
||||||
|
cachedUntil: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
||||||
|
type InngestRun = {
|
||||||
|
run_id: string;
|
||||||
|
event_id: string;
|
||||||
|
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
||||||
|
run_started_at?: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
output?: unknown;
|
||||||
|
// dev server / API may use different casing
|
||||||
|
run_started_at_ms?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
||||||
|
return ev.receivedAt ?? ev.received_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map Inngest run status to BullMQ-style state for the UI */
|
||||||
|
function runStatusToState(
|
||||||
|
status: string,
|
||||||
|
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
||||||
|
const s = status.toLowerCase();
|
||||||
|
if (s === "running") return "active";
|
||||||
|
if (s === "completed") return "completed";
|
||||||
|
if (s === "failed") return "failed";
|
||||||
|
if (s === "cancelled") return "cancelled";
|
||||||
|
if (s === "queued") return "pending";
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchInngestEvents = async () => {
|
||||||
|
const maxEvents = MAX_EVENTS;
|
||||||
|
const all: InngestEventRow[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: "100" });
|
||||||
|
if (cursor) {
|
||||||
|
params.set("cursor", cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest API error", {
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
data?: InngestEventRow[];
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
};
|
||||||
|
const data = Array.isArray(body.data) ? body.data : [];
|
||||||
|
all.push(...data);
|
||||||
|
|
||||||
|
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
||||||
|
const nextCursor =
|
||||||
|
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
||||||
|
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
||||||
|
cursor = hasMore ? nextCursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return all.slice(0, maxEvents);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
||||||
|
export const fetchInngestRunsForEvent = async (
|
||||||
|
eventId: string,
|
||||||
|
): Promise<InngestRun[]> => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${signingKey}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn("Inngest runs API error", {
|
||||||
|
eventId,
|
||||||
|
status: res.status,
|
||||||
|
body: await res.text(),
|
||||||
|
});
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const body = (await res.json()) as { data?: InngestRun[] };
|
||||||
|
return Array.isArray(body.data) ? body.data : [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row for the queue UI (BullMQ-compatible shape) */
|
||||||
|
export type DeploymentJobRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: number;
|
||||||
|
processedOn?: number;
|
||||||
|
finishedOn?: number;
|
||||||
|
failedReason?: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
||||||
|
function buildDeploymentRowsFromRuns(
|
||||||
|
events: InngestEventRow[],
|
||||||
|
runsByEventId: Map<string, InngestRun[]>,
|
||||||
|
serverId: string,
|
||||||
|
): DeploymentJobRow[] {
|
||||||
|
const requested = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
const rows: DeploymentJobRow[] = [];
|
||||||
|
|
||||||
|
for (const ev of requested) {
|
||||||
|
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||||
|
const runs = runsByEventId.get(ev.id) ?? [];
|
||||||
|
|
||||||
|
if (runs.length === 0) {
|
||||||
|
// Queued: event received but no run yet
|
||||||
|
rows.push({
|
||||||
|
id: ev.id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: ev.ts,
|
||||||
|
processedOn: ev.ts,
|
||||||
|
finishedOn: undefined,
|
||||||
|
failedReason: undefined,
|
||||||
|
state: "pending",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const run of runs) {
|
||||||
|
const state = runStatusToState(run.status);
|
||||||
|
const runStartedMs =
|
||||||
|
run.run_started_at_ms ??
|
||||||
|
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
||||||
|
const endedMs = run.ended_at
|
||||||
|
? new Date(run.ended_at).getTime()
|
||||||
|
: undefined;
|
||||||
|
const failedReason =
|
||||||
|
state === "failed" &&
|
||||||
|
run.output &&
|
||||||
|
typeof run.output === "object" &&
|
||||||
|
"error" in run.output
|
||||||
|
? String((run.output as { error?: unknown }).error)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: run.run_id,
|
||||||
|
name: ev.name,
|
||||||
|
data,
|
||||||
|
timestamp: runStartedMs,
|
||||||
|
processedOn: runStartedMs,
|
||||||
|
finishedOn:
|
||||||
|
state === "completed" || state === "failed" || state === "cancelled"
|
||||||
|
? endedMs
|
||||||
|
: undefined,
|
||||||
|
failedReason,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
||||||
|
export const fetchDeploymentJobs = async (
|
||||||
|
serverId: string,
|
||||||
|
): Promise<DeploymentJobRow[]> => {
|
||||||
|
if (!signingKey) {
|
||||||
|
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await fetchInngestEvents();
|
||||||
|
|
||||||
|
const requestedForServer = events.filter(
|
||||||
|
(e) =>
|
||||||
|
e.name === "deployment/requested" &&
|
||||||
|
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||||
|
);
|
||||||
|
// Limit to avoid too many run fetches
|
||||||
|
const toFetch = requestedForServer.slice(0, 50);
|
||||||
|
const runsByEventId = new Map<string, InngestRun[]>();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
toFetch.map(async (ev) => {
|
||||||
|
const runs = await fetchInngestRunsForEvent(ev.id);
|
||||||
|
runsByEventId.set(ev.id, runs);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema";
|
import type { DeployJob } from "./schema.js";
|
||||||
|
|
||||||
export const deploy = async (job: DeployJob) => {
|
export const deploy = async (job: DeployJob) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
20.16.0
|
|
||||||
@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
} as any;
|
} as any;
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(),
|
select: vi.fn(() => createChainableMock()),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
|
patch: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ApplicationNested } from "@dokploy/server";
|
import type { ApplicationNested } from "@dokploy/server";
|
||||||
@@ -9,17 +8,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||||
|
|
||||||
// Mock constants to avoid load error
|
|
||||||
vi.mock("@dokploy/server/constants", () => ({
|
|
||||||
paths: () => ({
|
|
||||||
LOGS_PATH: "/tmp/dokploy-test-real/logs",
|
|
||||||
APPLICATIONS_PATH: "/tmp/dokploy-test-real/applications",
|
|
||||||
PATCH_REPOS_PATH: "/tmp/dokploy-test-real/patch-repos",
|
|
||||||
}),
|
|
||||||
IS_CLOUD: false,
|
|
||||||
docker: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock ONLY database and notifications
|
// Mock ONLY database and notifications
|
||||||
vi.mock("@dokploy/server/db", () => {
|
vi.mock("@dokploy/server/db", () => {
|
||||||
const createChainableMock = (): any => {
|
const createChainableMock = (): any => {
|
||||||
@@ -27,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
set: vi.fn(() => chain),
|
set: vi.fn(() => chain),
|
||||||
where: vi.fn(() => chain),
|
where: vi.fn(() => chain),
|
||||||
returning: vi.fn().mockResolvedValue([{}]),
|
returning: vi.fn().mockResolvedValue([{}]),
|
||||||
|
from: vi.fn(() => chain),
|
||||||
|
innerJoin: vi.fn(() => chain),
|
||||||
|
then: (resolve: (v: any) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return chain;
|
return chain;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(),
|
select: vi.fn(() => createChainableMock()),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(() => createChainableMock()),
|
update: vi.fn(() => createChainableMock()),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
@@ -41,6 +34,12 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
applications: {
|
applications: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
},
|
},
|
||||||
|
patch: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -79,16 +78,6 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
|
|||||||
createRollback: vi.fn(),
|
createRollback: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@dokploy/server/services/patch", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<
|
|
||||||
typeof import("@dokploy/server/services/patch")
|
|
||||||
>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
findPatchesByApplicationId: vi.fn().mockResolvedValue([]),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// NOT mocked (executed for real):
|
// NOT mocked (executed for real):
|
||||||
// - execAsync
|
// - execAsync
|
||||||
// - cloneGitRepository
|
// - cloneGitRepository
|
||||||
@@ -100,11 +89,6 @@ import * as adminService from "@dokploy/server/services/admin";
|
|||||||
import * as applicationService from "@dokploy/server/services/application";
|
import * as applicationService from "@dokploy/server/services/application";
|
||||||
import { deployApplication } from "@dokploy/server/services/application";
|
import { deployApplication } from "@dokploy/server/services/application";
|
||||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||||
import * as patchService from "@dokploy/server/services/patch";
|
|
||||||
import { generatePatch } from "@dokploy/server/services/patch";
|
|
||||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const createMockApplication = (
|
const createMockApplication = (
|
||||||
overrides: Partial<ApplicationNested> = {},
|
overrides: Partial<ApplicationNested> = {},
|
||||||
@@ -501,105 +485,6 @@ describe(
|
|||||||
},
|
},
|
||||||
REAL_TEST_TIMEOUT,
|
REAL_TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
it(
|
|
||||||
"should REALLY apply patches from database during deployment",
|
|
||||||
async () => {
|
|
||||||
// 1. Setup local temporary git repo
|
|
||||||
const tempRepo = await mkdtemp(join(tmpdir(), "real-patch-repo-"));
|
|
||||||
// Helper for local git commands
|
|
||||||
const execLocal = async (cmd: string) => execAsync(cmd, { cwd: tempRepo });
|
|
||||||
|
|
||||||
await execLocal("git init");
|
|
||||||
await execLocal("git config user.email 'test@dokploy.com'");
|
|
||||||
await execLocal("git config user.name 'Dokploy Test'");
|
|
||||||
|
|
||||||
// Create a simple Dockerfile and server script
|
|
||||||
// We use a simple python server to verify output
|
|
||||||
await writeFile(join(tempRepo, "app.py"), "print('Original App')\n");
|
|
||||||
await writeFile(
|
|
||||||
join(tempRepo, "Dockerfile"),
|
|
||||||
"FROM python:3.9-slim\nCOPY app.py .\nCMD [\"python\", \"app.py\"]\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
await execLocal("git add .");
|
|
||||||
await execLocal("git commit -m 'Initial commit'");
|
|
||||||
// Ensure master/main branch exists (git init might create master or main depending on config)
|
|
||||||
// We force create a branch named 'main' to be consistent
|
|
||||||
await execLocal("git checkout -b main || git checkout main");
|
|
||||||
|
|
||||||
// 2. Mock Application to use this local repo
|
|
||||||
const patchAppName = `real-patch-app-${Date.now()}`;
|
|
||||||
const patchApp = createMockApplication({
|
|
||||||
appName: patchAppName,
|
|
||||||
buildType: "dockerfile",
|
|
||||||
customGitUrl: `file://${tempRepo}`,
|
|
||||||
customGitBranch: "main",
|
|
||||||
dockerfile: "Dockerfile",
|
|
||||||
});
|
|
||||||
currentAppName = patchAppName;
|
|
||||||
allTestAppNames.push(patchAppName);
|
|
||||||
|
|
||||||
// Setup standard mocks
|
|
||||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
|
||||||
patchApp as any,
|
|
||||||
);
|
|
||||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
|
||||||
patchApp as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Generate a patch
|
|
||||||
// We modify the file, generate patch, and then reset.
|
|
||||||
const newContent = "print('Patched App')\n";
|
|
||||||
const patchContent = await generatePatch({
|
|
||||||
codePath: tempRepo,
|
|
||||||
filePath: "app.py",
|
|
||||||
newContent,
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Mock patch service to return this patch
|
|
||||||
vi.mocked(patchService.findPatchesByApplicationId).mockResolvedValue([
|
|
||||||
{
|
|
||||||
patchId: "test-patch-1",
|
|
||||||
applicationId: "test-app-id",
|
|
||||||
composeId: null,
|
|
||||||
filePath: "app.py",
|
|
||||||
content: patchContent,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`\n🚀 Testing deployment with patch: ${currentAppName}`);
|
|
||||||
|
|
||||||
// 5. Deploy
|
|
||||||
const result = await deployApplication({
|
|
||||||
applicationId: "test-app-id",
|
|
||||||
titleLog: "Real Patch Test",
|
|
||||||
descriptionLog: "Testing patch application",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(true);
|
|
||||||
|
|
||||||
// 6. Verify Log contains "Applying patch"
|
|
||||||
const { stdout: logContent } = await execAsync(
|
|
||||||
`cat ${currentDeployment.logPath}`,
|
|
||||||
);
|
|
||||||
// The implementation logs "Applying patch: ..."
|
|
||||||
expect(logContent).toContain("Applying patch");
|
|
||||||
expect(logContent).toContain("app.py");
|
|
||||||
console.log("✅ Verified patch execution logs");
|
|
||||||
|
|
||||||
// 7. Verify the deployed image contains the patched code
|
|
||||||
// We run the image and check output
|
|
||||||
const { stdout: runOutput } = await execAsync(
|
|
||||||
`docker run --rm ${patchAppName}`,
|
|
||||||
);
|
|
||||||
expect(runOutput.trim()).toBe("Patched App");
|
|
||||||
console.log("✅ Verified patched output:", runOutput.trim());
|
|
||||||
},
|
|
||||||
REAL_TEST_TIMEOUT,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
REAL_TEST_TIMEOUT,
|
REAL_TEST_TIMEOUT,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
{ commits: [{ message: "[skip ci] test" }] },
|
{ commits: [{ message: "[skip ci] test" }] },
|
||||||
),
|
),
|
||||||
).toBe("[skip ci] test");
|
).toBe("[skip ci] test");
|
||||||
|
|
||||||
|
// Soft Serve
|
||||||
|
expect(
|
||||||
|
extractCommitMessage(
|
||||||
|
{ "x-softserve-event": "push" },
|
||||||
|
{ commits: [{ message: "[skip ci] test" }] },
|
||||||
|
),
|
||||||
|
).toBe("[skip ci] test");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle missing commit message", () => {
|
it("should handle missing commit message", () => {
|
||||||
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||||
"NEW COMMIT",
|
"NEW COMMIT",
|
||||||
);
|
);
|
||||||
|
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
||||||
|
"NEW COMMIT",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
extractBranchName,
|
||||||
|
extractCommitMessage,
|
||||||
|
extractHash,
|
||||||
|
getProviderByHeader,
|
||||||
|
} from "@/pages/api/deploy/[refreshToken]";
|
||||||
|
|
||||||
|
describe("Soft Serve Webhook", () => {
|
||||||
|
const mockSoftServeHeaders = {
|
||||||
|
"x-softserve-event": "push",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMockBody = (message: string, hash: string, branch: string) => ({
|
||||||
|
event: "push",
|
||||||
|
ref: `refs/heads/${branch}`,
|
||||||
|
after: hash,
|
||||||
|
commits: [{ message: message }],
|
||||||
|
});
|
||||||
|
const message: string = "feat: add new feature";
|
||||||
|
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
|
||||||
|
const branch: string = "feat/add-new";
|
||||||
|
const goodWebhook = createMockBody(message, hash, branch);
|
||||||
|
|
||||||
|
it("should properly extract the provider name", () => {
|
||||||
|
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly extract the commit message", () => {
|
||||||
|
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly extract hash", () => {
|
||||||
|
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should properly extract branch name", () => {
|
||||||
|
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should gracefully handle invalid webhook", () => {
|
||||||
|
expect(getProviderByHeader({})).toBeNull();
|
||||||
|
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||||
|
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||||
|
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
||||||
const { APPLICATIONS_PATH } = paths();
|
const { APPLICATIONS_PATH } = paths();
|
||||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||||
const actual = await importOriginal();
|
const actual = await importOriginal();
|
||||||
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
...actual,
|
...actual,
|
||||||
paths: () => ({
|
paths: () => ({
|
||||||
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
// @ts-ignore
|
||||||
|
...actual.paths(),
|
||||||
|
BASE_PATH: OUTPUT_BASE,
|
||||||
|
APPLICATIONS_PATH: OUTPUT_BASE,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
|
|||||||
ulimitsSwarm: null,
|
ulimitsSwarm: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
|
||||||
|
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
|
||||||
|
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
|
||||||
|
*/
|
||||||
|
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
|
||||||
|
baseApp.appName = "ghsa-rce";
|
||||||
|
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
|
||||||
|
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
|
||||||
|
const cronPayload = "* * * * * root id\n";
|
||||||
|
const placeholder = "x".repeat(traversalEntry.length);
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile(
|
||||||
|
"package.json",
|
||||||
|
Buffer.from('{"name": "app", "version": "1.0.0"}'),
|
||||||
|
);
|
||||||
|
zip.addFile("index.js", Buffer.from('console.log("Application");'));
|
||||||
|
zip.addFile(placeholder, Buffer.from(cronPayload));
|
||||||
|
let buf = Buffer.from(zip.toBuffer());
|
||||||
|
buf = Buffer.from(
|
||||||
|
buf.toString("binary").split(placeholder).join(traversalEntry),
|
||||||
|
"binary",
|
||||||
|
);
|
||||||
|
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
|
||||||
|
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
|
||||||
|
/Path traversal detected.*resolved path escapes output directory/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security: existing symlink escape", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT write outside base when directory is a symlink", async () => {
|
||||||
|
const appName = "symlink-existing";
|
||||||
|
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||||
|
await fs.mkdir(output, { recursive: true });
|
||||||
|
|
||||||
|
// outside target (attacker wants to write here)
|
||||||
|
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
|
||||||
|
await fs.mkdir(outside, { recursive: true });
|
||||||
|
|
||||||
|
// attacker-controlled symlink inside project
|
||||||
|
await fs.symlink(outside, path.join(output, "logs"));
|
||||||
|
|
||||||
|
// zip looks totally harmless
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
|
||||||
|
|
||||||
|
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||||
|
|
||||||
|
await unzipDrop(file, { ...baseApp, appName });
|
||||||
|
|
||||||
|
// if vulnerable -> file exists outside sandbox
|
||||||
|
const escaped = await fs
|
||||||
|
.readFile(path.join(outside, "pwned.txt"), "utf8")
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect(escaped).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security: zip symlink entry blocked", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects zip containing real symlink entry", async () => {
|
||||||
|
const appName = "zip-symlink";
|
||||||
|
|
||||||
|
const zipBuffer = await fs.readFile(
|
||||||
|
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File([zipBuffer as any], "exploit.zip");
|
||||||
|
|
||||||
|
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
|
||||||
|
/Dangerous node entries are not allowed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unzipDrop path under output (no traversal)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
|
||||||
|
baseApp.appName = "cron-under-output";
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile(
|
||||||
|
"etc/cron.d/malicious-cron",
|
||||||
|
Buffer.from("* * * * * root id\n"),
|
||||||
|
);
|
||||||
|
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
|
||||||
|
const file = new File(
|
||||||
|
[zip.toBuffer() as unknown as ArrayBuffer],
|
||||||
|
"app.zip",
|
||||||
|
);
|
||||||
|
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
|
await unzipDrop(file, baseApp);
|
||||||
|
const content = await fs.readFile(
|
||||||
|
path.join(outputPath, "etc/cron.d/malicious-cron"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
expect(content).toBe("* * * * * root id\n");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
|
||||||
|
const appName = "sandbox-escape";
|
||||||
|
|
||||||
|
const base = APPLICATIONS_PATH.replace("/applications", "");
|
||||||
|
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||||
|
|
||||||
|
await fs.mkdir(output, { recursive: true });
|
||||||
|
|
||||||
|
// attacker writes into traefik config inside base
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile(
|
||||||
|
"../../../traefik/dynamic/evil.yml",
|
||||||
|
Buffer.from("pwned: true"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||||
|
|
||||||
|
await unzipDrop(file, { ...baseApp, appName });
|
||||||
|
|
||||||
|
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
|
||||||
|
|
||||||
|
const exists = await fs
|
||||||
|
.readFile(escapedPath)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
expect(exists).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
// const { APPLICATIONS_PATH } = paths();
|
// const { APPLICATIONS_PATH } = paths();
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
|
|||||||
try {
|
try {
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||||
console.log(`Output Path: ${outputPath}`);
|
|
||||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||||
const file = new File([zipBuffer], "single.zip");
|
const file = new File([zipBuffer], "single.zip");
|
||||||
await unzipDrop(file, baseApp);
|
await unzipDrop(file, baseApp);
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
|
||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/etc/passwd
|
||||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
@@ -1,106 +0,0 @@
|
|||||||
|
|
||||||
import { generatePatch } from "@dokploy/server/services/patch";
|
|
||||||
import { describe, expect, it, afterEach } from "vitest";
|
|
||||||
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { exec } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
|
|
||||||
const execAsyncLocal = promisify(exec);
|
|
||||||
|
|
||||||
describe("Patch System Integration", () => {
|
|
||||||
let tempDir: string;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
if (tempDir) {
|
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should generate a patch that can be successfully applied via git", async () => {
|
|
||||||
// Setup repo
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-"));
|
|
||||||
const fileName = "test.txt";
|
|
||||||
const filePath = join(tempDir, fileName);
|
|
||||||
|
|
||||||
await execAsyncLocal("git init", { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
|
||||||
|
|
||||||
// Original content
|
|
||||||
await writeFile(filePath, "line1\nline2\n");
|
|
||||||
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
|
||||||
|
|
||||||
// Generate patch (modify content)
|
|
||||||
const newContent = "line1\nline2\nline3\n";
|
|
||||||
const patchContent = await generatePatch({
|
|
||||||
codePath: tempDir,
|
|
||||||
filePath: fileName,
|
|
||||||
newContent,
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify patch format
|
|
||||||
expect(patchContent.endsWith("\n")).toBe(true);
|
|
||||||
|
|
||||||
// Reset file (generatePatch does reset, but ensure it)
|
|
||||||
await execAsyncLocal("git checkout .", { cwd: tempDir });
|
|
||||||
const savedContent = await readFile(filePath, "utf-8");
|
|
||||||
expect(savedContent).toBe("line1\nline2\n");
|
|
||||||
|
|
||||||
// Apply patch verification
|
|
||||||
// We simulate what Deployment Service does: write patch to file and run git apply
|
|
||||||
const patchFile = join(tempDir, "changes.patch");
|
|
||||||
await writeFile(patchFile, patchContent);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("Git apply failed:", e.message);
|
|
||||||
console.log("Patch content:", JSON.stringify(patchContent));
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const appliedContent = await readFile(filePath, "utf-8");
|
|
||||||
expect(appliedContent).toBe(newContent);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle files created without trailing newline", async () => {
|
|
||||||
// Setup repo
|
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-noline-"));
|
|
||||||
const fileName = "noline.txt";
|
|
||||||
const filePath = join(tempDir, fileName);
|
|
||||||
|
|
||||||
await execAsyncLocal("git init", { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
|
||||||
|
|
||||||
// Original content WITHOUT newline
|
|
||||||
await writeFile(filePath, "line1");
|
|
||||||
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
|
||||||
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
|
||||||
|
|
||||||
// Generate patch
|
|
||||||
const newContent = "line1\nline2";
|
|
||||||
const patchContent = await generatePatch({
|
|
||||||
codePath: tempDir,
|
|
||||||
filePath: fileName,
|
|
||||||
newContent,
|
|
||||||
serverId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify patch format
|
|
||||||
expect(patchContent.endsWith("\n")).toBe(true);
|
|
||||||
|
|
||||||
// Apply patch
|
|
||||||
const patchFile = join(tempDir, "changes.patch");
|
|
||||||
await writeFile(patchFile, patchContent);
|
|
||||||
|
|
||||||
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
|
||||||
|
|
||||||
const appliedContent = await readFile(filePath, "utf-8");
|
|
||||||
expect(appliedContent).toBe(newContent);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
144
apps/dokploy/__test__/permissions/check-permission.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockMemberData = (
|
||||||
|
role: string,
|
||||||
|
overrides: Record<string, boolean> = {},
|
||||||
|
) => ({
|
||||||
|
id: "member-1",
|
||||||
|
role,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
accessedProjects: [] as string[],
|
||||||
|
accessedServices: [] as string[],
|
||||||
|
accessedEnvironments: [] as string[],
|
||||||
|
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||||
|
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||||
|
canCreateServices: overrides.canCreateServices ?? false,
|
||||||
|
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||||
|
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||||
|
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||||
|
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||||
|
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||||
|
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||||
|
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||||
|
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||||
|
user: { id: "user-1", email: "test@test.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||||
|
mockMemberData("member");
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
member: {
|
||||||
|
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
organizationRole: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||||
|
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { checkPermission } = await import("@dokploy/server/services/permission");
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("static roles bypass enterprise resources", () => {
|
||||||
|
it("owner bypasses deployment.read", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { deployment: ["read"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin bypasses backup.create", async () => {
|
||||||
|
memberToReturn = mockMemberData("admin");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { backup: ["create"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member bypasses schedule.delete", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { schedule: ["delete"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, {
|
||||||
|
deployment: ["read"],
|
||||||
|
backup: ["create"],
|
||||||
|
domain: ["delete"],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("static roles validate free-tier resources", () => {
|
||||||
|
it("owner passes project.create", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { project: ["create"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member fails project.create (no legacy override)", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { project: ["create"] }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member passes service.read", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { service: ["read"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member fails service.create", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { service: ["create"] }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legacy boolean overrides for member", () => {
|
||||||
|
it("member passes project.create with canCreateProjects=true", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { project: ["create"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member passes docker.read with canAccessToDocker=true", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||||
|
await expect(
|
||||||
|
checkPermission(ctx, { docker: ["read"] }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member fails docker.read with canAccessToDocker=false", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
enterpriseOnlyResources,
|
||||||
|
statements,
|
||||||
|
} from "@dokploy/server/lib/access-control";
|
||||||
|
|
||||||
|
const FREE_TIER_RESOURCES = [
|
||||||
|
"organization",
|
||||||
|
"member",
|
||||||
|
"invitation",
|
||||||
|
"team",
|
||||||
|
"ac",
|
||||||
|
"project",
|
||||||
|
"service",
|
||||||
|
"environment",
|
||||||
|
"docker",
|
||||||
|
"sshKeys",
|
||||||
|
"gitProviders",
|
||||||
|
"traefikFiles",
|
||||||
|
"api",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ENTERPRISE_RESOURCES = [
|
||||||
|
"volume",
|
||||||
|
"deployment",
|
||||||
|
"envVars",
|
||||||
|
"projectEnvVars",
|
||||||
|
"environmentEnvVars",
|
||||||
|
"server",
|
||||||
|
"registry",
|
||||||
|
"certificate",
|
||||||
|
"backup",
|
||||||
|
"volumeBackup",
|
||||||
|
"schedule",
|
||||||
|
"domain",
|
||||||
|
"destination",
|
||||||
|
"notification",
|
||||||
|
"logs",
|
||||||
|
"monitoring",
|
||||||
|
"auditLog",
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("enterpriseOnlyResources set", () => {
|
||||||
|
it("contains all enterprise resources", () => {
|
||||||
|
for (const resource of ENTERPRISE_RESOURCES) {
|
||||||
|
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT contain free-tier resources", () => {
|
||||||
|
for (const resource of FREE_TIER_RESOURCES) {
|
||||||
|
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every resource in statements is either free or enterprise", () => {
|
||||||
|
const allResources = Object.keys(statements);
|
||||||
|
for (const resource of allResources) {
|
||||||
|
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
||||||
|
const isEnterprise = enterpriseOnlyResources.has(resource);
|
||||||
|
expect(isFree || isEnterprise).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("free and enterprise sets don't overlap", () => {
|
||||||
|
for (const resource of FREE_TIER_RESOURCES) {
|
||||||
|
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all statement resources are accounted for", () => {
|
||||||
|
const allResources = Object.keys(statements);
|
||||||
|
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
||||||
|
for (const resource of allResources) {
|
||||||
|
expect(categorized).toContain(resource);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
161
apps/dokploy/__test__/permissions/resolve-permissions.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockMemberData = (
|
||||||
|
role: string,
|
||||||
|
overrides: Record<string, boolean> = {},
|
||||||
|
) => ({
|
||||||
|
id: "member-1",
|
||||||
|
role,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
accessedProjects: [] as string[],
|
||||||
|
accessedServices: [] as string[],
|
||||||
|
accessedEnvironments: [] as string[],
|
||||||
|
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||||
|
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||||
|
canCreateServices: overrides.canCreateServices ?? false,
|
||||||
|
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||||
|
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||||
|
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||||
|
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||||
|
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||||
|
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||||
|
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||||
|
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||||
|
user: { id: "user-1", email: "test@test.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||||
|
mockMemberData("member");
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
member: {
|
||||||
|
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
organizationRole: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||||
|
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolvePermissions } = await import(
|
||||||
|
"@dokploy/server/services/permission"
|
||||||
|
);
|
||||||
|
const { enterpriseOnlyResources, statements } = await import(
|
||||||
|
"@dokploy/server/lib/access-control"
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("enterprise resources for static roles", () => {
|
||||||
|
it("owner gets true for all enterprise resources", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
|
||||||
|
for (const resource of enterpriseOnlyResources) {
|
||||||
|
const actions = statements[resource as keyof typeof statements];
|
||||||
|
for (const action of actions) {
|
||||||
|
expect((perms as any)[resource][action]).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin gets true for all enterprise resources", async () => {
|
||||||
|
memberToReturn = mockMemberData("admin");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
|
||||||
|
for (const resource of enterpriseOnlyResources) {
|
||||||
|
const actions = statements[resource as keyof typeof statements];
|
||||||
|
for (const action of actions) {
|
||||||
|
expect((perms as any)[resource][action]).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets true for service-level enterprise resources", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
|
||||||
|
expect(perms.deployment.read).toBe(true);
|
||||||
|
expect(perms.deployment.create).toBe(true);
|
||||||
|
expect(perms.domain.read).toBe(true);
|
||||||
|
expect(perms.backup.read).toBe(true);
|
||||||
|
expect(perms.logs.read).toBe(true);
|
||||||
|
expect(perms.monitoring.read).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets false for org-level enterprise resources", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
|
||||||
|
expect(perms.server.read).toBe(false);
|
||||||
|
expect(perms.registry.read).toBe(false);
|
||||||
|
expect(perms.certificate.read).toBe(false);
|
||||||
|
expect(perms.destination.read).toBe(false);
|
||||||
|
expect(perms.notification.read).toBe(false);
|
||||||
|
expect(perms.auditLog.read).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("free-tier resources for member", () => {
|
||||||
|
it("member gets service.read=true", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.service.read).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets project.create=false without legacy override", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.project.create).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets project.create=true with canCreateProjects", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.project.create).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets docker.read=false without legacy override", async () => {
|
||||||
|
memberToReturn = mockMemberData("member");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.docker.read).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member gets docker.read=true with canAccessToDocker", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.docker.read).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("free-tier resources for owner", () => {
|
||||||
|
it("owner gets all free-tier permissions as true", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner");
|
||||||
|
const perms = await resolvePermissions(ctx);
|
||||||
|
expect(perms.project.create).toBe(true);
|
||||||
|
expect(perms.project.delete).toBe(true);
|
||||||
|
expect(perms.service.create).toBe(true);
|
||||||
|
expect(perms.service.read).toBe(true);
|
||||||
|
expect(perms.service.delete).toBe(true);
|
||||||
|
expect(perms.docker.read).toBe(true);
|
||||||
|
expect(perms.traefikFiles.read).toBe(true);
|
||||||
|
expect(perms.traefikFiles.write).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
132
apps/dokploy/__test__/permissions/service-access.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mockMemberData = (
|
||||||
|
role: string,
|
||||||
|
accessedServices: string[] = [],
|
||||||
|
accessedProjects: string[] = [],
|
||||||
|
) => ({
|
||||||
|
id: "member-1",
|
||||||
|
role,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
accessedProjects,
|
||||||
|
accessedServices,
|
||||||
|
accessedEnvironments: [] as string[],
|
||||||
|
canCreateProjects: false,
|
||||||
|
canDeleteProjects: false,
|
||||||
|
canCreateServices: false,
|
||||||
|
canDeleteServices: false,
|
||||||
|
canCreateEnvironments: false,
|
||||||
|
canDeleteEnvironments: false,
|
||||||
|
canAccessToTraefikFiles: false,
|
||||||
|
canAccessToDocker: false,
|
||||||
|
canAccessToAPI: false,
|
||||||
|
canAccessToSSHKeys: false,
|
||||||
|
canAccessToGitProviders: false,
|
||||||
|
user: { id: "user-1", email: "test@test.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||||
|
mockMemberData("member");
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
member: {
|
||||||
|
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
organizationRole: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(() => Promise.resolve([])),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||||
|
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
||||||
|
"@dokploy/server/services/permission"
|
||||||
|
);
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
user: { id: "user-1" },
|
||||||
|
session: { activeOrganizationId: "org-1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkServicePermissionAndAccess", () => {
|
||||||
|
it("owner bypasses accessedServices check", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner", []);
|
||||||
|
await expect(
|
||||||
|
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||||
|
deployment: ["read"],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin bypasses accessedServices check", async () => {
|
||||||
|
memberToReturn = mockMemberData("admin", []);
|
||||||
|
await expect(
|
||||||
|
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||||
|
backup: ["create"],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member with access to service passes", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", ["service-123"]);
|
||||||
|
await expect(
|
||||||
|
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||||
|
deployment: ["read"],
|
||||||
|
}),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member WITHOUT access to service fails", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", ["other-service"]);
|
||||||
|
await expect(
|
||||||
|
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||||
|
deployment: ["read"],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("You don't have access to this service");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member with empty accessedServices fails", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", []);
|
||||||
|
await expect(
|
||||||
|
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||||
|
domain: ["delete"],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("You don't have access to this service");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("checkServiceAccess", () => {
|
||||||
|
it("member with service access passes read check", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", ["app-1"]);
|
||||||
|
await expect(
|
||||||
|
checkServiceAccess(ctx, "app-1", "read"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("member without service access fails read check", async () => {
|
||||||
|
memberToReturn = mockMemberData("member", []);
|
||||||
|
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
||||||
|
"You don't have access to this service",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("owner bypasses all access checks", async () => {
|
||||||
|
memberToReturn = mockMemberData("owner", [], []);
|
||||||
|
await expect(
|
||||||
|
checkServiceAccess(ctx, "project-1", "create"),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,11 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
chain.where = () => chain;
|
chain.where = () => chain;
|
||||||
chain.values = () => chain;
|
chain.values = () => chain;
|
||||||
chain.returning = () => Promise.resolve([{}]);
|
chain.returning = () => Promise.resolve([{}]);
|
||||||
chain.then = undefined;
|
chain.from = () => chain;
|
||||||
|
chain.innerJoin = () => chain;
|
||||||
|
chain.then = (resolve: (value: unknown) => void) => {
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
|
||||||
const tableMock = {
|
const tableMock = {
|
||||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||||
@@ -21,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
|
|||||||
update: vi.fn(() => chain),
|
update: vi.fn(() => chain),
|
||||||
delete: vi.fn(() => chain),
|
delete: vi.fn(() => chain),
|
||||||
};
|
};
|
||||||
const createQueryMock = () => tableMock;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
db: {
|
db: {
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
|||||||
urlCallback: "",
|
urlCallback: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
whitelabelingConfig: {
|
||||||
|
appName: null,
|
||||||
|
appDescription: null,
|
||||||
|
logoUrl: null,
|
||||||
|
faviconUrl: null,
|
||||||
|
customCss: null,
|
||||||
|
loginLogoUrl: null,
|
||||||
|
supportUrl: null,
|
||||||
|
docsUrl: null,
|
||||||
|
errorPageTitle: null,
|
||||||
|
errorPageDescription: null,
|
||||||
|
metaTitle: null,
|
||||||
|
footerText: null,
|
||||||
|
},
|
||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
|
|||||||
@@ -275,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
|||||||
|
|
||||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** IDN/Punycode */
|
||||||
|
|
||||||
|
test("Internationalized domain name is converted to punycode", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, host: "тест.рф" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
||||||
|
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
||||||
|
expect(router.rule).not.toContain("тест.рф");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ASCII domain remains unchanged", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, host: "example.com" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(router.rule).toContain("Host(`example.com`)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, host: "сайт.ru" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// сайт in punycode is xn--80aswg
|
||||||
|
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
||||||
|
expect(router.rule).not.toContain("сайт");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
||||||
|
const router = await createRouterConfig(
|
||||||
|
baseApp,
|
||||||
|
{ ...baseDomain, host: "app.тест.рф" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
|
||||||
|
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
||||||
|
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
||||||
|
expect(router.rule).not.toContain("тест.рф");
|
||||||
|
});
|
||||||
|
|||||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const BASE = "/base";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import("@dokploy/server/constants")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
paths: () => ({
|
||||||
|
...actual.paths(),
|
||||||
|
BASE_PATH: BASE,
|
||||||
|
LOGS_PATH: `${BASE}/logs`,
|
||||||
|
APPLICATIONS_PATH: `${BASE}/applications`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import after mock so paths() uses our BASE
|
||||||
|
const { readValidDirectory } = await import("@dokploy/server");
|
||||||
|
|
||||||
|
describe("readValidDirectory (path traversal)", () => {
|
||||||
|
it("returns true when directory is exactly BASE_PATH", () => {
|
||||||
|
expect(readValidDirectory(BASE)).toBe(true);
|
||||||
|
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when directory is under BASE_PATH", () => {
|
||||||
|
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
|
||||||
|
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
|
||||||
|
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for path traversal escaping base (absolute)", () => {
|
||||||
|
expect(readValidDirectory("/etc/passwd")).toBe(false);
|
||||||
|
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
|
||||||
|
expect(readValidDirectory("/tmp/outside")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when resolved path escapes base via ..", () => {
|
||||||
|
// Resolved: /etc/passwd (outside /base)
|
||||||
|
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
|
||||||
|
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
|
||||||
|
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when .. stays within base", () => {
|
||||||
|
// e.g. /base/logs/../applications -> /base/applications (still under /base)
|
||||||
|
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
|
||||||
|
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts serverId for remote base path", () => {
|
||||||
|
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
|
||||||
|
expect(readValidDirectory(BASE, "server-1")).toBe(true);
|
||||||
|
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null/undefined-like paths that resolve outside", () => {
|
||||||
|
// Paths that might resolve to cwd or root
|
||||||
|
expect(readValidDirectory(".")).toBe(false);
|
||||||
|
expect(readValidDirectory("..")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
|
||||||
|
expect(readValidDirectory(`${BASE}/`)).toBe(true);
|
||||||
|
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
|
||||||
|
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when path looks like base but is a sibling or prefix", () => {
|
||||||
|
expect(readValidDirectory("/base-evil")).toBe(false);
|
||||||
|
expect(readValidDirectory("/bas")).toBe(false);
|
||||||
|
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
|
expect(readValidDirectory("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isValidContainerId,
|
||||||
|
isValidSearch,
|
||||||
|
isValidSince,
|
||||||
|
isValidTail,
|
||||||
|
} from "../../server/wss/utils";
|
||||||
|
|
||||||
|
describe("isValidTail (docker-container-logs)", () => {
|
||||||
|
it("accepts valid numeric tail values", () => {
|
||||||
|
expect(isValidTail("0")).toBe(true);
|
||||||
|
expect(isValidTail("1")).toBe(true);
|
||||||
|
expect(isValidTail("100")).toBe(true);
|
||||||
|
expect(isValidTail("10000")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects tail above 10000", () => {
|
||||||
|
expect(isValidTail("10001")).toBe(false);
|
||||||
|
expect(isValidTail("99999")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-numeric tail", () => {
|
||||||
|
expect(isValidTail("")).toBe(false);
|
||||||
|
expect(isValidTail("abc")).toBe(false);
|
||||||
|
expect(isValidTail("10a")).toBe(false);
|
||||||
|
expect(isValidTail("-1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects command injection payloads in tail", () => {
|
||||||
|
expect(isValidTail("10; whoami; #")).toBe(false);
|
||||||
|
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
|
||||||
|
expect(isValidTail("$(id)")).toBe(false);
|
||||||
|
expect(isValidTail("`id`")).toBe(false);
|
||||||
|
expect(isValidTail("100\nid")).toBe(false);
|
||||||
|
expect(isValidTail("100 && id")).toBe(false);
|
||||||
|
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidSince (docker-container-logs)", () => {
|
||||||
|
it("accepts 'all'", () => {
|
||||||
|
expect(isValidSince("all")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid duration format (number + s|m|h|d)", () => {
|
||||||
|
expect(isValidSince("5s")).toBe(true);
|
||||||
|
expect(isValidSince("10m")).toBe(true);
|
||||||
|
expect(isValidSince("1h")).toBe(true);
|
||||||
|
expect(isValidSince("2d")).toBe(true);
|
||||||
|
expect(isValidSince("0s")).toBe(true);
|
||||||
|
expect(isValidSince("999d")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid duration format", () => {
|
||||||
|
expect(isValidSince("")).toBe(false);
|
||||||
|
expect(isValidSince("5")).toBe(false);
|
||||||
|
expect(isValidSince("s")).toBe(false);
|
||||||
|
expect(isValidSince("5x")).toBe(false);
|
||||||
|
expect(isValidSince("5sec")).toBe(false);
|
||||||
|
expect(isValidSince("5 m")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects command injection payloads in since", () => {
|
||||||
|
expect(isValidSince("5s; whoami")).toBe(false);
|
||||||
|
expect(isValidSince("all; id")).toBe(false);
|
||||||
|
expect(isValidSince("1m$(id)")).toBe(false);
|
||||||
|
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidSearch (docker-container-logs)", () => {
|
||||||
|
it("accepts empty string", () => {
|
||||||
|
expect(isValidSearch("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
|
||||||
|
expect(isValidSearch("error")).toBe(true);
|
||||||
|
expect(isValidSearch("foo bar")).toBe(true);
|
||||||
|
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
|
||||||
|
expect(isValidSearch("")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects strings longer than 500 chars", () => {
|
||||||
|
expect(isValidSearch("a".repeat(501))).toBe(false);
|
||||||
|
expect(isValidSearch("a".repeat(500))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects control characters and non-printable", () => {
|
||||||
|
expect(isValidSearch("foo\nbar")).toBe(false);
|
||||||
|
expect(isValidSearch("foo\rbar")).toBe(false);
|
||||||
|
expect(isValidSearch("\x00")).toBe(false);
|
||||||
|
expect(isValidSearch("a\x19b")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
|
||||||
|
// Double-quoted context (SSH line 99): $ and ` execute
|
||||||
|
expect(isValidSearch("$(whoami)")).toBe(false);
|
||||||
|
expect(isValidSearch("`id`")).toBe(false);
|
||||||
|
expect(isValidSearch("$(id)")).toBe(false);
|
||||||
|
// Single-quoted context (local line 153): ' breaks out
|
||||||
|
expect(isValidSearch("'$(whoami)'")).toBe(false);
|
||||||
|
expect(isValidSearch("error'")).toBe(false);
|
||||||
|
expect(isValidSearch("'; whoami; #")).toBe(false);
|
||||||
|
// Other shell-metacharacters
|
||||||
|
expect(isValidSearch("error; id")).toBe(false);
|
||||||
|
expect(isValidSearch("a|b")).toBe(false);
|
||||||
|
expect(isValidSearch('error"')).toBe(false);
|
||||||
|
expect(isValidSearch("a&b")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidContainerId (docker-container-logs)", () => {
|
||||||
|
it("accepts valid hex container IDs", () => {
|
||||||
|
expect(isValidContainerId("a".repeat(12))).toBe(true);
|
||||||
|
expect(isValidContainerId("abc123def456")).toBe(true);
|
||||||
|
expect(isValidContainerId("a".repeat(64))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts valid container names", () => {
|
||||||
|
expect(isValidContainerId("my-container")).toBe(true);
|
||||||
|
expect(isValidContainerId("app_1")).toBe(true);
|
||||||
|
expect(isValidContainerId("service.name")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects command injection in container ID", () => {
|
||||||
|
expect(isValidContainerId("dummy; whoami")).toBe(false);
|
||||||
|
expect(isValidContainerId("$(id)")).toBe(false);
|
||||||
|
expect(isValidContainerId("`id`")).toBe(false);
|
||||||
|
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
|
||||||
|
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
|
|||||||
|
|
||||||
const modeData =
|
const modeData =
|
||||||
formData.type === "Replicated"
|
formData.type === "Replicated"
|
||||||
? { Replicated: { Replicas: formData.Replicas } }
|
? {
|
||||||
|
Replicated: {
|
||||||
|
Replicas:
|
||||||
|
formData.Replicas !== undefined && formData.Replicas !== ""
|
||||||
|
? Number(formData.Replicas)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
: { Global: {} };
|
: { Global: {} };
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
|
||||||
api.compose.processTemplate.useMutation();
|
api.compose.processTemplate.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: importTemplate,
|
mutateAsync: importTemplate,
|
||||||
isLoading: isImporting,
|
isPending: isImporting,
|
||||||
isSuccess: isImportSuccess,
|
isSuccess: isImportSuccess,
|
||||||
} = api.compose.import.useMutation();
|
} = api.compose.import.useMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const AddPortSchema = z.object({
|
const AddPortSchema = z.object({
|
||||||
publishedPort: z.number().int().min(1).max(65535),
|
publishedPort: z.number().int().min(1).max(65535),
|
||||||
publishMode: z.enum(["ingress", "host"], {
|
publishMode: z.enum(["ingress", "host"]),
|
||||||
required_error: "Publish mode is required",
|
|
||||||
}),
|
|
||||||
targetPort: z.number().int().min(1).max(65535),
|
targetPort: z.number().int().min(1).max(65535),
|
||||||
protocol: z.enum(["tcp", "udp"], {
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
required_error: "Protocol is required",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddPort = z.infer<typeof AddPortSchema>;
|
type AddPort = z.infer<typeof AddPortSchema>;
|
||||||
@@ -68,7 +64,7 @@ export const HandlePorts = ({
|
|||||||
enabled: !!portId,
|
enabled: !!portId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { mutateAsync, isLoading, error, isError } = portId
|
const { mutateAsync, isPending, error, isError } = portId
|
||||||
? api.port.update.useMutation()
|
? api.port.update.useMutation()
|
||||||
: api.port.create.useMutation();
|
: api.port.create.useMutation();
|
||||||
|
|
||||||
@@ -270,7 +266,7 @@ export const HandlePorts = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-add-port"
|
form="hook-form-add-port"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deletePort, isLoading: isRemoving } =
|
const { mutateAsync: deletePort, isPending: isRemoving } =
|
||||||
api.port.delete.useMutation();
|
api.port.delete.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -100,11 +100,11 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } = redirectId
|
const { mutateAsync, isPending, error, isError } = redirectId
|
||||||
? api.redirects.update.useMutation()
|
? api.redirects.update.useMutation()
|
||||||
: api.redirects.create.useMutation();
|
: api.redirects.create.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddRedirect>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
permanent: false,
|
permanent: false,
|
||||||
regex: "",
|
regex: "",
|
||||||
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-add-redirect"
|
form="hook-form-add-redirect"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
|
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
|
||||||
api.redirects.delete.useMutation();
|
api.redirects.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data } = api.security.one.useQuery(
|
const { data, refetch } = api.security.one.useQuery(
|
||||||
{
|
{
|
||||||
securityId: securityId ?? "",
|
securityId: securityId ?? "",
|
||||||
},
|
},
|
||||||
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } = securityId
|
const { mutateAsync, isPending, error, isError } = securityId
|
||||||
? api.security.update.useMutation()
|
? api.security.update.useMutation()
|
||||||
: api.security.create.useMutation();
|
: api.security.create.useMutation();
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
|
|||||||
await utils.application.readTraefikConfig.invalidate({
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
applicationId,
|
applicationId,
|
||||||
});
|
});
|
||||||
|
await refetch();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-add-security"
|
form="hook-form-add-security"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
|
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
|
||||||
api.security.delete.useMutation();
|
api.security.delete.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Server } from "lucide-react";
|
import { Server } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isPending} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
@@ -128,11 +128,11 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddResources>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
cpuLimit: "",
|
cpuLimit: "",
|
||||||
cpuReservation: "",
|
cpuReservation: "",
|
||||||
@@ -452,6 +452,11 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
min={-1}
|
min={-1}
|
||||||
placeholder="65535"
|
placeholder="65535"
|
||||||
{...field}
|
{...field}
|
||||||
|
value={
|
||||||
|
typeof field.value === "number"
|
||||||
|
? field.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(Number(e.target.value))
|
field.onChange(Number(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -475,6 +480,11 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
min={-1}
|
min={-1}
|
||||||
placeholder="65535"
|
placeholder="65535"
|
||||||
{...field}
|
{...field}
|
||||||
|
value={
|
||||||
|
typeof field.value === "number"
|
||||||
|
? field.value
|
||||||
|
: ""
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(Number(e.target.value))
|
field.onChange(Number(e.target.value))
|
||||||
}
|
}
|
||||||
@@ -507,7 +517,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isPending} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,13 +15,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||||
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canRead = permissions?.traefikFiles.read ?? false;
|
||||||
|
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
},
|
},
|
||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId && canRead },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!canRead) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between">
|
<CardHeader className="flex flex-row justify-between">
|
||||||
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{isLoading ? (
|
{isPending ? (
|
||||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
||||||
Loading...
|
Loading...
|
||||||
<Loader2 className="animate-spin" />
|
<Loader2 className="animate-spin" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
@@ -60,6 +60,8 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canWrite = permissions?.traefikFiles.write ?? false;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||||
@@ -69,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.application.updateTraefikConfig.useMutation();
|
api.application.updateTraefikConfig.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateTraefikConfig>({
|
const form = useForm<UpdateTraefikConfig>({
|
||||||
@@ -125,9 +127,11 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger asChild>
|
{canWrite && (
|
||||||
<Button isLoading={isLoading}>Modify</Button>
|
<DialogTrigger asChild>
|
||||||
</DialogTrigger>
|
<Button isLoading={isPending}>Modify</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update traefik config</DialogTitle>
|
<DialogTitle>Update traefik config</DialogTitle>
|
||||||
@@ -198,7 +202,7 @@ routers:
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-update-traefik-config"
|
form="hook-form-update-traefik-config"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowVolumes = ({ id, type }: Props) => {
|
export const ShowVolumes = ({ id, type }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canRead = permissions?.volume.read ?? false;
|
||||||
|
const canCreate = permissions?.volume.create ?? false;
|
||||||
|
const canDelete = permissions?.volume.delete ?? false;
|
||||||
|
|
||||||
|
if (!canRead) return null;
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
@@ -37,7 +44,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
const { data, refetch } = queryMap[type]
|
const { data, refetch } = queryMap[type]
|
||||||
? queryMap[type]()
|
? queryMap[type]()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
|
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||||
api.mounts.remove.useMutation();
|
api.mounts.remove.useMutation();
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -50,7 +57,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data && data?.mounts.length > 0 && (
|
{canCreate && data && data?.mounts.length > 0 && (
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
Add Volume
|
Add Volume
|
||||||
</AddVolumes>
|
</AddVolumes>
|
||||||
@@ -63,9 +70,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No volumes/mounts configured
|
No volumes/mounts configured
|
||||||
</span>
|
</span>
|
||||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
{canCreate && (
|
||||||
Add Volume
|
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||||
</AddVolumes>
|
Add Volume
|
||||||
|
</AddVolumes>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2 gap-4">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
@@ -130,38 +139,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-1">
|
<div className="flex flex-row gap-1">
|
||||||
<UpdateVolume
|
{canCreate && (
|
||||||
mountId={mount.mountId}
|
<UpdateVolume
|
||||||
type={mount.type}
|
mountId={mount.mountId}
|
||||||
refetch={refetch}
|
type={mount.type}
|
||||||
serviceType={type}
|
refetch={refetch}
|
||||||
/>
|
serviceType={type}
|
||||||
<DialogAction
|
/>
|
||||||
title="Delete Volume"
|
)}
|
||||||
description="Are you sure you want to delete this volume?"
|
{canDelete && (
|
||||||
type="destructive"
|
<DialogAction
|
||||||
onClick={async () => {
|
title="Delete Volume"
|
||||||
await deleteVolume({
|
description="Are you sure you want to delete this volume?"
|
||||||
mountId: mount.mountId,
|
type="destructive"
|
||||||
})
|
onClick={async () => {
|
||||||
.then(() => {
|
await deleteVolume({
|
||||||
refetch();
|
mountId: mount.mountId,
|
||||||
toast.success("Volume deleted successfully");
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deleting volume");
|
refetch();
|
||||||
});
|
toast.success("Volume deleted successfully");
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error deleting volume");
|
||||||
variant="ghost"
|
});
|
||||||
size="icon"
|
}}
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isPending, error, isError } =
|
||||||
api.mounts.update.useMutation();
|
api.mounts.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<UpdateMount>({
|
const form = useForm<UpdateMount>({
|
||||||
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-blue-500/10 "
|
className="group hover:bg-blue-500/10 "
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
>
|
>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -310,7 +310,7 @@ PORT=3000
|
|||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
// form="hook-form-update-volume"
|
// form="hook-form-update-volume"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
|
|||||||
const mySchema = z.discriminatedUnion("buildType", [
|
const mySchema = z.discriminatedUnion("buildType", [
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.dockerfile),
|
buildType: z.literal(BuildType.dockerfile),
|
||||||
dockerfile: z
|
dockerfile: z.string().nullable().default(""),
|
||||||
.string({
|
|
||||||
required_error: "Dockerfile path is required",
|
|
||||||
invalid_type_error: "Dockerfile path is required",
|
|
||||||
})
|
|
||||||
.min(1, "Dockerfile required"),
|
|
||||||
dockerContextPath: z.string().nullable().default(""),
|
dockerContextPath: z.string().nullable().default(""),
|
||||||
dockerBuildStage: z.string().nullable().default(""),
|
dockerBuildStage: z.string().nullable().default(""),
|
||||||
}),
|
}),
|
||||||
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.saveBuildType.useMutation();
|
api.application.saveBuildType.useMutation();
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{ applicationId },
|
{ applicationId },
|
||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<AddTemplate>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildType: BuildType.nixpacks,
|
buildType: BuildType.nixpacks,
|
||||||
},
|
},
|
||||||
@@ -347,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Docker File</FormLabel>
|
<FormLabel>Docker File</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Path of your docker file"
|
placeholder="Path of your docker file (default: Dockerfile)"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
@@ -533,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isPending} type="submit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Paintbrush } from "lucide-react";
|
import { Ban } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CancelQueues = ({ id, type }: Props) => {
|
export const CancelQueues = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.cleanQueues.useMutation()
|
? api.application.cleanQueues.useMutation()
|
||||||
: api.compose.cleanQueues.useMutation();
|
: api.compose.cleanQueues.useMutation();
|
||||||
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
<Button variant="destructive" className="w-fit" isLoading={isPending}>
|
||||||
Cancel Queues
|
Cancel Queues
|
||||||
<Paintbrush className="size-4" />
|
<Ban className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Paintbrush } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClearDeployments = ({ id, type }: Props) => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutateAsync, isPending } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.clearDeployments.useMutation()
|
||||||
|
: api.compose.clearDeployments.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||||
|
Clear deployments
|
||||||
|
<Paintbrush className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Are you sure you want to clear old deployments?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will delete all old deployment records and logs, keeping only
|
||||||
|
the active deployment (the most recent successful one).
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Old deployments cleared successfully");
|
||||||
|
await utils.deployment.allByType.invalidate({
|
||||||
|
id,
|
||||||
|
type: type as "application" | "compose",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -20,7 +20,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const KillBuild = ({ id, type }: Props) => {
|
export const KillBuild = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.killBuild.useMutation()
|
? api.application.killBuild.useMutation()
|
||||||
: api.compose.killBuild.useMutation();
|
: api.compose.killBuild.useMutation();
|
||||||
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||||
Kill Build
|
Kill Build
|
||||||
<Scissors className="size-4" />
|
<Scissors className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -194,13 +194,21 @@ export const ShowDeployment = ({
|
|||||||
{" "}
|
{" "}
|
||||||
{filteredLogs.length > 0 ? (
|
{filteredLogs.length > 0 ? (
|
||||||
filteredLogs.map((log: LogLine, index: number) => (
|
filteredLogs.map((log: LogLine, index: number) => (
|
||||||
<TerminalLine key={index} log={log} noTimestamp />
|
<TerminalLine
|
||||||
|
key={`${log.rawTimestamp ?? ""}-${index}`}
|
||||||
|
log={log}
|
||||||
|
noTimestamp
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{optionalErrors.length > 0 ? (
|
{optionalErrors.length > 0 ? (
|
||||||
optionalErrors.map((log: LogLine, index: number) => (
|
optionalErrors.map((log: LogLine, index: number) => (
|
||||||
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
<TerminalLine
|
||||||
|
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
|
||||||
|
log={log}
|
||||||
|
noTimestamp
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Clock,
|
Clock,
|
||||||
|
Copy,
|
||||||
Loader2,
|
Loader2,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Settings,
|
Settings,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
@@ -25,6 +28,7 @@ import {
|
|||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
|
import { ClearDeployments } from "./clear-deployments";
|
||||||
import { KillBuild } from "./kill-build";
|
import { KillBuild } from "./kill-build";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
@@ -59,7 +63,7 @@ export const ShowDeployments = ({
|
|||||||
const [activeLog, setActiveLog] = useState<
|
const [activeLog, setActiveLog] = useState<
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
>(null);
|
>(null);
|
||||||
const { data: deployments, isLoading: isLoadingDeployments } =
|
const { data: deployments, isPending: isLoadingDeployments } =
|
||||||
api.deployment.allByType.useQuery(
|
api.deployment.allByType.useQuery(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -73,19 +77,21 @@ export const ShowDeployments = ({
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
const { mutateAsync: rollback, isPending: isRollingBack } =
|
||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||||
api.deployment.killProcess.useMutation();
|
api.deployment.killProcess.useMutation();
|
||||||
|
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||||
|
api.deployment.removeDeployment.useMutation();
|
||||||
|
|
||||||
// Cancel deployment mutations
|
// Cancel deployment mutations
|
||||||
const {
|
const {
|
||||||
mutateAsync: cancelApplicationDeployment,
|
mutateAsync: cancelApplicationDeployment,
|
||||||
isLoading: isCancellingApp,
|
isPending: isCancellingApp,
|
||||||
} = api.application.cancelDeployment.useMutation();
|
} = api.application.cancelDeployment.useMutation();
|
||||||
const {
|
const {
|
||||||
mutateAsync: cancelComposeDeployment,
|
mutateAsync: cancelComposeDeployment,
|
||||||
isLoading: isCancellingCompose,
|
isPending: isCancellingCompose,
|
||||||
} = api.compose.cancelDeployment.useMutation();
|
} = api.compose.cancelDeployment.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
@@ -93,6 +99,12 @@ export const ShowDeployments = ({
|
|||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const webhookUrl = useMemo(
|
||||||
|
() =>
|
||||||
|
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||||
|
[url, refreshToken, type],
|
||||||
|
);
|
||||||
|
|
||||||
const MAX_DESCRIPTION_LENGTH = 200;
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
const truncateDescription = (description: string): string => {
|
const truncateDescription = (description: string): string => {
|
||||||
@@ -144,6 +156,9 @@ export const ShowDeployments = ({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<ClearDeployments id={id} type={type} />
|
||||||
|
)}
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -217,11 +232,27 @@ export const ShowDeployments = ({
|
|||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<span className="break-all text-muted-foreground">
|
<Badge
|
||||||
{`${url}/api/deploy${
|
role="button"
|
||||||
type === "compose" ? "/compose" : ""
|
tabIndex={0}
|
||||||
}/${refreshToken}`}
|
aria-label="Copy webhook URL to clipboard"
|
||||||
</span>
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||||
|
variant="outline"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
copy(webhookUrl);
|
||||||
|
toast.success("Copied to clipboard.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
copy(webhookUrl);
|
||||||
|
toast.success("Copied to clipboard.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{webhookUrl}
|
||||||
|
<Copy className="h-4 w-4 ml-2" />
|
||||||
|
</Badge>
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<RefreshToken id={id} type={type} />
|
<RefreshToken id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -252,6 +283,8 @@ export const ShowDeployments = ({
|
|||||||
const isExpanded = expandedDescriptions.has(
|
const isExpanded = expandedDescriptions.has(
|
||||||
deployment.deploymentId,
|
deployment.deploymentId,
|
||||||
);
|
);
|
||||||
|
const canDelete =
|
||||||
|
deployment.status === "done" || deployment.status === "error";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -370,6 +403,33 @@ export const ShowDeployments = ({
|
|||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Deployment"
|
||||||
|
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await removeDeployment({
|
||||||
|
deploymentId: deployment.deploymentId,
|
||||||
|
});
|
||||||
|
toast.success("Deployment deleted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting deployment");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isRemovingDeployment}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
|
||||||
{deployment?.rollback &&
|
{deployment?.rollback &&
|
||||||
deployment.status === "done" &&
|
deployment.status === "done" &&
|
||||||
type === "application" && (
|
type === "application" && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -159,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isPending } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
: api.domain.create.useMutation();
|
: api.domain.create.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
const { data: canGenerateTraefikMeDomains } =
|
||||||
@@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
domainType: type,
|
domainType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isPending, domainId]);
|
||||||
|
|
||||||
// Separate effect for handling custom cert resolver validation
|
// Separate effect for handling custom cert resolver validation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||||
{dictionary.submit}
|
{dictionary.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ id, type }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canCreateDomain = permissions?.domain.create ?? false;
|
||||||
|
const canDeleteDomain = permissions?.domain.delete ?? false;
|
||||||
const { data: application } =
|
const { data: application } =
|
||||||
type === "application"
|
type === "application"
|
||||||
? api.application.one.useQuery(
|
? api.application.one.useQuery(
|
||||||
@@ -97,7 +100,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync: validateDomain } =
|
const { mutateAsync: validateDomain } =
|
||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
const handleValidateDomain = async (host: string) => {
|
const handleValidateDomain = async (host: string) => {
|
||||||
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{canCreateDomain && data && data?.length > 0 && (
|
||||||
<AddDomain id={id} type={type}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
@@ -173,13 +176,15 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
{canCreateDomain && (
|
||||||
<AddDomain id={id} type={type}>
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<Button>
|
<AddDomain id={id} type={type}>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<Button>
|
||||||
</Button>
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</AddDomain>
|
</Button>
|
||||||
</div>
|
</AddDomain>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AddDomain
|
{canCreateDomain && (
|
||||||
id={id}
|
<AddDomain
|
||||||
type={type}
|
id={id}
|
||||||
domainId={item.domainId}
|
type={type}
|
||||||
>
|
domainId={item.domainId}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10"
|
|
||||||
>
|
>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</AddDomain>
|
size="icon"
|
||||||
<DialogAction
|
className="group hover:bg-blue-500/10"
|
||||||
title="Delete Domain"
|
>
|
||||||
description="Are you sure you want to delete this domain?"
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
type="destructive"
|
</Button>
|
||||||
onClick={async () => {
|
</AddDomain>
|
||||||
await deleteDomain({
|
)}
|
||||||
domainId: item.domainId,
|
{canDeleteDomain && (
|
||||||
})
|
<DialogAction
|
||||||
.then((_data) => {
|
title="Delete Domain"
|
||||||
refetch();
|
description="Are you sure you want to delete this domain?"
|
||||||
toast.success(
|
type="destructive"
|
||||||
"Domain deleted successfully",
|
onClick={async () => {
|
||||||
);
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then((_data) => {
|
||||||
toast.error("Error deleting domain");
|
refetch();
|
||||||
});
|
toast.success(
|
||||||
}}
|
"Domain deleted successfully",
|
||||||
>
|
);
|
||||||
<Button
|
})
|
||||||
variant="ghost"
|
.catch(() => {
|
||||||
size="icon"
|
toast.error("Error deleting domain");
|
||||||
className="group hover:bg-red-500/10"
|
});
|
||||||
isLoading={isRemoving}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="w-full break-all">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||||
import { type CSSProperties, useEffect, useState } from "react";
|
import { type CSSProperties, useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -36,6 +36,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canWrite = permissions?.envVars.write ?? false;
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
@@ -60,7 +62,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
mongo: () => api.mongo.update.useMutation(),
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
compose: () => api.compose.update.useMutation(),
|
compose: () => api.compose.update.useMutation(),
|
||||||
};
|
};
|
||||||
const { mutateAsync, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.update.useMutation();
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
@@ -111,7 +113,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -121,7 +123,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [form, onSubmit, isLoading]);
|
}, [form, onSubmit, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
@@ -185,25 +187,27 @@ PORT=3000
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{canWrite && (
|
||||||
{hasChanges && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
|
{hasChanges && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
isLoading={isPending}
|
||||||
variant="outline"
|
className="w-fit"
|
||||||
onClick={handleCancel}
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
>
|
>
|
||||||
Cancel
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
)}
|
||||||
isLoading={isLoading}
|
|
||||||
className="w-fit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -31,7 +31,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canWrite = permissions?.envVars.write ?? false;
|
||||||
|
const { mutateAsync, isPending } =
|
||||||
api.application.saveEnvironment.useMutation();
|
api.application.saveEnvironment.useMutation();
|
||||||
|
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [form, onSubmit, isLoading]);
|
}, [form, onSubmit, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background px-6 pb-6">
|
<Card className="bg-background px-6 pb-6">
|
||||||
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={!canWrite}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
{canWrite && (
|
||||||
{hasChanges && (
|
<div className="flex flex-row justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
{hasChanges && (
|
||||||
Cancel
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
isLoading={isPending}
|
||||||
|
className="w-fit"
|
||||||
|
type="submit"
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
)}
|
||||||
isLoading={isLoading}
|
|
||||||
className="w-fit"
|
|
||||||
type="submit"
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
|
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
||||||
api.application.saveBitbucketProvider.useMutation();
|
api.application.saveBitbucketProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<BitbucketProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -333,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -350,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -416,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -24,10 +24,10 @@ interface Props {
|
|||||||
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.dropDeployment.useMutation();
|
api.application.dropDeployment.useMutation();
|
||||||
|
|
||||||
const form = useForm<UploadFile>({
|
const form = useForm({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(uploadFileSchema),
|
resolver: zodResolver(uploadFileSchema),
|
||||||
});
|
});
|
||||||
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
disabled={!zip || isLoading}
|
disabled={!zip || isPending}
|
||||||
>
|
>
|
||||||
Deploy{" "}
|
Deploy{" "}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isPending } =
|
||||||
api.application.saveGitProvider.useMutation();
|
api.application.saveGitProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
branch: "",
|
branch: "",
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[300px]">
|
<TooltipContent className="max-w-[300px]">
|
||||||
<p>
|
<p>
|
||||||
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
<Button type="submit" className="w-fit" isLoading={isPending}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
||||||
api.application.saveGiteaProvider.useMutation();
|
api.application.saveGiteaProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GiteaProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -353,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -371,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -463,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<X
|
<X
|
||||||
className="size-3 cursor-pointer hover:text-destructive"
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPaths = [...field.value];
|
const newPaths = [...(field.value || [])];
|
||||||
newPaths.splice(index, 1);
|
newPaths.splice(index, 1);
|
||||||
field.onChange(newPaths);
|
field.onChange(newPaths);
|
||||||
}}
|
}}
|
||||||
@@ -481,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const path = input.value.trim();
|
const path = input.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
field.onChange([...field.value, path]);
|
field.onChange([...(field.value || []), path]);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const path = input.value.trim();
|
const path = input.value.trim();
|
||||||
if (path) {
|
if (path) {
|
||||||
field.onChange([...field.value, path]);
|
field.onChange([...(field.value || []), path]);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
const { mutateAsync, isPending: isSavingGithubProvider } =
|
||||||
api.application.saveGithubProvider.useMutation();
|
api.application.saveGithubProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GithubProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
const triggerType = form.watch("triggerType");
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isPending: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
githubId,
|
githubId,
|
||||||
@@ -320,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -337,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -459,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{field.value?.map((path, index) => (
|
{field.value?.map((path, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={`${path}-${index}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isSavingGitlabProvider } =
|
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
||||||
api.application.saveGitlabProvider.useMutation();
|
api.application.saveGitlabProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitlabProvider>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
repository: {
|
repository: {
|
||||||
@@ -351,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "loading" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? branches?.find(
|
||||||
@@ -368,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
placeholder="Search branch..."
|
placeholder="Search branch..."
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{status === "loading" && fetchStatus === "fetching" && (
|
{status === "pending" && fetchStatus === "fetching" && (
|
||||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
Loading Branches....
|
Loading Branches....
|
||||||
</span>
|
</span>
|
||||||
@@ -448,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
{field.value?.map((path, index) => (
|
{field.value?.map((path, index) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={index}
|
key={`${path}-${index}`}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
const { data: githubProviders, isPending: isLoadingGithub } =
|
||||||
api.github.githubProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
||||||
api.gitlab.gitlabProviders.useQuery();
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
const { data: giteaProviders, isPending: isLoadingGitea } =
|
||||||
api.gitea.giteaProviders.useQuery();
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
const { data: application, refetch } = api.application.one.useQuery({
|
const { data: application, refetch } = api.application.one.useQuery({
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ interface Props {
|
|||||||
|
|
||||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canDeploy = permissions?.deployment.create ?? false;
|
||||||
|
const canUpdateService = permissions?.service.create ?? false;
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
{ enabled: !!applicationId },
|
{ enabled: !!applicationId },
|
||||||
);
|
);
|
||||||
const { mutateAsync: update } = api.application.update.useMutation();
|
const { mutateAsync: update } = api.application.update.useMutation();
|
||||||
const { mutateAsync: start, isLoading: isStarting } =
|
const { mutateAsync: start, isPending: isStarting } =
|
||||||
api.application.start.useMutation();
|
api.application.start.useMutation();
|
||||||
const { mutateAsync: stop, isLoading: isStopping } =
|
const { mutateAsync: stop, isPending: isStopping } =
|
||||||
api.application.stop.useMutation();
|
api.application.stop.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: reload, isLoading: isReloading } =
|
const { mutateAsync: reload, isPending: isReloading } =
|
||||||
api.application.reload.useMutation();
|
api.application.reload.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
||||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
<DialogAction
|
{canDeploy && (
|
||||||
title="Deploy Application"
|
<DialogAction
|
||||||
description="Are you sure you want to deploy this application?"
|
title="Deploy Application"
|
||||||
type="default"
|
description="Are you sure you want to deploy this application?"
|
||||||
onClick={async () => {
|
type="default"
|
||||||
await deploy({
|
onClick={async () => {
|
||||||
applicationId: applicationId,
|
await deploy({
|
||||||
})
|
applicationId: applicationId,
|
||||||
.then(() => {
|
|
||||||
toast.success("Application deployed successfully");
|
|
||||||
refetch();
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error deploying application");
|
toast.success("Application deployed successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
router.push(
|
||||||
>
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||||
<Button
|
);
|
||||||
variant="default"
|
})
|
||||||
isLoading={data?.applicationStatus === "running"}
|
.catch(() => {
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
toast.error("Error deploying application");
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="default"
|
||||||
<div className="flex items-center">
|
isLoading={data?.applicationStatus === "running"}
|
||||||
<Rocket className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Deploy
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<Rocket className="size-4 mr-1" />
|
||||||
<p>
|
Deploy
|
||||||
Downloads the source code and performs a complete build
|
</div>
|
||||||
</p>
|
</TooltipTrigger>
|
||||||
</TooltipContent>
|
<TooltipPrimitive.Portal>
|
||||||
</TooltipPrimitive.Portal>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</Tooltip>
|
<p>
|
||||||
</Button>
|
Downloads the source code and performs a complete
|
||||||
</DialogAction>
|
build
|
||||||
<DialogAction
|
</p>
|
||||||
title="Reload Application"
|
</TooltipContent>
|
||||||
description="Are you sure you want to reload this application?"
|
</TooltipPrimitive.Portal>
|
||||||
type="default"
|
</Tooltip>
|
||||||
onClick={async () => {
|
</Button>
|
||||||
await reload({
|
</DialogAction>
|
||||||
applicationId: applicationId,
|
)}
|
||||||
appName: data?.appName || "",
|
{canDeploy && (
|
||||||
})
|
<DialogAction
|
||||||
.then(() => {
|
title="Reload Application"
|
||||||
toast.success("Application reloaded successfully");
|
description="Are you sure you want to reload this application?"
|
||||||
refetch();
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await reload({
|
||||||
|
applicationId: applicationId,
|
||||||
|
appName: data?.appName || "",
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error reloading application");
|
toast.success("Application reloaded successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error reloading application");
|
||||||
variant="secondary"
|
});
|
||||||
isLoading={isReloading}
|
}}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="secondary"
|
||||||
<div className="flex items-center">
|
isLoading={isReloading}
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Reload
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
<p>Reload the application without rebuilding it</p>
|
Reload
|
||||||
</TooltipContent>
|
</div>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipPrimitive.Portal>
|
||||||
</Button>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</DialogAction>
|
<p>Reload the application without rebuilding it</p>
|
||||||
<DialogAction
|
</TooltipContent>
|
||||||
title="Rebuild Application"
|
</TooltipPrimitive.Portal>
|
||||||
description="Are you sure you want to rebuild this application?"
|
</Tooltip>
|
||||||
type="default"
|
</Button>
|
||||||
onClick={async () => {
|
</DialogAction>
|
||||||
await redeploy({
|
)}
|
||||||
applicationId: applicationId,
|
{canDeploy && (
|
||||||
})
|
<DialogAction
|
||||||
.then(() => {
|
title="Rebuild Application"
|
||||||
toast.success("Application rebuilt successfully");
|
description="Are you sure you want to rebuild this application?"
|
||||||
refetch();
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeploy({
|
||||||
|
applicationId: applicationId,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(() => {
|
||||||
toast.error("Error rebuilding application");
|
toast.success("Application rebuilt successfully");
|
||||||
});
|
refetch();
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button
|
toast.error("Error rebuilding application");
|
||||||
variant="secondary"
|
});
|
||||||
isLoading={data?.applicationStatus === "running"}
|
}}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Button
|
||||||
<TooltipTrigger asChild>
|
variant="secondary"
|
||||||
<div className="flex items-center">
|
isLoading={data?.applicationStatus === "running"}
|
||||||
<Hammer className="size-4 mr-1" />
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
Rebuild
|
>
|
||||||
</div>
|
<Tooltip>
|
||||||
</TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<TooltipPrimitive.Portal>
|
<div className="flex items-center">
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<Hammer className="size-4 mr-1" />
|
||||||
<p>
|
Rebuild
|
||||||
Only rebuilds the application without downloading new
|
</div>
|
||||||
code
|
</TooltipTrigger>
|
||||||
</p>
|
<TooltipPrimitive.Portal>
|
||||||
</TooltipContent>
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
</TooltipPrimitive.Portal>
|
<p>
|
||||||
</Tooltip>
|
Only rebuilds the application without downloading new
|
||||||
</Button>
|
code
|
||||||
</DialogAction>
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
|
||||||
{data?.applicationStatus === "idle" ? (
|
{canDeploy && data?.applicationStatus === "idle" ? (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Start Application"
|
title="Start Application"
|
||||||
description="Are you sure you want to start this application?"
|
description="Are you sure you want to start this application?"
|
||||||
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : (
|
) : canDeploy ? (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Stop Application"
|
title="Stop Application"
|
||||||
description="Are you sure you want to stop this application?"
|
description="Are you sure you want to stop this application?"
|
||||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
) : null}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
{canUpdateService && (
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<Switch
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
aria-label="Toggle autodeploy"
|
<Switch
|
||||||
checked={data?.autoDeploy || false}
|
aria-label="Toggle autodeploy"
|
||||||
onCheckedChange={async (enabled) => {
|
checked={data?.autoDeploy || false}
|
||||||
await update({
|
onCheckedChange={async (enabled) => {
|
||||||
applicationId,
|
await update({
|
||||||
autoDeploy: enabled,
|
applicationId,
|
||||||
})
|
autoDeploy: enabled,
|
||||||
.then(async () => {
|
|
||||||
toast.success("Auto Deploy Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error updating Auto Deploy");
|
toast.success("Auto Deploy Updated");
|
||||||
});
|
await refetch();
|
||||||
}}
|
})
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
.catch(() => {
|
||||||
/>
|
toast.error("Error updating Auto Deploy");
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
{canUpdateService && (
|
||||||
<span className="text-sm font-medium">Clean Cache</span>
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<Switch
|
<span className="text-sm font-medium">Clean Cache</span>
|
||||||
aria-label="Toggle clean cache"
|
<Switch
|
||||||
checked={data?.cleanCache || false}
|
aria-label="Toggle clean cache"
|
||||||
onCheckedChange={async (enabled) => {
|
checked={data?.cleanCache || false}
|
||||||
await update({
|
onCheckedChange={async (enabled) => {
|
||||||
applicationId,
|
await update({
|
||||||
cleanCache: enabled,
|
applicationId,
|
||||||
})
|
cleanCache: enabled,
|
||||||
.then(async () => {
|
|
||||||
toast.success("Clean Cache Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error updating Clean Cache");
|
toast.success("Clean Cache Updated");
|
||||||
});
|
await refetch();
|
||||||
}}
|
})
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
.catch(() => {
|
||||||
/>
|
toast.error("Error updating Clean Cache");
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ShowProviderForm applicationId={applicationId} />
|
<ShowProviderForm applicationId={applicationId} />
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
|
|
||||||
const { data: services, isLoading: servicesLoading } =
|
const { data: services, isPending: servicesLoading } =
|
||||||
api.docker.getServiceContainersByAppName.useQuery(
|
api.docker.getServiceContainersByAppName.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: containers, isLoading: containersLoading } =
|
const { data: containers, isPending: containersLoading } =
|
||||||
api.docker.getContainersByAppNameMatch.useQuery(
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
{
|
{
|
||||||
appName,
|
appName,
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { FilePlus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
folderPath: string;
|
||||||
|
onCreate: (filename: string, content: string) => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
alwaysVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreateFileDialog = ({
|
||||||
|
folderPath,
|
||||||
|
onCreate,
|
||||||
|
onOpenChange,
|
||||||
|
alwaysVisible = false,
|
||||||
|
}: Props) => {
|
||||||
|
const [filename, setFilename] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!filename.trim()) return;
|
||||||
|
onCreate(filename.trim(), content);
|
||||||
|
setFilename("");
|
||||||
|
setContent("");
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
|
||||||
|
title="Create file"
|
||||||
|
>
|
||||||
|
<FilePlus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create file</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="filename">Filename</Label>
|
||||||
|
<Input
|
||||||
|
id="filename"
|
||||||
|
placeholder="e.g. .env.example"
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Content</Label>
|
||||||
|
<div className="h-[200px] rounded-md border">
|
||||||
|
<CodeEditor
|
||||||
|
value={content}
|
||||||
|
onChange={(v) => setContent(v ?? "")}
|
||||||
|
className="h-full"
|
||||||
|
wrapperClassName="h-[200px]"
|
||||||
|
lineWrapping
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" type="button">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="submit" disabled={!filename.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Loader2, Pencil } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
patchId: string;
|
||||||
|
entityId: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditPatchDialog = ({
|
||||||
|
patchId,
|
||||||
|
entityId,
|
||||||
|
type,
|
||||||
|
onSuccess,
|
||||||
|
}: Props) => {
|
||||||
|
const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
|
||||||
|
{ patchId },
|
||||||
|
{ enabled: !!patchId },
|
||||||
|
);
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (patch) {
|
||||||
|
setContent(patch.content);
|
||||||
|
}
|
||||||
|
}, [patch]);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const updatePatch = api.patch.update.useMutation();
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updatePatch
|
||||||
|
.mutateAsync({ patchId, content })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Patch saved");
|
||||||
|
utils.patch.byEntityId.invalidate({ id: entityId, type });
|
||||||
|
onSuccess?.();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" title="Edit patch">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4">
|
||||||
|
<DialogTitle>Edit Patch</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isPatchLoading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
|
||||||
|
<CodeEditor
|
||||||
|
value={content}
|
||||||
|
onChange={(value) => setContent(value ?? "")}
|
||||||
|
className="h-[400px] w-full"
|
||||||
|
wrapperClassName="h-[400px]"
|
||||||
|
lineWrapping
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter className="px-6 ">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button onClick={handleSave} isLoading={updatePatch.isPending}>
|
||||||
|
{updatePatch.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save } from "lucide-react";
|
import {
|
||||||
import { useCallback, useState } from "react";
|
ArrowLeft,
|
||||||
|
ChevronRight,
|
||||||
|
File,
|
||||||
|
Folder,
|
||||||
|
Loader2,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
import { CreateFileDialog } from "./create-file-dialog";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId?: string;
|
id: string;
|
||||||
composeId?: string;
|
type: "application" | "compose";
|
||||||
repoPath: string;
|
repoPath: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -22,63 +36,52 @@ type DirectoryEntry = {
|
|||||||
children?: DirectoryEntry[];
|
children?: DirectoryEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PatchEditor = ({
|
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
|
||||||
applicationId,
|
|
||||||
composeId,
|
|
||||||
repoPath,
|
|
||||||
onClose,
|
|
||||||
}: Props) => {
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [fileContent, setFileContent] = useState<string>("");
|
const [fileContent, setFileContent] = useState<string>("");
|
||||||
const [originalContent, setOriginalContent] = useState<string>("");
|
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
// Fetch directory tree
|
const utils = api.useUtils();
|
||||||
const { data: directories, isLoading: isDirLoading } =
|
const { data: directories, isPending: isDirLoading } =
|
||||||
api.patch.readRepoDirectories.useQuery(
|
api.patch.readRepoDirectories.useQuery(
|
||||||
{ applicationId, composeId, repoPath },
|
{ id: id, type, repoPath },
|
||||||
{ enabled: !!repoPath },
|
{ enabled: !!repoPath },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save mutation
|
const { data: patches } = api.patch.byEntityId.useQuery(
|
||||||
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
|
{ id, type },
|
||||||
onSuccess: (result) => {
|
{ enabled: !!id },
|
||||||
setIsSaving(false);
|
);
|
||||||
if (result.deleted) {
|
|
||||||
toast.success("No changes - patch removed");
|
const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
|
||||||
} else {
|
api.patch.saveFileAsPatch.useMutation();
|
||||||
toast.success("Patch saved");
|
|
||||||
}
|
const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
|
||||||
setOriginalContent(fileContent);
|
api.patch.markFileForDeletion.useMutation();
|
||||||
},
|
|
||||||
onError: () => {
|
const updatePatch = api.patch.update.useMutation();
|
||||||
setIsSaving(false);
|
|
||||||
toast.error("Failed to save patch");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Read file content when selected
|
|
||||||
const { data: fileData, isFetching: isFileLoading } =
|
const { data: fileData, isFetching: isFileLoading } =
|
||||||
api.patch.readRepoFile.useQuery(
|
api.patch.readRepoFile.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
id,
|
||||||
composeId,
|
type,
|
||||||
repoPath,
|
|
||||||
filePath: selectedFile || "",
|
filePath: selectedFile || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!selectedFile,
|
enabled: !!selectedFile,
|
||||||
onSuccess: (data) => {
|
|
||||||
setFileContent(data.content);
|
|
||||||
setOriginalContent(data.content);
|
|
||||||
if (data.patchError) {
|
|
||||||
toast.error(data.patchErrorMessage || "Failed to apply patch");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fileData !== undefined) {
|
||||||
|
setFileContent(fileData);
|
||||||
|
}
|
||||||
|
}, [fileData]);
|
||||||
|
|
||||||
const handleFileSelect = (filePath: string) => {
|
const handleFileSelect = (filePath: string) => {
|
||||||
setSelectedFile(filePath);
|
setSelectedFile(filePath);
|
||||||
};
|
};
|
||||||
@@ -97,17 +100,77 @@ export const PatchEditor = ({
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
setIsSaving(true);
|
saveAsPatch({
|
||||||
saveAsPatch.mutate({
|
id,
|
||||||
applicationId,
|
type,
|
||||||
composeId,
|
|
||||||
repoPath,
|
|
||||||
filePath: selectedFile,
|
filePath: selectedFile,
|
||||||
content: fileContent,
|
content: fileContent,
|
||||||
});
|
patchType: "update",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Patch saved");
|
||||||
|
utils.patch.byEntityId.invalidate({ id, type });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to save patch");
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasChanges = fileContent !== originalContent;
|
const handleMarkForDeletion = () => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
markForDeletion({ id, type, filePath: selectedFile })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("File marked for deletion");
|
||||||
|
utils.patch.byEntityId.invalidate({ id, type });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to mark file for deletion");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFile = useCallback(
|
||||||
|
(folderPath: string, filename: string, content: string) => {
|
||||||
|
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
|
||||||
|
saveAsPatch({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
filePath,
|
||||||
|
content,
|
||||||
|
patchType: "create",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("File created");
|
||||||
|
utils.patch.byEntityId.invalidate({ id, type });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to create file");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[id, type, saveAsPatch, utils],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedFilePatch = patches?.find(
|
||||||
|
(p) => p.filePath === selectedFile && p.type === "delete",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUnmarkDeletion = () => {
|
||||||
|
if (!selectedFilePatch) return;
|
||||||
|
updatePatch
|
||||||
|
.mutateAsync({
|
||||||
|
patchId: selectedFilePatch.patchId,
|
||||||
|
type: "update",
|
||||||
|
content: fileData || "",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Deletion unmarked");
|
||||||
|
utils.patch.byEntityId.invalidate({ id, type });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to unmark deletion");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = fileData !== undefined && fileContent !== fileData;
|
||||||
|
|
||||||
const renderTree = useCallback(
|
const renderTree = useCallback(
|
||||||
(entries: DirectoryEntry[], depth = 0) => {
|
(entries: DirectoryEntry[], depth = 0) => {
|
||||||
@@ -126,19 +189,33 @@ export const PatchEditor = ({
|
|||||||
if (entry.type === "directory") {
|
if (entry.type === "directory") {
|
||||||
return (
|
return (
|
||||||
<div key={entry.path}>
|
<div key={entry.path}>
|
||||||
<button
|
<div className="group flex items-center">
|
||||||
onClick={() => toggleFolder(entry.path)}
|
<button
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors`}
|
type="button"
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
onClick={() => toggleFolder(entry.path)}
|
||||||
>
|
className={
|
||||||
<ChevronRight
|
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
|
||||||
className={`h-4 w-4 transition-transform ${
|
}
|
||||||
isExpanded ? "rotate-90" : ""
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
}`}
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 shrink-0 transition-transform ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
|
<span className="truncate">{entry.name}</span>
|
||||||
|
</button>
|
||||||
|
<CreateFileDialog
|
||||||
|
folderPath={entry.path}
|
||||||
|
onCreate={(filename, content) =>
|
||||||
|
handleCreateFile(entry.path, filename, content)
|
||||||
|
}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setCreateFolderPath(open ? entry.path : null)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Folder className="h-4 w-4 text-blue-500" />
|
</div>
|
||||||
<span className="truncate">{entry.name}</span>
|
|
||||||
</button>
|
|
||||||
{isExpanded && entry.children && (
|
{isExpanded && entry.children && (
|
||||||
<div>{renderTree(entry.children, depth + 1)}</div>
|
<div>{renderTree(entry.children, depth + 1)}</div>
|
||||||
)}
|
)}
|
||||||
@@ -146,22 +223,30 @@ export const PatchEditor = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMarkedForDeletion = patches?.some(
|
||||||
|
(p) => p.filePath === entry.path && p.type === "delete",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={entry.path}
|
key={entry.path}
|
||||||
onClick={() => handleFileSelect(entry.path)}
|
onClick={() => handleFileSelect(entry.path)}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
|
||||||
isSelected ? "bg-muted" : ""
|
isSelected ? "bg-muted" : ""
|
||||||
}`}
|
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
|
||||||
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
||||||
>
|
>
|
||||||
<File className="h-4 w-4 text-muted-foreground" />
|
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate">{entry.name}</span>
|
<span className="truncate">{entry.name}</span>
|
||||||
|
{isMarkedForDeletion && (
|
||||||
|
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[expandedFolders, selectedFile],
|
[expandedFolders, selectedFile, patches, handleCreateFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -181,19 +266,68 @@ export const PatchEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<Button onClick={handleSave} disabled={isSaving || !hasChanges}>
|
<div className="flex items-center gap-2">
|
||||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{selectedFilePatch ? (
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Button
|
||||||
Save Patch
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
|
onClick={handleUnmarkDeletion}
|
||||||
|
disabled={updatePatch.isPending}
|
||||||
|
>
|
||||||
|
{updatePatch.isPending && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Unmark deletion
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleMarkForDeletion}
|
||||||
|
disabled={isMarkingDeletion}
|
||||||
|
>
|
||||||
|
{isMarkingDeletion && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Mark for deletion
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSavingPatch || !hasChanges}
|
||||||
|
>
|
||||||
|
{isSavingPatch && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Patch
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
||||||
{/* File Tree */}
|
|
||||||
<div className="border-r h-full overflow-hidden">
|
<div className="border-r h-full overflow-hidden">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-2">
|
<div className="p-2 space-y-1">
|
||||||
|
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
|
||||||
|
<CreateFileDialog
|
||||||
|
folderPath=""
|
||||||
|
alwaysVisible
|
||||||
|
onCreate={(filename, content) =>
|
||||||
|
handleCreateFile("", filename, content)
|
||||||
|
}
|
||||||
|
onOpenChange={(open) =>
|
||||||
|
setCreateFolderPath(open ? "" : null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
New file in root
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{isDirLoading ? (
|
{isDirLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
@@ -208,7 +342,6 @@ export const PatchEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
{/* Editor */}
|
|
||||||
<div className="h-full overflow-hidden relative">
|
<div className="h-full overflow-hidden relative">
|
||||||
{isFileLoading ? (
|
{isFileLoading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
@@ -216,7 +349,7 @@ export const PatchEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
) : selectedFile ? (
|
) : selectedFile ? (
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={fileContent}
|
value={fileData || ""}
|
||||||
onChange={(value) => setFileContent(value || "")}
|
onChange={(value) => setFileContent(value || "")}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
wrapperClassName="h-full"
|
wrapperClassName="h-full"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { AlertCircle, ChevronRight, File, Folder, Loader2, Power, Trash2 } from "lucide-react";
|
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -14,185 +20,199 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { RouterOutputs } from "@/utils/api";
|
import { EditPatchDialog } from "./edit-patch-dialog";
|
||||||
import { PatchEditor } from "./patch-editor";
|
import { PatchEditor } from "./patch-editor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId?: string;
|
id: string;
|
||||||
composeId?: string;
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
|
export const ShowPatches = ({ id, type }: Props) => {
|
||||||
|
|
||||||
export const ShowPatches = ({ applicationId, composeId }: Props) => {
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [repoPath, setRepoPath] = useState<string | null>(null);
|
const [repoPath, setRepoPath] = useState<string | null>(null);
|
||||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
// Fetch patches
|
const { data: patches, isPending: isPatchesLoading } =
|
||||||
// Fetch patches
|
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
|
||||||
const { data: appPatches, isLoading: isAppPatchesLoading } =
|
|
||||||
api.patch.byApplicationId.useQuery(
|
|
||||||
{ applicationId: applicationId! },
|
|
||||||
{ enabled: !!applicationId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: composePatches, isLoading: isComposePatchesLoading } =
|
const mutationMap = {
|
||||||
api.patch.byComposeId.useQuery(
|
application: () => api.patch.delete.useMutation(),
|
||||||
{ composeId: composeId! },
|
compose: () => api.patch.delete.useMutation(),
|
||||||
{ enabled: !!composeId },
|
};
|
||||||
);
|
|
||||||
|
|
||||||
const patches = applicationId ? appPatches : composePatches;
|
|
||||||
const isPatchesLoading = applicationId
|
|
||||||
? isAppPatchesLoading
|
|
||||||
: isComposePatchesLoading;
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const deletePatch = api.patch.delete.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Patch deleted");
|
|
||||||
if (applicationId) {
|
|
||||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
|
||||||
} else if (composeId) {
|
|
||||||
utils.patch.byComposeId.invalidate({ composeId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to delete patch");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const togglePatch = api.patch.toggleEnabled.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("Patch updated");
|
|
||||||
if (applicationId) {
|
|
||||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
|
||||||
} else if (composeId) {
|
|
||||||
utils.patch.byComposeId.invalidate({ composeId });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error("Failed to update patch");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ensureRepo = api.patch.ensureRepo.useMutation();
|
const ensureRepo = api.patch.ensureRepo.useMutation();
|
||||||
|
|
||||||
const handleOpenEditor = async () => {
|
const togglePatch = api.patch.toggleEnabled.useMutation();
|
||||||
setIsLoadingRepo(true);
|
|
||||||
const toastId = toast.loading("Syncing repository...");
|
|
||||||
ensureRepo.mutate(
|
|
||||||
{ applicationId, composeId },
|
|
||||||
{
|
|
||||||
onSuccess: (path) => {
|
|
||||||
setRepoPath(path);
|
|
||||||
setIsLoadingRepo(false);
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setIsLoadingRepo(false);
|
|
||||||
toast.dismiss(toastId);
|
|
||||||
toast.error("Failed to load repository");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePatch = (patchId: string) => {
|
const { mutateAsync } = mutationMap[type]
|
||||||
deletePatch.mutate({ patchId });
|
? mutationMap[type]()
|
||||||
};
|
: api.patch.delete.useMutation();
|
||||||
|
|
||||||
const handleTogglePatch = (patchId: string, enabled: boolean) => {
|
|
||||||
togglePatch.mutate({ patchId, enabled });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseEditor = () => {
|
const handleCloseEditor = () => {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
setRepoPath(null);
|
setRepoPath(null);
|
||||||
if (applicationId) {
|
|
||||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
|
||||||
} else if (composeId) {
|
|
||||||
utils.patch.byComposeId.invalidate({ composeId });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (repoPath) {
|
if (repoPath) {
|
||||||
return (
|
return (
|
||||||
<PatchEditor
|
<PatchEditor
|
||||||
applicationId={applicationId}
|
id={id}
|
||||||
composeId={composeId}
|
type={type}
|
||||||
repoPath={repoPath}
|
repoPath={repoPath || ""}
|
||||||
onClose={handleCloseEditor}
|
onClose={handleCloseEditor}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenEditor = async () => {
|
||||||
|
setIsLoadingRepo(true);
|
||||||
|
await ensureRepo
|
||||||
|
.mutateAsync({ id, type })
|
||||||
|
.then((result) => {
|
||||||
|
setRepoPath(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingRepo(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Patches</CardTitle>
|
<CardTitle>Patches</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Apply code patches to your repository during build. Patches are applied after
|
Apply code patches to your repository during build. Patches are
|
||||||
cloning the repository and before building.
|
applied after cloning the repository and before building.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
{patches && patches?.length > 0 && (
|
||||||
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||||
Create Patch
|
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
</Button>
|
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||||
|
Create Patch
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isPatchesLoading ? (
|
{isPatchesLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : !patches || patches.length === 0 ? (
|
) : patches?.length === 0 ? (
|
||||||
<Alert>
|
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<div className="rounded-full bg-muted p-4">
|
||||||
<AlertTitle>No patches</AlertTitle>
|
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
|
||||||
<AlertDescription>
|
</div>
|
||||||
No patches have been created for this application yet. Click "Create Patch"
|
<div className="space-y-1 text-center">
|
||||||
to add modifications to your code during build.
|
<p className="text-sm font-medium">No patches yet</p>
|
||||||
</AlertDescription>
|
<p className="max-w-sm text-sm text-muted-foreground">
|
||||||
</Alert>
|
Add file patches to modify your repo before each build—configs,
|
||||||
|
env, or code. Create your first patch to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||||
|
{isLoadingRepo && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||||
|
Create Patch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>File Path</TableHead>
|
<TableHead>File Path</TableHead>
|
||||||
|
<TableHead className="w-[80px]">Type</TableHead>
|
||||||
<TableHead className="w-[100px]">Enabled</TableHead>
|
<TableHead className="w-[100px]">Enabled</TableHead>
|
||||||
<TableHead className="w-[80px]">Actions</TableHead>
|
<TableHead className="w-[100px]">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{patches.map((patch: Patch) => (
|
{patches?.map((patch) => (
|
||||||
<TableRow key={patch.patchId}>
|
<TableRow key={patch.patchId}>
|
||||||
<TableCell className="font-mono text-sm">
|
<TableCell className="font-mono text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<File className="h-4 w-4 text-muted-foreground" />
|
<File className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
{patch.filePath}
|
{patch.filePath}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
patch.type === "delete"
|
||||||
|
? "destructive"
|
||||||
|
: patch.type === "create"
|
||||||
|
? "default"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="font-normal"
|
||||||
|
>
|
||||||
|
{patch.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
checked={patch.enabled}
|
checked={patch.enabled}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => {
|
||||||
handleTogglePatch(patch.patchId, checked)
|
togglePatch
|
||||||
}
|
.mutateAsync({
|
||||||
|
patchId: patch.patchId,
|
||||||
|
enabled: checked,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Patch updated");
|
||||||
|
utils.patch.byEntityId.invalidate({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoadingRepo(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{(patch.type === "update" || patch.type === "create") && (
|
||||||
size="icon"
|
<EditPatchDialog
|
||||||
onClick={() => handleDeletePatch(patch.patchId)}
|
patchId={patch.patchId}
|
||||||
>
|
entityId={id}
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
type={type}
|
||||||
</Button>
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
mutateAsync({ patchId: patch.patchId })
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Patch deleted");
|
||||||
|
utils.patch.byEntityId.invalidate({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Delete patch"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { Dices } from "lucide-react";
|
import { Dices } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isPending } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
: api.domain.create.useMutation();
|
: api.domain.create.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||||
api.domain.generateDomain.useMutation();
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
const form = useForm<Domain>({
|
||||||
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
|
|||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
form.reset({});
|
form.reset({});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data, isLoading]);
|
}, [form, form.reset, data, isPending]);
|
||||||
|
|
||||||
const dictionary = {
|
const dictionary = {
|
||||||
success: domainId ? "Domain Updated" : "Domain Created",
|
success: domainId ? "Domain Updated" : "Domain Created",
|
||||||
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||||
{dictionary.submit}
|
{dictionary.submit}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ interface Props {
|
|||||||
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||||
const { data } = api.application.one.useQuery({ applicationId });
|
const { data } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isPending } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: redeployPreviewDeployment } =
|
const { mutateAsync: redeployPreviewDeployment } =
|
||||||
@@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
refetchInterval: (data) =>
|
refetchInterval: 2000,
|
||||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
|
import { HelpCircle, Plus, Settings2, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -80,7 +80,7 @@ interface Props {
|
|||||||
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isEnabled, setIsEnabled] = useState(false);
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
const { mutateAsync: updateApplication, isLoading } =
|
const { mutateAsync: updateApplication, isPending } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
|
|
||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
@@ -535,7 +535,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-delete-application"
|
form="hook-form-delete-application"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -71,7 +71,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync: updateApplication, isLoading } =
|
const { mutateAsync: updateApplication, isPending } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
|
|
||||||
const { data: registries } = api.registry.all.useQuery();
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
@@ -212,7 +212,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
<Button type="submit" className="w-full" isLoading={isPending}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: standardSchemaResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
cronExpression: "",
|
cronExpression: "",
|
||||||
@@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [form, schedule, scheduleId]);
|
}, [form, schedule, scheduleId]);
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = scheduleId
|
const { mutateAsync, isPending } = scheduleId
|
||||||
? api.schedule.update.useMutation()
|
? api.schedule.update.useMutation()
|
||||||
: api.schedule.create.useMutation();
|
: api.schedule.create.useMutation();
|
||||||
|
|
||||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
const onSubmit = async (values: z.output<typeof formSchema>) => {
|
||||||
if (!id && !scheduleId) return;
|
if (!id && !scheduleId) return;
|
||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -662,7 +662,7 @@ echo "Hello, world!"
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
<Button type="submit" isLoading={isPending} className="w-full">
|
||||||
{scheduleId ? "Update" : "Create"} Schedule
|
{scheduleId ? "Update" : "Create"} Schedule
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
const { mutateAsync: deleteSchedule, isPending: isDeleting } =
|
||||||
api.schedule.delete.useMutation();
|
api.schedule.delete.useMutation();
|
||||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { PenBoxIcon } from "lucide-react";
|
import { PenBoxIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -43,7 +43,7 @@ interface Props {
|
|||||||
export const UpdateApplication = ({ applicationId }: Props) => {
|
export const UpdateApplication = ({ applicationId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, error, isError, isLoading } =
|
const { mutateAsync, error, isError, isPending } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
const { data } = api.application.one.useQuery(
|
const { data } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
form="hook-form-update-application"
|
form="hook-form-update-application"
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -116,7 +116,7 @@ export const HandleVolumeBackups = ({
|
|||||||
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -195,7 +195,7 @@ export const HandleVolumeBackups = ({
|
|||||||
}
|
}
|
||||||
}, [form, volumeBackup, volumeBackupId]);
|
}, [form, volumeBackup, volumeBackupId]);
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = volumeBackupId
|
const { mutateAsync, isPending } = volumeBackupId
|
||||||
? api.volumeBackups.update.useMutation()
|
? api.volumeBackups.update.useMutation()
|
||||||
: api.volumeBackups.create.useMutation();
|
: api.volumeBackups.create.useMutation();
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ export const HandleVolumeBackups = ({
|
|||||||
|
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
...values,
|
...values,
|
||||||
keepLatestCount: preparedKeepLatestCount,
|
keepLatestCount: preparedKeepLatestCount ?? undefined,
|
||||||
destinationId: values.destinationId,
|
destinationId: values.destinationId,
|
||||||
volumeBackupId: volumeBackupId || "",
|
volumeBackupId: volumeBackupId || "",
|
||||||
serviceType: volumeBackupType,
|
serviceType: volumeBackupType,
|
||||||
@@ -630,7 +630,7 @@ export const HandleVolumeBackups = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
<Button type="submit" isLoading={isPending} className="w-full">
|
||||||
{volumeBackupId ? "Update" : "Create"} Volume Backup
|
{volumeBackupId ? "Update" : "Create"} Volume Backup
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { debounce } from "lodash";
|
import debounce from "lodash/debounce";
|
||||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -53,27 +53,15 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z.object({
|
const RestoreBackupSchema = z.object({
|
||||||
destinationId: z
|
destinationId: z.string().min(1, {
|
||||||
.string({
|
message: "Destination is required",
|
||||||
required_error: "Please select a destination",
|
}),
|
||||||
})
|
backupFile: z.string().min(1, {
|
||||||
.min(1, {
|
message: "Backup file is required",
|
||||||
message: "Destination is required",
|
}),
|
||||||
}),
|
volumeName: z.string().min(1, {
|
||||||
backupFile: z
|
message: "Volume name is required",
|
||||||
.string({
|
}),
|
||||||
required_error: "Please select a backup file",
|
|
||||||
})
|
|
||||||
.min(1, {
|
|
||||||
message: "Backup file is required",
|
|
||||||
}),
|
|
||||||
volumeName: z
|
|
||||||
.string({
|
|
||||||
required_error: "Please enter a volume name",
|
|
||||||
})
|
|
||||||
.min(1, {
|
|
||||||
message: "Volume name is required",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||||
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
|
|
||||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
const form = useForm({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
backupFile: "",
|
backupFile: "",
|
||||||
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
debouncedSetSearch(value);
|
debouncedSetSearch(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
|
||||||
{
|
{
|
||||||
destinationId: destinationId,
|
destinationId: destinationId,
|
||||||
search: debouncedSearchTerm,
|
search: debouncedSearchTerm,
|
||||||
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
onValueChange={handleSearchChange}
|
onValueChange={handleSearchChange}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoading ? (
|
{isPending ? (
|
||||||
<div className="py-6 text-center text-sm">
|
<div className="py-6 text-center text-sm">
|
||||||
Loading backup files...
|
Loading backup files...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
|
||||||
api.volumeBackups.delete.useMutation();
|
api.volumeBackups.delete.useMutation();
|
||||||
const { mutateAsync: runManually } =
|
const { mutateAsync: runManually } =
|
||||||
api.volumeBackups.runManually.useMutation();
|
api.volumeBackups.runManually.useMutation();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ServiceType } from "@dokploy/server/db/schema";
|
import type { ServiceType } from "@dokploy/server/db/schema";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { Copy, Trash2 } from "lucide-react";
|
import { Copy, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -46,6 +46,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteService = ({ id, type }: Props) => {
|
export const DeleteService = ({ id, type }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canDelete = permissions?.service.delete ?? false;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
@@ -74,7 +76,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
mongo: () => api.mongo.remove.useMutation(),
|
mongo: () => api.mongo.remove.useMutation(),
|
||||||
compose: () => api.compose.delete.useMutation(),
|
compose: () => api.compose.delete.useMutation(),
|
||||||
};
|
};
|
||||||
const { mutateAsync, isLoading } = mutationMap[type]
|
const { mutateAsync, isPending } = mutationMap[type]
|
||||||
? mutationMap[type]()
|
? mutationMap[type]()
|
||||||
: api.mongo.remove.useMutation();
|
: api.mongo.remove.useMutation();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
@@ -123,6 +125,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
data?.applicationStatus === "running") ||
|
data?.applicationStatus === "running") ||
|
||||||
(data && "composeStatus" in data && data?.composeStatus === "running");
|
(data && "composeStatus" in data && data?.composeStatus === "running");
|
||||||
|
|
||||||
|
if (!canDelete) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -130,7 +134,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10 "
|
className="group hover:bg-red-500/10 "
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -228,7 +232,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isPending}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
form="hook-form-delete-compose"
|
form="hook-form-delete-compose"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ComposeActions = ({ composeId }: Props) => {
|
export const ComposeActions = ({ composeId }: Props) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canDeploy = permissions?.deployment.create ?? false;
|
||||||
|
const canUpdateService = permissions?.service.create ?? false;
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -28,169 +31,176 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
const { mutateAsync: update } = api.compose.update.useMutation();
|
const { mutateAsync: update } = api.compose.update.useMutation();
|
||||||
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
|
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
|
||||||
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
|
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
|
||||||
const { mutateAsync: start, isLoading: isStarting } =
|
const { mutateAsync: start, isPending: isStarting } =
|
||||||
api.compose.start.useMutation();
|
api.compose.start.useMutation();
|
||||||
const { mutateAsync: stop, isLoading: isStopping } =
|
const { mutateAsync: stop, isPending: isStopping } =
|
||||||
api.compose.stop.useMutation();
|
api.compose.stop.useMutation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||||
<DialogAction
|
{canDeploy && (
|
||||||
title="Deploy Compose"
|
|
||||||
description="Are you sure you want to deploy this compose?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await deploy({
|
|
||||||
composeId: composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose deployed successfully");
|
|
||||||
refetch();
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deploying compose");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
isLoading={data?.composeStatus === "running"}
|
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Rocket className="size-4 mr-1" />
|
|
||||||
Deploy
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Downloads the source code and performs a complete build</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
<DialogAction
|
|
||||||
title="Reload Compose"
|
|
||||||
description="Are you sure you want to reload this compose?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await redeploy({
|
|
||||||
composeId: composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose reloaded successfully");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error reloading compose");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
isLoading={data?.composeStatus === "running"}
|
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
|
||||||
>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<RefreshCcw className="size-4 mr-1" />
|
|
||||||
Reload
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
|
||||||
<p>Reload the compose without rebuilding it</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
{data?.composeType === "docker-compose" &&
|
|
||||||
data?.composeStatus === "idle" ? (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Start Compose"
|
title="Deploy Compose"
|
||||||
description="Are you sure you want to start this compose?"
|
description="Are you sure you want to deploy this compose?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await start({
|
await deploy({
|
||||||
composeId: composeId,
|
composeId: composeId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Compose started successfully");
|
toast.success("Compose deployed successfully");
|
||||||
refetch();
|
refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error starting compose");
|
toast.error("Error deploying compose");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="default"
|
||||||
isLoading={isStarting}
|
isLoading={data?.composeStatus === "running"}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<CheckCircle2 className="size-4 mr-1" />
|
<Rocket className="size-4 mr-1" />
|
||||||
Start
|
Deploy
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>
|
<p>
|
||||||
Start the compose (requires a previous successful build)
|
Downloads the source code and performs a complete build
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
) : (
|
)}
|
||||||
|
{canDeploy && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Stop Compose"
|
title="Reload Compose"
|
||||||
description="Are you sure you want to stop this compose?"
|
description="Are you sure you want to reload this compose?"
|
||||||
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await stop({
|
await redeploy({
|
||||||
composeId: composeId,
|
composeId: composeId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Compose stopped successfully");
|
toast.success("Compose reloaded successfully");
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error stopping compose");
|
toast.error("Error reloading compose");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="secondary"
|
||||||
isLoading={isStopping}
|
isLoading={data?.composeStatus === "running"}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Ban className="size-4 mr-1" />
|
<RefreshCcw className="size-4 mr-1" />
|
||||||
Stop
|
Reload
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPrimitive.Portal>
|
<TooltipPrimitive.Portal>
|
||||||
<TooltipContent sideOffset={5} className="z-[60]">
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
<p>Stop the currently running compose</p>
|
<p>Reload the compose without rebuilding it</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
{canDeploy &&
|
||||||
|
(data?.composeType === "docker-compose" &&
|
||||||
|
data?.composeStatus === "idle" ? (
|
||||||
|
<DialogAction
|
||||||
|
title="Start Compose"
|
||||||
|
description="Are you sure you want to start this compose?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await start({
|
||||||
|
composeId: composeId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose started successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error starting compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
isLoading={isStarting}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle2 className="size-4 mr-1" />
|
||||||
|
Start
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
|
<p>
|
||||||
|
Start the compose (requires a previous successful build)
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
) : (
|
||||||
|
<DialogAction
|
||||||
|
title="Stop Compose"
|
||||||
|
description="Are you sure you want to stop this compose?"
|
||||||
|
onClick={async () => {
|
||||||
|
await stop({
|
||||||
|
composeId: composeId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Compose stopped successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error stopping compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
isLoading={isStopping}
|
||||||
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Ban className="size-4 mr-1" />
|
||||||
|
Stop
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent sideOffset={5} className="z-[60]">
|
||||||
|
<p>Stop the currently running compose</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
))}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
@@ -205,27 +215,29 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
{canUpdateService && (
|
||||||
<span className="text-sm font-medium">Autodeploy</span>
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
<Switch
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
aria-label="Toggle autodeploy"
|
<Switch
|
||||||
checked={data?.autoDeploy || false}
|
aria-label="Toggle autodeploy"
|
||||||
onCheckedChange={async (enabled) => {
|
checked={data?.autoDeploy || false}
|
||||||
await update({
|
onCheckedChange={async (enabled) => {
|
||||||
composeId,
|
await update({
|
||||||
autoDeploy: enabled,
|
composeId,
|
||||||
})
|
autoDeploy: enabled,
|
||||||
.then(async () => {
|
|
||||||
toast.success("Auto Deploy Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error updating Auto Deploy");
|
toast.success("Auto Deploy Updated");
|
||||||
});
|
await refetch();
|
||||||
}}
|
})
|
||||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
.catch(() => {
|
||||||
/>
|
toast.error("Error updating Auto Deploy");
|
||||||
</div>
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,6 +26,8 @@ const AddComposeFile = z.object({
|
|||||||
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
type AddComposeFile = z.infer<typeof AddComposeFile>;
|
||||||
|
|
||||||
export const ComposeFileEditor = ({ composeId }: Props) => {
|
export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||||
|
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||||
|
const canUpdate = permissions?.service.create ?? false;
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -34,7 +36,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
{ enabled: !!composeId },
|
{ enabled: !!composeId },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
const { mutateAsync, isPending } = api.compose.update.useMutation();
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
|
||||||
const form = useForm<AddComposeFile>({
|
const form = useForm<AddComposeFile>({
|
||||||
@@ -93,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [form, onSubmit, isLoading]);
|
}, [form, onSubmit, isPending]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -164,14 +166,16 @@ services:
|
|||||||
</Form>
|
</Form>
|
||||||
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
<div className="flex justify-between flex-col lg:flex-row gap-2">
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
<div className="w-full flex flex-col lg:flex-row gap-4 items-end" />
|
||||||
<Button
|
{canUpdate && (
|
||||||
type="submit"
|
<Button
|
||||||
form="hook-form-save-compose-file"
|
type="submit"
|
||||||
isLoading={isLoading}
|
form="hook-form-save-compose-file"
|
||||||
className="lg:w-fit w-full"
|
isLoading={isPending}
|
||||||
>
|
className="lg:w-fit w-full"
|
||||||
Save
|
>
|
||||||
</Button>
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user